refactor(agents): split CLI runner execution (#113716)

* refactor(agents): split CLI runner execution

* refactor(agents): keep CLI log helper private
This commit is contained in:
Peter Steinberger
2026-07-25 08:07:41 -07:00
committed by GitHub
parent f1538c6d6c
commit 22226bf2f6
7 changed files with 1964 additions and 1965 deletions

View File

@@ -394,7 +394,6 @@ src/agents/cli-runner.spawn.test.ts
src/agents/cli-runner.ts
src/agents/cli-runner/claude-live-session.ts
src/agents/cli-runner/execute.supervisor-capture.test.ts
src/agents/cli-runner/execute.ts
src/agents/cli-runner/prepare.test.ts
src/agents/cli-runner/prepare.ts
src/agents/code-mode-namespaces.ts

View File

@@ -0,0 +1,21 @@
import { invokeNodeClaudeCliRun } from "../../gateway/node-agent-cli-runtime.js";
import { requestHeartbeat as requestHeartbeatImpl } from "../../infra/heartbeat-wake.js";
import { enqueueSystemEvent as enqueueSystemEventImpl } from "../../infra/system-events.js";
import { getProcessSupervisor as getProcessSupervisorImpl } from "../../process/supervisor/index.js";
import {
registerExecApprovalRequestForHostOrThrow,
resolveRegisteredExecApprovalDecision,
} from "../bash-tools.exec-approval-request.js";
import { writeCliSystemPromptFile } from "./helpers.js";
export const executeDeps = {
getProcessSupervisor: getProcessSupervisorImpl,
enqueueSystemEvent: enqueueSystemEventImpl,
requestHeartbeat: requestHeartbeatImpl,
writeCliSystemPromptFile,
invokeNodeClaudeCliRun,
registerExecApprovalRequestForHostOrThrow,
resolveRegisteredExecApprovalDecision,
};
export type CliExecuteDeps = typeof executeDeps;

View File

@@ -0,0 +1,300 @@
import { emitAgentEvent } from "../../infra/agent-events.js";
import { emitTrustedDiagnosticEvent } from "../../infra/diagnostic-events.js";
import type {
CliPlanUpdate,
CliStreamingDelta,
CliThinkingDelta,
CliThinkingProgress,
CliToolUseStartDelta,
} from "../cli-output.js";
import { sanitizeToolArgs, sanitizeToolResult } from "../embedded-agent-subscribe.tools.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
import { resolveCliToolTerminalReason } from "../run-termination.js";
import type { CliToolTracking } from "./execute-tool-tracking.js";
import type { PreparedCliRunContext } from "./types.js";
type CliToolResult = {
toolCallId: string;
name: string;
isError: boolean;
result?: unknown;
};
export function createCliEventHandlers(params: {
context: PreparedCliRunContext;
toolTracking: CliToolTracking;
getRunState: () => { failed: boolean; error: unknown };
}) {
const context = params.context;
const runParams = context.params;
const emitLiveEvents = runParams.executionMode !== "side-question";
let observedCliActivity = false;
let signaledToolExecutionStarted = false;
let signaledAssistantOutputStarted = false;
let commentaryCounter = 0;
const activeParsedTools = new Map<
string,
{ startedAt: number; toolName: string; kind: CliToolUseStartDelta["kind"] }
>();
const emitCliToolUseStart = (event: CliToolUseStartDelta) => {
observedCliActivity = true;
if (!signaledToolExecutionStarted) {
signaledToolExecutionStarted = true;
runParams.onExecutionPhase?.({
phase: "tool_execution_started",
provider: runParams.provider,
model: context.modelId,
backend: context.backendResolved.id,
});
}
params.toolTracking.handleCliToolUseStart(event);
if (emitLiveEvents) {
emitAgentEvent({
runId: runParams.runId,
stream: "tool",
data: {
phase: "start",
name: event.name,
toolCallId: event.toolCallId,
args: sanitizeToolArgs(event.args),
},
});
}
};
const emitCliToolResult = (event: CliToolResult) => {
observedCliActivity = true;
params.toolTracking.handleCliToolResult(event);
if (emitLiveEvents) {
emitAgentEvent({
runId: runParams.runId,
stream: "tool",
data: {
phase: "result",
name: event.name,
toolCallId: event.toolCallId,
isError: event.isError,
result: sanitizeToolResult(event.result),
},
});
}
};
const emitParsedToolUseStart = (event: CliToolUseStartDelta) => {
const startedAt = Date.now();
activeParsedTools.set(event.toolCallId, {
startedAt,
toolName: event.name,
kind: event.kind,
});
emitTrustedDiagnosticEvent({
type: "tool.execution.started",
runId: runParams.runId,
sessionId: runParams.sessionId,
...(runParams.sessionKey ? { sessionKey: runParams.sessionKey } : {}),
...(runParams.agentId ? { agentId: runParams.agentId } : {}),
toolName: event.name,
toolSource: event.name.startsWith("mcp__") ? "mcp" : "core",
toolOwner: "cli-runner",
toolCallId: event.toolCallId,
});
emitCliToolUseStart(event);
};
const emitParsedToolTerminal = (event: {
toolCallId: string;
name: string;
isError: boolean;
incomplete?: boolean;
}) => {
const activeTool = activeParsedTools.get(event.toolCallId);
activeParsedTools.delete(event.toolCallId);
const trustedOutcome = params.toolTracking.resolveCliLoopbackTerminalOutcome(event.toolCallId);
const toolName = activeTool?.toolName ?? event.name;
const now = Date.now();
const trustedTerminalReason =
trustedOutcome &&
trustedOutcome.outcome !== "blocked" &&
trustedOutcome.outcome !== "completed" &&
trustedOutcome.outcome !== "unknown"
? trustedOutcome.outcome
: undefined;
const runState = params.getRunState();
const terminalReason =
trustedTerminalReason ??
resolveCliToolTerminalReason({
error: event.incomplete ? runState.error : undefined,
abortSignal: runParams.abortSignal,
});
// Incomplete client/MCP tools inherit the enclosing failed run even when
// the loopback disconnect is ambiguous. Server-native tools do not.
const useEnclosingTerminalReason =
event.incomplete &&
runState.failed &&
activeTool !== undefined &&
activeTool.kind !== "server_tool_use";
const diagnosticBase = {
runId: runParams.runId,
sessionId: runParams.sessionId,
...(runParams.sessionKey ? { sessionKey: runParams.sessionKey } : {}),
...(runParams.agentId ? { agentId: runParams.agentId } : {}),
toolName,
toolSource: toolName.startsWith("mcp__") ? ("mcp" as const) : ("core" as const),
toolOwner: "cli-runner",
toolCallId: event.toolCallId,
durationMs: Math.max(0, now - (activeTool?.startedAt ?? now)),
};
if (trustedOutcome?.outcome === "unknown" && !useEnclosingTerminalReason) {
emitTrustedDiagnosticEvent({
type: "tool.execution.error",
...diagnosticBase,
errorCategory: "cli_tool_ambiguous",
errorCode: "tool_outcome_unknown",
});
return;
}
if (event.incomplete && activeTool?.kind === "server_tool_use" && !trustedOutcome) {
emitTrustedDiagnosticEvent({
type: "tool.execution.error",
...diagnosticBase,
errorCategory: "cli_tool_ambiguous",
errorCode: "tool_outcome_unknown",
});
return;
}
const trustedFailure = trustedOutcome !== undefined && trustedOutcome.outcome !== "completed";
emitTrustedDiagnosticEvent(
trustedOutcome?.outcome === "blocked"
? {
type: "tool.execution.blocked",
...diagnosticBase,
deniedReason: trustedOutcome.deniedReason,
reason: "blocked by before-tool policy",
}
: trustedFailure || (!trustedOutcome && event.isError)
? {
type: "tool.execution.error",
...diagnosticBase,
errorCategory:
terminalReason === "cancelled"
? "aborted"
: event.incomplete && (!trustedOutcome || useEnclosingTerminalReason)
? "cli_tool_incomplete"
: "cli_tool",
terminalReason,
}
: { type: "tool.execution.completed", ...diagnosticBase },
);
};
const emitParsedToolResult = (event: CliToolResult) => {
emitParsedToolTerminal(event);
emitCliToolResult(event);
};
const finalizeParsedTools = () => {
for (const [toolCallId, activeTool] of Array.from(activeParsedTools)) {
emitParsedToolTerminal({
toolCallId,
name: activeTool.toolName,
isError: true,
incomplete: true,
});
}
};
const emitCliCommentaryText = (text: string) => {
if (!emitLiveEvents) {
return;
}
commentaryCounter += 1;
emitAgentEvent({
runId: runParams.runId,
stream: "item",
data: {
kind: "preamble",
itemId: `commentary-${runParams.runId}-${commentaryCounter}`,
phase: "update",
title: "commentary",
status: "running",
progressText: applyPluginTextReplacements(
text,
context.backendResolved.textTransforms?.output,
),
},
});
};
const emitCliAssistantDelta = ({ text, delta }: CliStreamingDelta) => {
if (text || delta) {
observedCliActivity = true;
if (!signaledAssistantOutputStarted) {
signaledAssistantOutputStarted = true;
runParams.onExecutionPhase?.({
phase: "assistant_output_started",
provider: runParams.provider,
model: context.modelId,
backend: context.backendResolved.id,
});
}
}
if (emitLiveEvents) {
emitAgentEvent({
runId: runParams.runId,
stream: "assistant",
data: {
text: applyPluginTextReplacements(text, context.backendResolved.textTransforms?.output),
delta: applyPluginTextReplacements(delta, context.backendResolved.textTransforms?.output),
},
});
}
};
// Emit-always: thinking reaches the event bus and session archive like the
// embedded reasoning stream; /reasoning and /verbose gate presentation only.
const emitCliThinkingDelta = ({ text, delta, isReasoningSnapshot }: CliThinkingDelta) => {
if (text || delta) {
observedCliActivity = true;
}
if (emitLiveEvents) {
emitAgentEvent({
runId: runParams.runId,
stream: "thinking",
data: { text, delta, ...(isReasoningSnapshot ? { isReasoningSnapshot } : {}) },
});
}
};
const emitCliThinkingProgress = ({ progressTokens }: CliThinkingProgress) => {
observedCliActivity = true;
if (emitLiveEvents) {
emitAgentEvent({
runId: runParams.runId,
stream: "thinking",
data: { progressTokens },
});
}
};
const emitCliPlanUpdate = ({ steps }: CliPlanUpdate) => {
observedCliActivity = true;
if (emitLiveEvents) {
emitAgentEvent({
runId: runParams.runId,
stream: "plan",
data: { phase: "update", title: "Plan updated", source: "codex-exec", steps },
});
}
};
return {
emitLiveEvents,
emitCliToolUseStart,
emitCliToolResult,
emitParsedToolUseStart,
emitParsedToolResult,
finalizeParsedTools,
emitCliCommentaryText,
emitCliAssistantDelta,
emitCliThinkingDelta,
emitCliThinkingProgress,
emitCliPlanUpdate,
hasObservedCliActivity: () => observedCliActivity,
activeParsedToolCount: () => activeParsedTools.size,
};
}
export type CliEventHandlers = ReturnType<typeof createCliEventHandlers>;

View File

@@ -0,0 +1,210 @@
import crypto from "node:crypto";
import type { CliReusableSession, PreparedCliRunContext } from "./types.js";
function buildCliLogArgs(params: {
args: string[];
systemPromptArg?: string;
modelArg?: string;
imageArg?: string;
argsPrompt?: string;
}): string[] {
const logArgs: string[] = [];
for (let i = 0; i < params.args.length; i += 1) {
const arg = params.args[i] ?? "";
if (arg === params.systemPromptArg) {
const systemPromptValue = params.args[i + 1] ?? "";
logArgs.push(arg, `<systemPrompt:${systemPromptValue.length} chars>`);
i += 1;
continue;
}
if (arg === params.modelArg) {
logArgs.push(arg, params.args[i + 1] ?? "");
i += 1;
continue;
}
if (arg === params.imageArg) {
logArgs.push(arg, "<image>");
i += 1;
continue;
}
logArgs.push(arg);
}
if (params.argsPrompt) {
const promptIndex = logArgs.indexOf(params.argsPrompt);
if (promptIndex >= 0) {
logArgs[promptIndex] = `<prompt:${params.argsPrompt.length} chars>`;
}
}
return logArgs;
}
const CLI_ENV_AUTH_LOG_KEYS = [
"AI_GATEWAY_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_OLD",
"ANTHROPIC_API_TOKEN",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_CUSTOM_HEADERS",
"ANTHROPIC_OAUTH_TOKEN",
"ANTHROPIC_UNIX_SOCKET",
"AZURE_OPENAI_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST",
"OPENAI_API_KEY",
"OPENAI_STEIPETE_API_KEY",
"OPENROUTER_API_KEY",
] as const;
const CLI_ENV_RUNTIME_LOG_KEYS = ["GEMINI_CLI_HOME", "GEMINI_CLI_SYSTEM_SETTINGS_PATH"] as const;
export const CLAUDE_SELECTED_AUTH_ENV_KEYS = new Set([
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
]);
export const NODE_CLAUDE_FORWARD_ENV_KEYS = new Set(["CLAUDE_CODE_AUTO_COMPACT_WINDOW"]);
export function resolveNodeClaudeAuthEnv(context: PreparedCliRunContext): Record<string, string> {
const secretInput = context.preparedBackend.secretInput;
if (!secretInput) {
return {};
}
const descriptorEnv = context.preparedBackend.env ?? {};
const requestEnv = Object.hasOwn(descriptorEnv, "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR")
? "CLAUDE_CODE_OAUTH_TOKEN"
: Object.hasOwn(descriptorEnv, "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR")
? "ANTHROPIC_API_KEY"
: undefined;
if (!requestEnv) {
return {};
}
const data = secretInput.createData();
try {
return { [requestEnv]: data.toString("utf8") };
} finally {
data.fill(0);
}
}
export const CLI_BACKEND_PRESERVE_ENV = "OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV";
export function parseCliBackendPreserveEnv(raw: string | undefined): Set<string> {
const trimmed = raw?.trim();
if (!trimmed) {
return new Set();
}
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed) as unknown;
return new Set(
Array.isArray(parsed)
? parsed.filter((entry): entry is string => typeof entry === "string")
: [],
);
} catch {
return new Set();
}
}
return new Set(
trimmed
.split(/[,\s]+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
);
}
function listPresentCliEnvKeys(
env: Record<string, string | undefined>,
keys: readonly string[],
): string[] {
return keys.filter((key) => {
const value = env[key];
return typeof value === "string" && value.length > 0;
});
}
function formatCliEnvKeyList(keys: readonly string[]): string {
return keys.length > 0 ? keys.join(",") : "none";
}
function buildCliEnvMcpLog(childEnv: Record<string, string>): string {
return [
`token=${childEnv.OPENCLAW_MCP_TOKEN ? "set" : "missing"}`,
`capture=${childEnv.OPENCLAW_MCP_CLI_CAPTURE_KEY ? "set" : "missing"}`,
].join(" ");
}
function fingerprintCliSessionId(sessionId?: string): string {
const trimmed = sessionId?.trim();
if (!trimmed) {
return "none";
}
return crypto.createHash("sha256").update(trimmed).digest("hex").slice(0, 12);
}
function formatCliSessionReuseLogState(reusableSession: CliReusableSession): string {
switch (reusableSession.mode) {
case "reuse":
return "reusable";
case "reuse-with-drift":
return `reusable-drift:${reusableSession.drift.reasons.join(",")}`;
case "invalidate":
return `invalidated:${reusableSession.invalidatedReason}`;
case "none":
return "none";
}
const exhaustive: never = reusableSession;
return exhaustive;
}
/** Builds the compact execution summary logged before a CLI backend run. */
export function buildCliExecLogLine(params: {
provider: string;
model: string;
promptChars: number;
trigger?: string;
useResume: boolean;
cliSessionId?: string;
resolvedSessionId?: string;
reusableSession: CliReusableSession;
hasHistoryPrompt: boolean;
}): string {
return [
`cli exec: provider=${params.provider}`,
`model=${params.model}`,
`promptChars=${params.promptChars}`,
`trigger=${params.trigger ?? "unknown"}`,
`useResume=${params.useResume ? "true" : "false"}`,
`session=${params.cliSessionId ? "present" : "none"}`,
`resumeSession=${params.useResume ? fingerprintCliSessionId(params.resolvedSessionId) : "none"}`,
`reuse=${formatCliSessionReuseLogState(params.reusableSession)}`,
`historyPrompt=${params.hasHistoryPrompt ? "present" : "none"}`,
].join(" ");
}
/** Summarizes auth-related env keys preserved or cleared for a CLI child process. */
export function buildCliEnvAuthLog(childEnv: Record<string, string>): string {
const hostKeys = listPresentCliEnvKeys(process.env, CLI_ENV_AUTH_LOG_KEYS);
const childKeys = listPresentCliEnvKeys(childEnv, CLI_ENV_AUTH_LOG_KEYS);
const childKeySet = new Set(childKeys);
const clearedKeys = hostKeys.filter((key) => !childKeySet.has(key));
const runtimeHostKeys = listPresentCliEnvKeys(process.env, CLI_ENV_RUNTIME_LOG_KEYS);
const runtimeChildKeys = listPresentCliEnvKeys(childEnv, CLI_ENV_RUNTIME_LOG_KEYS);
const runtimeChildKeySet = new Set(runtimeChildKeys);
const runtimeClearedKeys = runtimeHostKeys.filter((key) => !runtimeChildKeySet.has(key));
return [
`host=${formatCliEnvKeyList(hostKeys)}`,
`child=${formatCliEnvKeyList(childKeys)}`,
`cleared=${formatCliEnvKeyList(clearedKeys)}`,
`runtimeHost=${formatCliEnvKeyList(runtimeHostKeys)}`,
`runtimeChild=${formatCliEnvKeyList(runtimeChildKeys)}`,
`runtimeCleared=${formatCliEnvKeyList(runtimeClearedKeys)}`,
].join(" ");
}
export function logCliInvocation(params: {
args: string[];
command: string;
env: Record<string, string>;
systemPromptArg?: string;
modelArg?: string;
imageArg?: string;
argsPrompt?: string;
log: (message: string) => void;
}): void {
const logArgs = buildCliLogArgs(params);
params.log(`cli argv: ${params.command} ${logArgs.join(" ")}`);
params.log(`cli env auth: ${buildCliEnvAuthLog(params.env)}`);
if (params.env.OPENCLAW_MCP_TOKEN) {
params.log(`cli env mcp: ${buildCliEnvMcpLog(params.env)}`);
}
}

