Skip to content
4 changes: 2 additions & 2 deletions scripts/lib/codex-app-server-watchdog-proxy.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createProgressWatchdog } from "./agent-progress-watchdog.mjs";
import { createProgressWatchdog } from "./watchdog-investigation-progress.mjs";
import { observeCodexAppServerMessage } from "./codex-progress-watchdog.mjs";

const STREAM_WATCHDOG_DEFAULTS = Object.freeze({
Expand Down Expand Up @@ -119,4 +119,4 @@ export function createAppServerWatchdogRouter(options = {}) {
onServerMessage,
activeTurnCount: () => turns.size,
};
}
}
58 changes: 57 additions & 1 deletion scripts/lib/codex-progress-watchdog.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createProgressWatchdog } from "./agent-progress-watchdog.mjs";
import { createProgressWatchdog } from "./watchdog-investigation-progress.mjs";
import {
classifyAppServerItem,
isSuccessfulAppServerItem,
Expand Down Expand Up @@ -94,6 +94,43 @@ function resetMicroNarration(context) {
context.microNarrationIntentCount = 0;
}

function appServerEvidence(item = {}) {
if (item?.type === "commandExecution") {
return {
toolName: "commandExecution",
input: {
command: item.command ?? item.commandText ?? item.process?.command ?? "",
},
response:
item.aggregatedOutput ??
item.output ??
item.stdout ??
item.result ??
item.text ??
item.content ??
"",
};
}
return {
toolName:
item?.appContext?.actionName ||
item?.toolName ||
item?.tool ||
item?.name ||
item?.server ||
"",
input: item?.arguments ?? item?.input ?? {},
response:
item?.aggregatedOutput ??
item?.output ??
item?.stdout ??
item?.result ??
item?.text ??
item?.content ??
"",
};
}

function observeMicroNarration(delta, context) {
if (typeof delta !== "string" || delta.length === 0) return { action: "allow" };
const current = `${context.microNarrationBuffer || ""}${delta}`;
Expand Down Expand Up @@ -189,6 +226,14 @@ export function observeCodexAppServerMessage(watchdog, message, context = {}) {
}
const classification = classifyAppServerItem(item);
if (classification.kind === "evidence") {
if (typeof watchdog.prepareEvidenceAttempt === "function") {
const evidence = appServerEvidence(item);
watchdog.prepareEvidenceAttempt({
toolName: evidence.toolName,
input: evidence.input,
volatility: classification.volatility || "stable",
});
}
const decision = watchdog.chargeEvidenceAttempt();
if (decision.action === "block") {
return maybeInterrupt(
Expand All @@ -212,6 +257,17 @@ export function observeCodexAppServerMessage(watchdog, message, context = {}) {
} else if (classification.kind === "execution") {
watchdog.recordExecutionProgress({ kind: "codex_execution_completed" });
resetMicroNarration(context);
} else if (
classification.kind === "evidence" &&
typeof watchdog.recordEvidenceResult === "function"
) {
const evidence = appServerEvidence(item);
watchdog.recordEvidenceResult({
toolName: evidence.toolName,
input: evidence.input,
volatility: classification.volatility || "stable",
response: evidence.response,
});
}
}
return { decision: { action: "allow" } };
Expand Down
10 changes: 9 additions & 1 deletion scripts/lib/codex-watchdog-hook.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createProgressWatchdog } from "./agent-progress-watchdog.mjs";
import { createProgressWatchdog } from "./watchdog-investigation-progress.mjs";
import {
createEvidenceRegistry,
deriveShellEvidenceDescriptor,
Expand Down Expand Up @@ -34,6 +34,7 @@ function hydrate(state, options) {
volatileReadIntervalMs: options.volatileReadIntervalMs,
evidenceSoftLimit: options.evidenceSoftLimit,
evidenceHardLimit: options.evidenceHardLimit,
investigationCreditLimit: options.investigationCreditLimit,
};
if (options.generatedCharSoftLimit !== undefined) {
watchdogOptions.generatedCharSoftLimit = options.generatedCharSoftLimit;
Expand Down Expand Up @@ -198,6 +199,7 @@ export function evaluateCodexHook(input, state = {}, options = {}) {
maxSubagentInputChars: options.maxSubagentInputChars ?? 6_000,
evidenceSoftLimit: options.evidenceSoftLimit ?? 8,
evidenceHardLimit: options.evidenceHardLimit ?? 12,
investigationCreditLimit: options.investigationCreditLimit,
maxNarrationRecoveryAttempts:
options.maxNarrationRecoveryAttempts ?? DEFAULT_MAX_NARRATION_RECOVERY_ATTEMPTS,
generatedCharSoftLimit: stopFinalizationCandidate
Expand Down Expand Up @@ -285,6 +287,12 @@ export function evaluateCodexHook(input, state = {}, options = {}) {
} else if (classification.kind === "execution") {
watchdog.recordExecutionProgress({ kind: "tool_execution_completed", toolName: input.tool_name });
} else if (classification.kind === "evidence" && !responseExplicitlyFailed(input.tool_response)) {
watchdog.recordEvidenceResult({
toolName: input.tool_name,
input: input.tool_input,
volatility: classification.volatility || "stable",
response: input.tool_response,
});
const descriptor = shellEvidenceDescriptor(input);
if (descriptor) {
evidenceRegistry.record({
Expand Down
187 changes: 187 additions & 0 deletions scripts/lib/watchdog-investigation-progress.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { posix } from "node:path";

import { createProgressWatchdog as createBaseProgressWatchdog } from "./agent-progress-watchdog.mjs";

const DEFAULT_INVESTIGATION_CREDIT_LIMIT = 4;
const SOURCE_READ_COMMAND = /\b(?:get-content|cat|type)\b\s+(?:(?:-path|-literalpath)\s+)?(?:"([^"]+)"|'([^']+)'|([^\s|;&]+))/i;
const SOURCE_PATH = /\.(?:[cm]?[jt]sx?|json|ya?ml|toml|md|ps1|cs|csproj|fs|go|rs|py|rb|php|java|kt|kts|swift|c|cc|cpp|cxx|h|hh|hpp|sh|bash|zsh)$/i;

function asText(value) {
if (typeof value === "string") return value;
try {
return JSON.stringify(value ?? null);
} catch {
return String(value ?? "");
}
}

function normalizeSourcePath(value) {
let path = String(value || "").trim().replace(/^['"]|['"]$/g, "").replace(/\\/g, "/");
if (!path || /^https?:\/\//i.test(path)) return null;
path = posix.normalize(path).replace(/^\.\//, "");
return SOURCE_PATH.test(path) ? path : null;
}

function sourceReadTarget(toolName, input = {}) {
const direct = input?.path ?? input?.file_path ?? input?.filePath ?? input?.filename;
const directTarget = normalizeSourcePath(direct);
if (directTarget) return directTarget;

const name = String(toolName || "");
if (name !== "Bash" && !/(?:^|__)shell(?:_|$)/i.test(name) && name !== "commandExecution") {
return null;
}
const command = String(input?.command ?? input?.commandText ?? "");
const match = command.match(SOURCE_READ_COMMAND);
return normalizeSourcePath(match?.[1] || match?.[2] || match?.[3]);
}

function resolveReference(currentTarget, reference) {
const raw = String(reference || "").trim().replace(/\\/g, "/");
if (!raw || !SOURCE_PATH.test(raw)) return null;
if (raw.startsWith("./") || raw.startsWith("../")) {
return normalizeSourcePath(posix.join(posix.dirname(currentTarget), raw));
}
return normalizeSourcePath(raw);
}

function referencedSourceTargets(response, currentTarget) {
if (!currentTarget) return [];
const text = asText(response);
const found = new Set();
const patterns = [
/\b(?:from|require\s*\(|import\s*\()\s*["']([^"']+)["']/g,
/["']((?:\.\.?\/|[A-Za-z0-9_.-]+\/)[^"']+\.[A-Za-z0-9]+)["']/g,
];
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const target = resolveReference(currentTarget, match[1]);
if (target) found.add(target);
}
}
return [...found].sort();
}

function positiveInteger(value, fallback) {
return Number.isInteger(value) && value > 0 ? value : fallback;
}

function nonNegativeInteger(value) {
return Number.isInteger(value) && value >= 0 ? value : 0;
}

export function createProgressWatchdog(options = {}) {
const base = createBaseProgressWatchdog(options);
const persistedCreditLimit = positiveInteger(
options.investigationCreditLimitPersisted,
DEFAULT_INVESTIGATION_CREDIT_LIMIT,
);
const creditLimit = positiveInteger(options.investigationCreditLimit, persistedCreditLimit);
let creditGeneration = Number.isInteger(options.investigationCreditGeneration)
? options.investigationCreditGeneration
: base.snapshot().stateGeneration;
let creditsUsed = nonNegativeInteger(options.investigationCreditsUsed);
let creditedSinceHydration = 0;
let referencedTargets = new Set(
Array.isArray(options.investigationReferencedTargets)
? options.investigationReferencedTargets.map(normalizeSourcePath).filter(Boolean)
: [],
);
let pendingCreditFingerprint = null;

function syncGeneration() {
const generation = base.snapshot().stateGeneration;
if (generation !== creditGeneration) {
creditGeneration = generation;
creditsUsed = 0;
referencedTargets = new Set();
pendingCreditFingerprint = null;
}
return generation;
}

function decideRead(options = {}) {
const decision = base.decideRead(options);
syncGeneration();
if (options.record === false) {
pendingCreditFingerprint = null;
if (decision.action === "allow" && options.volatility !== "volatile") {
const target = sourceReadTarget(options.toolName, options.input);
if (target && referencedTargets.has(target) && creditsUsed < creditLimit) {
pendingCreditFingerprint = decision.fingerprint;
}
}
}
return decision;
}

function prepareEvidenceAttempt({ toolName, input, volatility = "stable" } = {}) {
syncGeneration();
pendingCreditFingerprint = null;
if (volatility === "volatile" || creditsUsed >= creditLimit) return false;
const target = sourceReadTarget(toolName, input);
if (!target || !referencedTargets.has(target)) return false;
pendingCreditFingerprint = `prepared:${target}`;
return true;
}

function chargeEvidenceAttempt() {
syncGeneration();
if (pendingCreditFingerprint && creditsUsed < creditLimit) {
creditsUsed += 1;
creditedSinceHydration += 1;
pendingCreditFingerprint = null;
const current = base.snapshot();
return {
action: "allow",
reason: "dependency_following_investigation_progress",
investigationProgress: true,
investigationCreditsUsed: creditsUsed,
investigationCreditLimit: creditLimit,
consecutiveEvidenceAttempts: current.consecutiveEvidenceAttempts,
totalEvidenceAttempts: current.totalEvidenceAttempts + creditedSinceHydration,
};
}
const decision = base.chargeEvidenceAttempt();
return {
...decision,
totalEvidenceAttempts: decision.totalEvidenceAttempts + creditedSinceHydration,
investigationCreditsUsed: creditsUsed,
investigationCreditLimit: creditLimit,
};
}

function recordEvidenceResult({ toolName, input, volatility = "stable", response } = {}) {
syncGeneration();
if (volatility === "volatile") {
referencedTargets = new Set();
return [];
}
const target = sourceReadTarget(toolName, input);
referencedTargets = new Set(referencedSourceTargets(response, target));
return [...referencedTargets];
}

function snapshot() {
syncGeneration();
const current = base.snapshot();
return {
...current,
totalEvidenceAttempts: current.totalEvidenceAttempts + creditedSinceHydration,
investigationCreditGeneration: creditGeneration,
investigationCreditsUsed: creditsUsed,
investigationCreditLimit: creditLimit,
investigationCreditLimitPersisted: creditLimit,
investigationReferencedTargets: [...referencedTargets].sort(),
};
}

return {
...base,
decideRead,
prepareEvidenceAttempt,
chargeEvidenceAttempt,
recordEvidenceResult,
snapshot,
};
}
Loading
Loading