fix(pi-claude-cli): unblock parameterless MCP tool calls in triage

Triage with claude-sonnet-4-6 via pi-claude-cli kept looping on
fn_review_spec calls that were rejected by pi's validator with
"root: must be object". Parameterless MCP tools (schema
{type:"object", properties:{}}) emit zero input_json_delta events,
so partialJson stayed "" and the catch fell through to
finalArgs = "" — a string, which TypeBox's Type.Object({}) rightly
refuses. Default empty partialJson to {} so the call lands.

Also:
- Add a 2-step reminder loop in triage before swapping to the
  fallback planning model — primary models that wrote PROMPT.md
  but forgot fn_review_spec recover from a nudge, no need to pay
  the cold-start tax of a new triage on a different model.
- Inject @runfusion/fusion's own pi extension into dashboard/
  daemon/serve sessions and propagate the path to createFnAgent
  via setHostExtensionPaths so fn_* tools register globally
  without requiring `pi install npm:@runfusion/fusion`.
- Drop the "historical" qualifier from replayed tool labels —
  Claude was reading "TOOL RESULT (historical Read):" as
  "previous session, ignore" and looping on verification.
- Remove subprocess-lifecycle stderr debug logs that landed for
  hang diagnosis — root cause is fixed, the noise can go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-26 00:00:49 -07:00
parent 15149905fd
commit c1a2b6edd7
13 changed files with 269 additions and 89 deletions

View File

@@ -136,11 +136,6 @@ export function streamViaCli(
});
const getStderr = captureStderr(proc);
const spawnTime = Date.now();
const procPid = proc.pid;
const traceMode = resumeSessionId ? "resume" : "new";
console.error(
`[pi-claude-cli] spawn pid=${procPid} model=${model.id} mode=${traceMode} effort=${effort ?? "default"} promptLen=${typeof prompt === "string" ? prompt.length : 0} systemPromptLen=${systemPrompt?.length ?? 0} mcp=${options?.mcpConfigPath ? "yes" : "no"}`,
);
// Register in global process registry for teardown cleanup
registerProcess(proc);
@@ -231,12 +226,8 @@ export function streamViaCli(
});
// Handle subprocess close -- surface crashes with stderr and exit code
proc.on("close", (code: number | null, signal: string | null) => {
proc.on("close", (code: number | null, _signal: string | null) => {
clearTimeout(inactivityTimer);
const elapsedMs = Date.now() - spawnTime;
console.error(
`[pi-claude-cli] close pid=${procPid} code=${code ?? "null"} signal=${signal ?? "null"} elapsedMs=${elapsedMs} broken=${broken}`,
);
if (broken) return; // Break-early kill, expected
if (code !== 0 && code !== null) {
const stderr = getStderr();
@@ -250,9 +241,6 @@ export function streamViaCli(
// Start inactivity timer after writing user message
resetInactivityTimer();
let firstLineLoggedAt = 0;
let lineCount = 0;
// Process NDJSON lines from stdout using event-based callback
// NOTE: Using 'line' event instead of `for await` because the async
// iterator batches lines, breaking real-time streaming to pi.
@@ -261,36 +249,10 @@ export function streamViaCli(
// Reset inactivity timer on each line of output
resetInactivityTimer();
lineCount++;
if (lineCount === 1) {
firstLineLoggedAt = Date.now();
console.error(
`[pi-claude-cli] first-stdout-line pid=${procPid} afterMs=${firstLineLoggedAt - spawnTime}`,
);
}
const msg = parseLine(line);
if (!msg) return;
// Log init system event so we can see MCP server status / model on stderr
if (
msg.type === "system" &&
(msg as { subtype?: string }).subtype === "init"
) {
const init = msg as unknown as {
session_id?: string;
mcp_servers?: Array<{ name: string; status: string }>;
model?: string;
permissionMode?: string;
};
const mcps = (init.mcp_servers ?? [])
.map((s) => `${s.name}=${s.status}`)
.join(",");
console.error(
`[pi-claude-cli] init pid=${procPid} session=${init.session_id ?? "?"} model=${init.model ?? "?"} permissionMode=${init.permissionMode ?? "?"} mcp=[${mcps}]`,
);
}
if (msg.type === "stream_event") {
// Only forward top-level events to pi's event bridge.
// Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
@@ -306,11 +268,6 @@ export function streamViaCli(
msg.event.content_block?.type === "tool_use"
) {
const toolName = msg.event.content_block.name;
if (toolName) {
console.error(
`[pi-claude-cli] tool_use pid=${procPid} name=${toolName} piKnown=${isPiKnownClaudeTool(toolName)}`,
);
}
if (toolName && isPiKnownClaudeTool(toolName)) {
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
@@ -325,9 +282,6 @@ export function streamViaCli(
msg.event.type === "message_stop" &&
sawBuiltInOrCustomTool
) {
console.error(
`[pi-claude-cli] break-early pid=${procPid} elapsedMs=${Date.now() - spawnTime} lines=${lineCount}`,
);
broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
clearTimeout(inactivityTimer);
// Pi will execute these tools. Kill subprocess to prevent CLI from executing them.