View File

@@ -0,0 +1,537 @@
import crypto from "node:crypto";
import { shouldLogVerbose } from "../../globals.js";
import {
resolveEventSessionKeyForPolicy,
resolveEventSessionRoutingPolicy,
scopedHeartbeatWakeOptionsForPolicy,
} from "../../infra/event-session-routing.js";
import type { CliBackendConfig } from "../../plugins/cli-backend.types.js";
import type { RunExit } from "../../process/supervisor/types.js";
import {
createCliJsonlStreamingParser,
extractCliErrorMessage,
parseCliOutput,
type CliOutput,
} from "../cli-output.js";
import { classifyFailoverReason } from "../embedded-agent-helpers.js";
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
import { runClaudeLiveSessionTurn } from "./claude-live-session.js";
import type { CliExecuteDeps } from "./execute-deps.js";
import type { CliEventHandlers } from "./execute-events.js";
import {
createCliAbortError,
executeNodeClaudeRun,
type resolveNodeClaudePlacement,
} from "./execute-node-claude.js";
import { appendCliOutputTail } from "./execute-output-buffer.js";
import type { CliToolTracking } from "./execute-tool-tracking.js";
import { buildCliSupervisorScopeKey } from "./helpers.js";
import { cliBackendLog, formatCliBackendOutputDigest } from "./log.js";
import type { createClaudeCliModelCallDiagnostics } from "./model-call-diagnostics.js";
import { createCliOutputFailoverError } from "./output-error.js";
import type { PreparedCliRunContext } from "./types.js";
const CLI_RUNNER_OUTPUT_PARSE_BYTES = 1024 * 1024;
function appendCliOutputParseBuffer(buffer: Buffer, chunk: string) {
if (!chunk) {
return { buffer, exceeded: false };
}
const chunkBuffer = Buffer.from(chunk);
if (buffer.byteLength + chunkBuffer.byteLength <= CLI_RUNNER_OUTPUT_PARSE_BYTES) {
return {
buffer: Buffer.concat([buffer, chunkBuffer], buffer.byteLength + chunkBuffer.byteLength),
exceeded: false,
};
}
const remainingBytes = CLI_RUNNER_OUTPUT_PARSE_BYTES - buffer.byteLength;
return {
buffer:
remainingBytes <= 0
? buffer
: Buffer.concat(
[buffer, chunkBuffer.subarray(0, remainingBytes)],
CLI_RUNNER_OUTPUT_PARSE_BYTES,
),
exceeded: true,
};
}
type ExecuteCliProcessOptions = {
onPhase?: (phase: "send" | "resolve" | "cleanup") => void;
};
export async function executeCliProcess(params: {
context: PreparedCliRunContext;
backend: CliBackendConfig;
deps: CliExecuteDeps;
events: CliEventHandlers;
toolTracking: CliToolTracking;
diagnostics: ReturnType<typeof createClaudeCliModelCallDiagnostics>;
nodePlacement: ReturnType<typeof resolveNodeClaudePlacement>;
nodeSystemPrompt?: string;
nodeEnv?: Record<string, string>;
nodeClearEnv?: string[];
useManagedClaudeLiveSession: boolean;
useResume: boolean;
cliSessionIdToUse?: string;
resolvedSessionId?: string;
executionCommand: string;
executionLeadingArgv: readonly string[];
executionArgs: string[];
env: Record<string, string>;
prompt: string;
argsPrompt?: string;
stdin?: string;
noOutputTimeoutMs: number;
outputMode: CliBackendConfig["output"];
logOutputText: boolean;
cliTurnStartedAt: number;
fallbackCleanup?: () => Promise<void>;
claimFallbackCleanup: () => void;
observeForkSuccessor: (sessionId: string) => void;
options?: ExecuteCliProcessOptions;
}): Promise<CliOutput> {
const context = params.context;
const runParams = context.params;
const failoverContext = {
provider: runParams.provider,
model: context.modelId,
sessionId: runParams.sessionId,
lane: runParams.lane,
};
const outputErrorContext = { ...failoverContext, runId: runParams.runId };
const hasJsonlOutput = params.outputMode === "jsonl";
if (params.useManagedClaudeLiveSession) {
if (!hasJsonlOutput) {
throw new Error("Claude live session requires JSONL streaming parser");
}
runParams.onExecutionPhase?.({
phase: "process_spawned",
provider: runParams.provider,
model: context.modelId,
backend: context.backendResolved.id,
});
params.claimFallbackCleanup();
const liveResult = await runClaudeLiveSessionTurn({
context,
args: params.executionArgs,
executableCommand: params.executionCommand,
executableLeadingArgv: params.executionLeadingArgv,
env: params.env,
prompt: params.prompt,
useResume: params.useResume,
forceNewSession:
params.cliSessionIdToUse === undefined && context.openClawHistoryPrompt !== undefined,
requiredSessionGeneration: params.cliSessionIdToUse
? context.requiredClaudeLiveSessionGeneration
: undefined,
noOutputTimeoutMs: params.noOutputTimeoutMs,
getProcessSupervisor: params.deps.getProcessSupervisor,
onAssistantDelta: params.events.emitCliAssistantDelta,
onThinkingDelta: params.events.emitCliThinkingDelta,
onThinkingProgress: params.events.emitCliThinkingProgress,
onToolUseStart: params.events.emitCliToolUseStart,
onToolResult: params.events.emitCliToolResult,
resolveToolResultTerminalOutcome: (event) => {
const outcome = params.toolTracking.resolveCliLoopbackTerminalOutcome(event.toolCallId);
return outcome?.outcome === "completed" ? undefined : outcome;
},
onCommentaryText:
params.events.emitLiveEvents && runParams.emitCommentaryText
? params.events.emitCliCommentaryText
: undefined,
onMcpCaptureReady: params.toolTracking.beginGatewayCapture,
cleanup: async () => {
await params.fallbackCleanup?.();
},
onSessionId: params.observeForkSuccessor,
onAssistantMessage: params.diagnostics?.observeAssistantMessage,
onUsage: params.diagnostics?.observeUsage,
onCliOutput: params.diagnostics?.observeCliOutput,
onRequestPayload: params.diagnostics?.observeRequestPayload,
onPhase: params.options?.onPhase,
});
params.options?.onPhase?.("resolve");
const rawText = liveResult.output.text;
return {
...liveResult.output,
rawText,
finalPromptText: params.prompt,
text: applyPluginTextReplacements(rawText, context.backendResolved.textTransforms?.output),
};
}
const streamingParser = hasJsonlOutput
? createCliJsonlStreamingParser({
backend: params.backend,
providerId: context.backendResolved.id,
onAssistantDelta: params.events.emitCliAssistantDelta,
onThinkingDelta: params.events.emitCliThinkingDelta,
onThinkingProgress: params.events.emitCliThinkingProgress,
onPlanUpdate: params.events.emitCliPlanUpdate,
onToolUseStart: params.events.emitParsedToolUseStart,
onToolResult: params.events.emitParsedToolResult,
onCommentaryText:
params.events.emitLiveEvents && runParams.emitCommentaryText
? params.events.emitCliCommentaryText
: undefined,
onSessionId: params.observeForkSuccessor,
onAssistantMessage: params.diagnostics?.observeAssistantMessage,
onUsage: params.diagnostics?.observeUsage,
})
: null;
let stdoutTail = "";
let stdoutParseBuffer: Buffer = Buffer.alloc(0);
let stdoutBytes = 0;
const stdoutHash = crypto.createHash("sha256");
let stdoutParseExceeded = false;
let stderrTail = "";
let stderrParseBuffer: Buffer = Buffer.alloc(0);
let stderrBytes = 0;
const stderrHash = crypto.createHash("sha256");
let stderrParseExceeded = false;
const consumeStdout = (chunk: string) => {
const chunkBytes = Buffer.byteLength(chunk);
params.diagnostics?.observeCliOutput(chunk, "stdout", chunkBytes);
stdoutBytes += chunkBytes;
stdoutHash.update(chunk);
stdoutTail = appendCliOutputTail(stdoutTail, chunk);
if (!stdoutParseExceeded) {
const next = appendCliOutputParseBuffer(stdoutParseBuffer, chunk);
stdoutParseBuffer = next.buffer;
stdoutParseExceeded = next.exceeded;
}
streamingParser?.push(chunk);
};
const consumeStderr = (chunk: string) => {
params.diagnostics?.observeCliOutput(chunk, "stderr");
stderrBytes += Buffer.byteLength(chunk);
stderrHash.update(chunk);
stderrTail = appendCliOutputTail(stderrTail, chunk);
if (!stderrParseExceeded) {
const next = appendCliOutputParseBuffer(stderrParseBuffer, chunk);
stderrParseBuffer = next.buffer;
stderrParseExceeded = next.exceeded;
}
};
runParams.onExecutionPhase?.({
phase: "process_spawned",
provider: runParams.provider,
model: context.modelId,
backend: context.backendResolved.id,
});
let managedRunPid: number | undefined;
let nodeRunAbortSignal: AbortSignal | undefined;
let nodeRunTruncated = false;
let result: RunExit;
params.diagnostics?.observeRequestPayload(params.stdin ?? params.argsPrompt ?? "");
if (params.nodePlacement) {
const nodeRun = await executeNodeClaudeRun({
context,
nodePlacement: params.nodePlacement,
executionArgs: params.executionArgs,
stdinPayload: params.stdin ?? "",
...(params.nodeSystemPrompt !== undefined
? { nodeSystemPrompt: params.nodeSystemPrompt }
: {}),
...(params.nodeEnv ? { nodeEnv: params.nodeEnv } : {}),
...(params.nodeClearEnv ? { nodeClearEnv: params.nodeClearEnv } : {}),
noOutputTimeoutMs: params.noOutputTimeoutMs,
consumeStdout,
consumeStderr,
deps: params.deps,
});
result = nodeRun.result;
nodeRunAbortSignal = nodeRun.nodeRunAbortSignal;
nodeRunTruncated = nodeRun.nodeRunTruncated;
} else {
const supervisor = params.deps.getProcessSupervisor();
const scopeKey = buildCliSupervisorScopeKey({
backend: params.backend,
backendId: context.backendResolved.id,
cliSessionId: params.useResume ? params.resolvedSessionId : undefined,
});
const managedRun = await supervisor.spawn({
sessionId: runParams.sessionId,
backendId: context.backendResolved.id,
scopeKey,
replaceExistingScope: Boolean(params.useResume && scopeKey),
mode: "child",
argv: [params.executionCommand, ...params.executionLeadingArgv, ...params.executionArgs],
timeoutMs: runParams.timeoutMs,
noOutputTimeoutMs: params.noOutputTimeoutMs,
cwd: context.cwd ?? context.workspaceDir,
env: params.env,
input: params.stdin ?? "",
secretInput: context.preparedBackend.secretInput,
captureOutput: false,
onStdout: consumeStdout,
onStderr: consumeStderr,
});
managedRunPid = managedRun.pid;
let replyBackendCompleted = false;
const replyBackendHandle = runParams.replyOperation
? {
kind: "cli" as const,
cancel: () => managedRun.cancel("manual-cancel"),
isStreaming: () => !replyBackendCompleted,
}
: undefined;
if (replyBackendHandle) {
runParams.replyOperation?.attachBackend(replyBackendHandle);
}
const abortManagedRun = () => managedRun.cancel("manual-cancel");
runParams.abortSignal?.addEventListener("abort", abortManagedRun, { once: true });
if (runParams.abortSignal?.aborted) {
abortManagedRun();
}
try {
result = await managedRun.wait();
} finally {
replyBackendCompleted = true;
if (replyBackendHandle) {
runParams.replyOperation?.detachBackend(replyBackendHandle);
}
runParams.abortSignal?.removeEventListener("abort", abortManagedRun);
}
}
if (
(runParams.abortSignal?.aborted || nodeRunAbortSignal?.aborted) &&
result.reason === "manual-cancel"
) {
throw createCliAbortError();
}
params.options?.onPhase?.("resolve");
streamingParser?.finish();
const streamingParserErrorText =
params.outputMode === "jsonl" ? (streamingParser?.getErrorText() ?? null) : null;
if (streamingParserErrorText) {
throw new FailoverError(streamingParserErrorText, {
reason: "format",
...failoverContext,
status: resolveFailoverStatus("format"),
});
}
// The node re-injects the terminal result after truncation. If even that is
// missing, the turn outcome is unknowable and cannot pass as a clean exit.
if (
nodeRunTruncated &&
result.exitCode === 0 &&
!result.timedOut &&
!streamingParser?.getOutput()
) {
throw new FailoverError(
"paired node truncated the Claude CLI stream before the terminal result; refusing to accept partial output.",
{
reason: "format",
...failoverContext,
status: resolveFailoverStatus("format"),
},
);
}
const stdout = stdoutParseBuffer.toString("utf8").trim();
const stdoutDiagnostic = stdoutTail.trim();
const stderr = stderrParseBuffer.toString("utf8").trim();
const stderrDiagnostic = stderrTail.trim();
const processDiagnostics = {
backendId: context.backendResolved.id,
processReason: result.reason,
exitCode: result.exitCode,
exitSignal: result.exitSignal,
durationMs: result.durationMs,
stdoutBytes,
stdoutHash: stdoutHash.digest("hex").slice(0, 12),
stderrBytes,
stderrHash: stderrHash.digest("hex").slice(0, 12),
useResume: params.useResume,
};
if (params.logOutputText) {
if (stdoutDiagnostic) {
cliBackendLog.info(`cli stdout:\n${stdoutDiagnostic}`);
}
if (stderrDiagnostic) {
cliBackendLog.info(`cli stderr:\n${stderrDiagnostic}`);
}
}
if (shouldLogVerbose()) {
if (stdoutDiagnostic) {
cliBackendLog.debug(`cli stdout:\n${stdoutDiagnostic}`);
}
if (stderrDiagnostic) {
cliBackendLog.debug(`cli stderr:\n${stderrDiagnostic}`);
}
}
const streamedJsonlOutput =
params.outputMode === "jsonl" ? (streamingParser?.getOutput() ?? null) : null;
const parsedStructuredOutput =
streamedJsonlOutput ??
(params.outputMode === "json" && !stdoutParseExceeded
? parseCliOutput({
raw: stdout,
backend: params.backend,
providerId: context.backendResolved.id,
outputMode: params.outputMode,
fallbackSessionId: params.resolvedSessionId,
})
: null);
// A completed terminal record is authoritative even if the CLI hangs
// afterward. Reclassifying it as a timeout could replay completed tools.
if (parsedStructuredOutput?.terminalFailure) {
const terminalError = createCliOutputFailoverError({
output: parsedStructuredOutput,
...outputErrorContext,
});
if (terminalError) {
throw terminalError;
}
}
if (result.exitCode !== 0 || result.reason !== "exit") {
params.options?.onPhase?.("send");
if (result.reason === "no-output-timeout" || result.noOutputTimedOut) {
const timeoutSeconds = Math.round(params.noOutputTimeoutMs / 1000);
cliBackendLog.warn(
`cli watchdog timeout: provider=${runParams.provider} model=${context.modelId} session=${params.resolvedSessionId ?? runParams.sessionId} noOutputTimeoutMs=${params.noOutputTimeoutMs} pid=${managedRunPid ?? "node"}`,
);
const retryable =
!params.events.hasObservedCliActivity() && !stdoutDiagnostic && !stderrDiagnostic;
const deferNotice =
retryable &&
Boolean(params.cliSessionIdToUse) &&
Boolean(params.resolvedSessionId) &&
Boolean(context.openClawHistoryPrompt) &&
Boolean(runParams.sessionKey) &&
runParams.timeoutMs - (Date.now() - context.started) > 0;
if (runParams.sessionKey && params.events.emitLiveEvents && !deferNotice) {
const stallNotice = [
`CLI agent (${runParams.provider}) produced no output for ${timeoutSeconds}s and was terminated.`,
"It may have been waiting for interactive input or an approval prompt.",
...(params.nodePlacement
? ["Check the node's Claude permission settings for pending prompts."]
: ["For Claude Code, prefer --permission-mode bypassPermissions --print."]),
].join(" ");
const routing = resolveEventSessionRoutingPolicy({
cfg: runParams.config,
sessionKey: runParams.sessionKey,
channel: runParams.messageProvider,
accountId: runParams.agentAccountId,
});
params.deps.enqueueSystemEvent(stallNotice, {
sessionKey: resolveEventSessionKeyForPolicy(runParams.sessionKey, routing),
});
params.deps.requestHeartbeat(
scopedHeartbeatWakeOptionsForPolicy(
runParams.sessionKey,
{ source: "cli-watchdog", intent: "event", reason: "cli:watchdog:stall" },
routing,
),
);
}
throw new FailoverError(`CLI produced no output for ${timeoutSeconds}s and was terminated.`, {
reason: "timeout",
...failoverContext,
status: resolveFailoverStatus("timeout"),
code: retryable ? "cli_no_output_timeout" : undefined,
cliTimeout: {
mode: "no-output",
timeoutSeconds,
observedActivity: params.events.hasObservedCliActivity(),
activeToolCount: params.events.activeParsedToolCount(),
backgroundTaskCount: 0,
},
});
}
if (result.reason === "overall-timeout") {
const timeoutSeconds = Math.round(runParams.timeoutMs / 1000);
throw new FailoverError(`CLI exceeded timeout (${timeoutSeconds}s) and was terminated.`, {
reason: "timeout",
...failoverContext,
status: resolveFailoverStatus("timeout"),
code: "cli_overall_timeout",
cliTimeout: {
mode: "overall",
timeoutSeconds,
observedActivity: params.events.hasObservedCliActivity(),
activeToolCount: params.events.activeParsedToolCount(),
backgroundTaskCount: 0,
},
});
}
const errorCandidates = [stderr, stdout, stderrDiagnostic, stdoutDiagnostic].filter(Boolean);
const structuredError =
errorCandidates.map((candidate) => extractCliErrorMessage(candidate)).find(Boolean) ?? null;
let classifiedErrorText = structuredError;
let reason = structuredError
? classifyFailoverReason(structuredError, { provider: runParams.provider })
: null;
if (!reason) {
for (const candidate of errorCandidates) {
reason = classifyFailoverReason(candidate, { provider: runParams.provider });
if (reason) {
classifiedErrorText = candidate;
break;
}
}
}
const errorText = structuredError || classifiedErrorText || errorCandidates[0] || "CLI failed.";
reason ??= "unknown";
const retryCode =
reason === "context_overflow"
? "cli_context_overflow"
: reason === "unknown" &&
result.reason === "exit" &&
errorCandidates.length === 0 &&
!params.events.hasObservedCliActivity()
? "cli_unknown_empty_failure"
: undefined;
throw new FailoverError(errorText, {
reason,
...failoverContext,
status: resolveFailoverStatus(reason),
code: retryCode,
});
}
if (stdoutParseExceeded && !streamedJsonlOutput) {
throw new FailoverError(
`CLI stdout exceeded ${CLI_RUNNER_OUTPUT_PARSE_BYTES} bytes; refusing to parse truncated output.`,
{
reason: "format",
...failoverContext,
status: resolveFailoverStatus("format"),
},
);
}
const parsed =
parsedStructuredOutput ??
parseCliOutput({
raw: stdout,
backend: params.backend,
providerId: context.backendResolved.id,
outputMode: params.outputMode,
fallbackSessionId: params.resolvedSessionId,
});
const parsedError = createCliOutputFailoverError({
output: parsed,
...outputErrorContext,
});
if (parsedError) {
throw parsedError;
}
const rawText = parsed.text;
cliBackendLog.info(
`cli turn: provider=${runParams.provider} model=${context.modelId} durationMs=${Date.now() - params.cliTurnStartedAt} ${formatCliBackendOutputDigest(rawText)}`,
);
return {
...parsed,
diagnostics: { ...parsed.diagnostics, process: processDiagnostics },
rawText,
finalPromptText: params.prompt,
text: applyPluginTextReplacements(rawText, context.backendResolved.textTransforms?.output),
};
}

View File

@@ -0,0 +1,622 @@
import { isDeepStrictEqual } from "node:util";
import {
beginMcpLoopbackToolCallCapture,
clearMcpLoopbackToolCallCapture,
type McpLoopbackToolCallStart,
type McpLoopbackToolCallTerminalOutcome,
waitForMcpLoopbackToolCallCaptureIdle,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { shouldUseInternalSourceReplySink } from "../../infra/outbound/internal-source-reply.js";
import type { CliOutput, CliToolUseStartDelta } from "../cli-output.js";
import {
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
} from "../embedded-agent-message-tool-source-reply.js";
import {
isMessagingTool,
isMessagingToolDeliveryAction,
isMessagingToolSendAction,
} from "../embedded-agent-messaging.js";
import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
} from "../embedded-agent-messaging.types.js";
import {
extractMessagingToolSendResult,
extractMessagingToolSourceReplyPayload,
} from "../embedded-agent-subscribe.tools.js";
import { rotateClaudeLiveMcpCaptureKeyForContext } from "./claude-live-session.js";
import { attachCliMessagingDeliveryEvidence } from "./delivery-evidence.js";
import {
appendUniqueCliMessagingEvidence,
buildMessagingToolSendEvidenceKey,
CLI_MESSAGING_EVIDENCE_MAX_CALLS,
extractCliMessagingContent,
extractCliMessagingTarget,
normalizeCliMessagingToolName,
} from "./execute-messaging.js";
import type { PreparedCliRunContext } from "./types.js";
const CLI_LOOPBACK_CORRELATION_MAX_CALLS = 64;
const CLI_MCP_DELIVERY_DRAIN_GRACE_MS = 5_000;
const CLI_MCP_REQUEST_ADMISSION_GRACE_MS = 250;
type CliToolTerminalOutcome = McpLoopbackToolCallTerminalOutcome | { outcome: "completed" };
type CliLoopbackCall = {
admitted: McpLoopbackToolCallStart;
current: McpLoopbackToolCallStart;
boundToolCallId?: string;
outcome?: CliToolTerminalOutcome;
ambiguous: boolean;
ambiguityGroup?: CliLoopbackAmbiguityGroup;
};
type CliLoopbackAmbiguityGroup = {
calls: Set<CliLoopbackCall>;
activeToolCallIds: Set<string>;
};
type ActiveCliTool = {
toolName: string;
args: Record<string, unknown>;
loopbackCall?: CliLoopbackCall;
loopbackAmbiguous: boolean;
ambiguityGroup?: CliLoopbackAmbiguityGroup;
};
export function createCliToolTracking(context: PreparedCliRunContext) {
let gatewayCaptureKey: string | undefined;
let yielded = false;
let didSendViaMessagingTool = false;
let didDeliverSourceReplyViaMessageTool = false;
let inFlightUnclassifiedMcpRequests = 0;
let inFlightMessagingToolCalls = 0;
const inFlightPreparedMessagingCalls = new Set<McpLoopbackToolCallStart>();
const pendingMessagingCalls = new Map<
string,
{ toolName: string; args: Record<string, unknown>; target?: MessagingToolSend }
>();
const cliLoopbackCalls: CliLoopbackCall[] = [];
const activeCliTools = new Map<string, ActiveCliTool>();
let cliLoopbackCorrelationOverflowed = false;
const messagingToolSentTexts: string[] = [];
const messagingToolSentTextKeys = new Set<string>();
const messagingToolSentMediaUrls: string[] = [];
const messagingToolSentMediaUrlKeys = new Set<string>();
const messagingToolSentTargets: MessagingToolSend[] = [];
const messagingToolSentTargetKeys = new Set<string>();
const messagingToolSourceReplyPayloads: MessagingToolSourceReplyPayload[] = [];
const matchesCliLoopbackCall = (
toolName: string,
toolArgs: Record<string, unknown>,
call: McpLoopbackToolCallStart,
) =>
normalizeCliMessagingToolName(toolName) === call.toolName &&
isDeepStrictEqual(toolArgs, call.args);
const markCliLoopbackCallsAmbiguous = (
calls: CliLoopbackCall[],
activeEntries = Array.from(activeCliTools.entries()).filter(
([, activeTool]) =>
activeTool.loopbackCall !== undefined && calls.includes(activeTool.loopbackCall),
),
) => {
const groups = new Set<CliLoopbackAmbiguityGroup>();
for (const call of calls) {
if (call.ambiguityGroup) {
groups.add(call.ambiguityGroup);
}
}
for (const [, activeTool] of activeEntries) {
if (activeTool.ambiguityGroup) {
groups.add(activeTool.ambiguityGroup);
}
}
const group = groups.values().next().value ?? {
calls: new Set<CliLoopbackCall>(),
activeToolCallIds: new Set<string>(),
};
for (const existing of groups) {
if (existing === group) {
continue;
}
for (const call of existing.calls) {
call.ambiguityGroup = group;
group.calls.add(call);
}
for (const toolCallId of existing.activeToolCallIds) {
const activeTool = activeCliTools.get(toolCallId);
if (activeTool) {
activeTool.ambiguityGroup = group;
group.activeToolCallIds.add(toolCallId);
}
}
existing.calls.clear();
existing.activeToolCallIds.clear();
}
for (const call of calls) {
call.ambiguous = true;
call.ambiguityGroup = group;
group.calls.add(call);
}
for (const [toolCallId, activeTool] of activeEntries) {
activeTool.loopbackAmbiguous = true;
activeTool.ambiguityGroup = group;
group.activeToolCallIds.add(toolCallId);
}
};
const matchingActiveCliTools = (call: McpLoopbackToolCallStart): Array<[string, ActiveCliTool]> =>
Array.from(activeCliTools.entries()).filter(([, activeTool]) =>
matchesCliLoopbackCall(activeTool.toolName, activeTool.args, call),
);
const markCliLoopbackSignatureAmbiguous = (call: McpLoopbackToolCallStart) => {
const calls = cliLoopbackCalls.filter((candidate) =>
matchesCliLoopbackCall(call.toolName, call.args, candidate.admitted),
);
markCliLoopbackCallsAmbiguous(calls, matchingActiveCliTools(call));
};
const retainCliLoopbackCall = (call: McpLoopbackToolCallStart) => {
if (cliLoopbackCalls.length >= CLI_LOOPBACK_CORRELATION_MAX_CALLS) {
cliLoopbackCorrelationOverflowed = true;
for (const activeTool of activeCliTools.values()) {
if (activeTool.loopbackCall || activeTool.toolName.startsWith("mcp__")) {
activeTool.loopbackAmbiguous = true;
}
}
cliLoopbackCalls.length = 0;
return undefined;
}
const retained: CliLoopbackCall = { admitted: call, current: call, ambiguous: false };
cliLoopbackCalls.push(retained);
return retained;
};
const bindCliLoopbackCall = (
call: CliLoopbackCall,
toolCallId: string,
activeTool: ActiveCliTool,
) => {
call.boundToolCallId = toolCallId;
activeTool.loopbackCall = call;
activeTool.loopbackAmbiguous ||= call.ambiguous;
if (call.ambiguityGroup) {
activeTool.ambiguityGroup = call.ambiguityGroup;
call.ambiguityGroup.activeToolCallIds.add(toolCallId);
}
};
const removeCliLoopbackCall = (call: CliLoopbackCall | undefined) => {
if (!call) {
return;
}
const index = cliLoopbackCalls.indexOf(call);
if (index >= 0) {
cliLoopbackCalls.splice(index, 1);
}
};
const retireCliLoopbackCorrelation = (
toolCallId: string,
activeTool: ActiveCliTool | undefined,
) => {
removeCliLoopbackCall(activeTool?.loopbackCall);
const group = activeTool?.ambiguityGroup;
if (!group) {
return;
}
group.activeToolCallIds.delete(toolCallId);
const hasUnboundCall = Array.from(group.calls).some(
(call) => call.boundToolCallId === undefined && cliLoopbackCalls.includes(call),
);
if (group.activeToolCallIds.size > 0 || hasUnboundCall) {
return;
}
// An ambiguous group owns unbound captures too. Retire the whole group
// once its parsed tools finish so stale calls cannot poison later tools.
for (const call of group.calls) {
removeCliLoopbackCall(call);
}
group.calls.clear();
};
const commitMessagingToolResult = (params: {
toolName: string;
target?: MessagingToolSend;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;
}) => {
if (!isDeliveredMessagingToolResult(params)) {
return;
}
didSendViaMessagingTool = true;
const toolArgs = params.args ?? {};
const isMessagingSend = isMessagingToolSendAction(params.toolName, toolArgs);
const content = isMessagingSend ? extractCliMessagingContent(toolArgs, params.result) : {};
if (isMessagingSend) {
appendUniqueCliMessagingEvidence(
messagingToolSentTexts,
messagingToolSentTextKeys,
content.text ? [content.text] : [],
);
appendUniqueCliMessagingEvidence(
messagingToolSentMediaUrls,
messagingToolSentMediaUrlKeys,
content.mediaUrls ?? [],
);
if (
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: context.params.sourceReplyDeliveryMode,
toolName: params.toolName,
args: params.args,
result: params.result,
isError: params.isError,
})
) {
didDeliverSourceReplyViaMessageTool = true;
const payload = extractMessagingToolSourceReplyPayload(params.result);
if (payload) {
if (messagingToolSourceReplyPayloads.length >= CLI_MESSAGING_EVIDENCE_MAX_CALLS) {
messagingToolSourceReplyPayloads.shift();
}
// Each internal source-reply send is a distinct delivery, even when
// two intentional sends have identical text or media.
messagingToolSourceReplyPayloads.push(payload);
}
}
}
if (!params.target) {
return;
}
const targetWithContent = {
...extractMessagingToolSendResult(params.target, params.result),
...content,
};
const evidenceKey = buildMessagingToolSendEvidenceKey(targetWithContent);
if (messagingToolSentTargetKeys.has(evidenceKey)) {
return;
}
if (messagingToolSentTargets.length >= CLI_MESSAGING_EVIDENCE_MAX_CALLS) {
const removed = messagingToolSentTargets.shift();
if (removed) {
messagingToolSentTargetKeys.delete(buildMessagingToolSendEvidenceKey(removed));
}
}
messagingToolSentTargets.push(targetWithContent);
messagingToolSentTargetKeys.add(evidenceKey);
};
const isPreparedInternalSourceReply = async (call: McpLoopbackToolCallStart) => {
if (
context.params.sourceReplyDeliveryMode !== "message_tool_only" ||
normalizeCliMessagingToolName(call.toolName) !== "message" ||
call.args.action !== "send" ||
!context.params.config
) {
return false;
}
return await shouldUseInternalSourceReplySink(
{
cfg: context.params.config,
action: "send",
sessionKey: context.params.sessionKey,
sourceReplyDeliveryMode: context.params.sourceReplyDeliveryMode,
toolContext: {
currentChannelProvider: context.params.messageChannel ?? context.params.messageProvider,
currentChannelId: context.params.currentChannelId,
currentThreadTs: context.params.currentThreadTs,
currentMessageId: context.params.currentMessageId,
},
},
call.args,
);
};
const beginGatewayCapture = (captureKey: string | undefined) => {
if (!captureKey || gatewayCaptureKey === captureKey) {
return;
}
if (gatewayCaptureKey) {
throw new Error("CLI MCP capture key changed during an active attempt");
}
context.preparedBackend.mcpClientGrantCapture?.activate(captureKey);
gatewayCaptureKey = captureKey;
const isPotentialDelivery = (toolName: string) =>
isMessagingTool(normalizeCliMessagingToolName(toolName));
const isPreparedDelivery = (toolName: string, toolArgs: Record<string, unknown>) =>
toolArgs.dryRun !== true &&
isMessagingToolDeliveryAction(normalizeCliMessagingToolName(toolName), toolArgs);
beginMcpLoopbackToolCallCapture({
captureKey,
onYield: () => {
yielded = true;
},
onRequestStart: () => {
inFlightUnclassifiedMcpRequests += 1;
},
onRequestClassified: () => {
inFlightUnclassifiedMcpRequests = Math.max(0, inFlightUnclassifiedMcpRequests - 1);
},
onToolCallStart: (call) => {
const retained = retainCliLoopbackCall(call);
const candidates = matchingActiveCliTools(call);
// Parallel same-name calls can reach the loopback out of stream order.
// Bind only a unique name+arguments match; ambiguity is safer than a wrong outcome.
let matched =
retained &&
candidates.length === 1 &&
!candidates[0]?.[1].loopbackCall &&
!candidates[0]?.[1].loopbackAmbiguous
? candidates[0]
: undefined;
if (retained && matched) {
bindCliLoopbackCall(retained, matched[0], matched[1]);
} else if (retained && candidates.length > 0) {
markCliLoopbackSignatureAmbiguous(call);
matched = candidates.find(([, activeTool]) => !activeTool.loopbackCall);
if (matched) {
bindCliLoopbackCall(retained, matched[0], matched[1]);
}
}
if (isPotentialDelivery(call.toolName)) {
inFlightMessagingToolCalls += 1;
}
return matched?.[0];
},
onToolCallUpdate: ({ previous, current }) => {
const candidates = cliLoopbackCalls.filter((candidate) =>
matchesCliLoopbackCall(previous.toolName, previous.args, candidate.current),
);
const candidate = candidates.at(0);
if (candidates.length === 1 && candidate && !candidate.ambiguous) {
candidate.current = current;
} else if (candidates.length > 0) {
markCliLoopbackCallsAmbiguous(candidates);
}
inFlightPreparedMessagingCalls.delete(previous);
const wasDelivery = isPotentialDelivery(previous.toolName);
const isDelivery = isPreparedDelivery(current.toolName, current.args);
if (wasDelivery !== isDelivery) {
inFlightMessagingToolCalls = Math.max(
0,
inFlightMessagingToolCalls + (isDelivery ? 1 : -1),
);
}
if (isDelivery) {
inFlightPreparedMessagingCalls.add(current);
}
},
onToolCallFinish: (call, { prepared }) => {
const isDelivery = prepared
? isPreparedDelivery(call.toolName, call.args)
: isPotentialDelivery(call.toolName);
if (isDelivery) {
inFlightMessagingToolCalls = Math.max(0, inFlightMessagingToolCalls - 1);
}
inFlightPreparedMessagingCalls.delete(call);
},
onToolCallResult: (call) => {
const terminalOutcome: CliToolTerminalOutcome =
call.outcome === "blocked"
? { outcome: call.outcome, deniedReason: call.deniedReason }
: { outcome: call.outcome };
const correlated = call.correlationId
? cliLoopbackCalls.find((candidate) => candidate.boundToolCallId === call.correlationId)
: undefined;
const candidates = correlated
? [correlated]
: cliLoopbackCalls.filter((candidate) =>
matchesCliLoopbackCall(call.toolName, call.args, candidate.current),
);
if (candidates.length === 1 && candidates[0]) {
candidates[0].outcome = terminalOutcome;
} else if (candidates.length > 1) {
markCliLoopbackCallsAmbiguous(candidates);
}
const toolName = normalizeCliMessagingToolName(call.toolName);
if (isMessagingToolDeliveryAction(toolName, call.args)) {
commitMessagingToolResult({
toolName,
target: extractCliMessagingTarget(context, toolName, call.args),
args: call.args,
result: "result" in call ? call.result : undefined,
isError: call.outcome !== "completed",
});
}
},
});
};
const handleCliToolUseStart = (event: CliToolUseStartDelta) => {
if (event.kind !== "server_tool_use") {
const activeTool: ActiveCliTool = {
toolName: event.name,
args: event.args,
loopbackAmbiguous: cliLoopbackCorrelationOverflowed && event.name.startsWith("mcp__"),
};
activeCliTools.set(event.toolCallId, activeTool);
const admittedCall = {
toolName: normalizeCliMessagingToolName(event.name),
args: event.args,
};
const pendingCandidates = cliLoopbackCalls.filter(
(candidate) =>
candidate.boundToolCallId === undefined &&
matchesCliLoopbackCall(event.name, event.args, candidate.admitted),
);
const hasAssociatedPeer = matchingActiveCliTools(admittedCall).some(
([toolCallId, peer]) =>
toolCallId !== event.toolCallId &&
(peer.loopbackCall !== undefined || peer.loopbackAmbiguous),
);
const pending = pendingCandidates[0];
if (hasAssociatedPeer || pendingCandidates.length > 1 || pending?.ambiguous) {
markCliLoopbackSignatureAmbiguous(admittedCall);
if (pending) {
bindCliLoopbackCall(pending, event.toolCallId, activeTool);
}
} else if (pendingCandidates.length === 1 && pending) {
bindCliLoopbackCall(pending, event.toolCallId, activeTool);
}
}
const toolName = normalizeCliMessagingToolName(event.name);
if (
event.kind === "server_tool_use" ||
gatewayCaptureKey ||
event.args.dryRun === true ||
!isMessagingToolDeliveryAction(toolName, event.args)
) {
return;
}
if (pendingMessagingCalls.size >= CLI_MESSAGING_EVIDENCE_MAX_CALLS) {
const oldestToolCallId = pendingMessagingCalls.keys().next().value;
if (oldestToolCallId !== undefined) {
pendingMessagingCalls.delete(oldestToolCallId);
// Once an unresolved send is evicted, its later result cannot be correlated.
// Fail closed so a failed turn cannot duplicate it.
didSendViaMessagingTool = true;
}
}
pendingMessagingCalls.set(event.toolCallId, {
toolName,
args: event.args,
target: extractCliMessagingTarget(context, toolName, event.args),
});
};
const handleCliToolResult = (event: {
toolCallId: string;
name: string;
isError: boolean;
result?: unknown;
}) => {
const activeTool = activeCliTools.get(event.toolCallId);
activeCliTools.delete(event.toolCallId);
retireCliLoopbackCorrelation(event.toolCallId, activeTool);
const pending = pendingMessagingCalls.get(event.toolCallId);
if (pending) {
pendingMessagingCalls.delete(event.toolCallId);
commitMessagingToolResult({
toolName: pending.toolName,
target: pending.target,
args: pending.args,
result: event.result,
isError: event.isError,
});
}
};
const resolveCliLoopbackTerminalOutcome = (toolCallId: string) => {
const activeTool = activeCliTools.get(toolCallId);
if (activeTool?.loopbackAmbiguous) {
return { outcome: "unknown" } as const;
}
return activeTool?.loopbackCall?.outcome;
};
const finishDeliveryTracking = async (params: {
useManagedClaudeLiveSession: boolean;
recordRunError: (error: unknown) => void;
}) => {
try {
if (!gatewayCaptureKey && pendingMessagingCalls.size > 0) {
const calls = Array.from(pendingMessagingCalls.values());
const internalStates = await Promise.all(calls.map(isPreparedInternalSourceReply));
if (internalStates.some((internal) => !internal)) {
didSendViaMessagingTool = true;
params.recordRunError(
new Error("CLI JSONL message tool call remained unresolved after exit"),
);
} else {
params.recordRunError(
new Error("CLI JSONL source reply call remained unresolved after exit"),
);
}
}
if (!gatewayCaptureKey) {
return;
}
const captureBecameIdle = await waitForMcpLoopbackToolCallCaptureIdle(gatewayCaptureKey, {
timeoutMs: CLI_MCP_DELIVERY_DRAIN_GRACE_MS,
admissionGraceMs: CLI_MCP_REQUEST_ADMISSION_GRACE_MS,
});
if (captureBecameIdle) {
return;
}
if (params.useManagedClaudeLiveSession) {
await rotateClaudeLiveMcpCaptureKeyForContext(context);
}
const internalStates = await Promise.all(
Array.from(inFlightPreparedMessagingCalls).map(isPreparedInternalSourceReply),
);
const internalCount = internalStates.filter(Boolean).length;
const hasPotentialVisibleSend = inFlightMessagingToolCalls > internalCount;
if (inFlightUnclassifiedMcpRequests > 0 || hasPotentialVisibleSend) {
didSendViaMessagingTool = true;
params.recordRunError(new Error("CLI message tool call remained in flight after exit"));
} else if (inFlightMessagingToolCalls > 0) {
params.recordRunError(new Error("CLI source reply call remained in flight after exit"));
}
} catch (error) {
if (
pendingMessagingCalls.size > 0 ||
inFlightUnclassifiedMcpRequests > 0 ||
inFlightMessagingToolCalls > 0
) {
didSendViaMessagingTool = true;
}
params.recordRunError(error);
}
};
const finalizeCapture = (finalizeParsedTools: () => void) => {
// Captured MCP calls may settle after the CLI process exits. Drain first so
// finalization can use their trusted terminal outcomes.
try {
finalizeParsedTools();
} finally {
if (gatewayCaptureKey) {
// Fence this exact grant generation before clearing observers; otherwise
// a late request escapes accounting.
try {
context.preparedBackend.mcpClientGrantCapture?.deactivate(gatewayCaptureKey);
} finally {
clearMcpLoopbackToolCallCapture(gatewayCaptureKey);
}
}
}
};
const evidence = () => ({
didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool,
messagingToolSentTexts,
messagingToolSentMediaUrls,
messagingToolSentTargets,
messagingToolSourceReplyPayloads,
});
return {
beginGatewayCapture,
handleCliToolUseStart,
handleCliToolResult,
resolveCliLoopbackTerminalOutcome,
finishDeliveryTracking,
finalizeCapture,
withExecutionEvidence(output: CliOutput): CliOutput {
const current = evidence();
return {
...output,
...(yielded ? { yielded: true as const } : {}),
...(current.didSendViaMessagingTool ? { didSendViaMessagingTool: true } : {}),
...(current.didDeliverSourceReplyViaMessageTool
? { didDeliverSourceReplyViaMessageTool: true }
: {}),
...(current.messagingToolSentTexts.length > 0
? { messagingToolSentTexts: current.messagingToolSentTexts.slice() }
: {}),
...(current.messagingToolSentMediaUrls.length > 0
? { messagingToolSentMediaUrls: current.messagingToolSentMediaUrls.slice() }
: {}),
...(current.messagingToolSentTargets.length > 0
? { messagingToolSentTargets: current.messagingToolSentTargets.slice() }
: {}),
...(current.messagingToolSourceReplyPayloads.length > 0
? { messagingToolSourceReplyPayloads: current.messagingToolSourceReplyPayloads.slice() }
: {}),
};
},
attachDeliveryEvidence(error: unknown) {
return attachCliMessagingDeliveryEvidence(error, evidence());
},
};
}
export type CliToolTracking = ReturnType<typeof createCliToolTracking>;

File diff suppressed because it is too large Load Diff