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:
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
||||||
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
|
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
|
||||||
import { ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
|
import { ProjectEngineManager, PeerExchangeService, setHostExtensionPaths } from "@fusion/engine";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
DefaultPackageManager,
|
DefaultPackageManager,
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
resolveClaudeCliExtensionPaths,
|
resolveClaudeCliExtensionPaths,
|
||||||
setCachedClaudeCliResolution,
|
setCachedClaudeCliResolution,
|
||||||
} from "./claude-cli-extension.js";
|
} from "./claude-cli-extension.js";
|
||||||
|
import { resolveSelfExtension } from "./self-extension.js";
|
||||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
@@ -416,8 +417,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
|||||||
// external `pi-claude-cli` install. Drops shadowing externals (e.g. a
|
// external `pi-claude-cli` install. Drops shadowing externals (e.g. a
|
||||||
// global `npm install -g pi-claude-cli`) so the upstream's once-and-lock
|
// global `npm install -g pi-claude-cli`) so the upstream's once-and-lock
|
||||||
// MCP-config bug can't poison sessions.
|
// MCP-config bug can't poison sessions.
|
||||||
|
// Inject the cli's own extension (@runfusion/fusion) so fn_* tools
|
||||||
|
// register globally without requiring `pi install npm:@runfusion/fusion`.
|
||||||
|
const selfExtension = resolveSelfExtension();
|
||||||
|
const selfExtensionPaths = selfExtension.status === "ok" ? [selfExtension.path] : [];
|
||||||
|
if (selfExtension.status !== "ok") {
|
||||||
|
console.warn(`[extensions] self: ${selfExtension.reason}`);
|
||||||
|
}
|
||||||
|
setHostExtensionPaths(selfExtensionPaths);
|
||||||
|
|
||||||
const reconciledExtensionPaths = reconcileClaudeCliPaths(
|
const reconciledExtensionPaths = reconcileClaudeCliPaths(
|
||||||
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
|
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
|
||||||
claudeCliPaths[0] ?? null,
|
claudeCliPaths[0] ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
stopAllDevServers,
|
stopAllDevServers,
|
||||||
type RuntimeLogger,
|
type RuntimeLogger,
|
||||||
} from "@fusion/dashboard";
|
} from "@fusion/dashboard";
|
||||||
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
|
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService, setHostExtensionPaths } from "@fusion/engine";
|
||||||
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
|
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
getMergeStrategy,
|
getMergeStrategy,
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
resolveClaudeCliExtensionPaths,
|
resolveClaudeCliExtensionPaths,
|
||||||
setCachedClaudeCliResolution,
|
setCachedClaudeCliResolution,
|
||||||
} from "./claude-cli-extension.js";
|
} from "./claude-cli-extension.js";
|
||||||
|
import { resolveSelfExtension } from "./self-extension.js";
|
||||||
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
|
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
|
||||||
|
|
||||||
// Re-export for backward compatibility with tests
|
// Re-export for backward compatibility with tests
|
||||||
@@ -1131,9 +1132,24 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Always inject the cli's own extension (`@runfusion/fusion`) so its
|
||||||
|
// `fn_*` tools register globally even when the user hasn't run
|
||||||
|
// `pi install npm:@runfusion/fusion`. Without this, agent chat with
|
||||||
|
// pi-claude-cli has no fn_* tools at all.
|
||||||
|
const selfExtension = resolveSelfExtension();
|
||||||
|
const selfExtensionPaths = selfExtension.status === "ok" ? [selfExtension.path] : [];
|
||||||
|
if (selfExtension.status !== "ok") {
|
||||||
|
logSink.warn(`[extensions] self: ${selfExtension.reason}`, "extensions");
|
||||||
|
}
|
||||||
|
// Propagate self-extension path to engine so createFnAgent sessions
|
||||||
|
// (chat, refine, mission, etc.) also load fn_* tools, not just the
|
||||||
|
// dashboard's extension runtime.
|
||||||
|
setHostExtensionPaths(selfExtensionPaths);
|
||||||
|
|
||||||
// Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved.
|
// Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved.
|
||||||
const extensionsResult = await discoverAndLoadExtensions(
|
const extensionsResult = await discoverAndLoadExtensions(
|
||||||
[
|
[
|
||||||
|
...selfExtensionPaths,
|
||||||
...getEnabledPiExtensionPaths(cwd),
|
...getEnabledPiExtensionPaths(cwd),
|
||||||
...packageExtensionPaths,
|
...packageExtensionPaths,
|
||||||
...claudeCliPaths,
|
...claudeCliPaths,
|
||||||
|
|||||||
88
packages/cli/src/commands/self-extension.ts
Normal file
88
packages/cli/src/commands/self-extension.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Resolver for the CLI's own pi extension (`@runfusion/fusion`).
|
||||||
|
*
|
||||||
|
* `packages/cli/src/extension.ts` registers all `fn_*` tools (`fn_task_create`,
|
||||||
|
* `fn_mission_create`, etc.). For these to appear in pi sessions launched by
|
||||||
|
* the dashboard/daemon (e.g. agent chat using pi-claude-cli), the extension
|
||||||
|
* must be loaded by pi's `discoverAndLoadExtensions`.
|
||||||
|
*
|
||||||
|
* Pi normally only loads extensions that are registered in
|
||||||
|
* `~/.pi/agent/settings.json` packages or symlinked into an extensions
|
||||||
|
* directory. To avoid requiring users to `pi install npm:@runfusion/fusion`
|
||||||
|
* before fn_* tools work, we resolve the bundled extension path at runtime
|
||||||
|
* and inject it into the load list — same pattern as
|
||||||
|
* `claude-cli-extension.ts` does for `@fusion/pi-claude-cli`.
|
||||||
|
*
|
||||||
|
* In dev (`pnpm dev dashboard`), this resolves to `packages/cli/src/extension.ts`.
|
||||||
|
* In a published install, it resolves to `<install>/dist/extension.js`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export type SelfExtensionResolution =
|
||||||
|
| { status: "ok"; path: string; packageVersion: string }
|
||||||
|
| { status: "missing"; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the absolute path to the cli's own pi extension entry file.
|
||||||
|
*
|
||||||
|
* Walk up from this module to the @runfusion/fusion package.json and read
|
||||||
|
* `pi.extensions[0]`. Prefer `src/extension.ts` over `dist/extension.js` when
|
||||||
|
* both exist so dev iterations don't require a rebuild.
|
||||||
|
*/
|
||||||
|
export function resolveSelfExtension(): SelfExtensionResolution {
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
let pkgDir: string | undefined;
|
||||||
|
let cur = here;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
if (existsSync(resolve(cur, "package.json"))) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(resolve(cur, "package.json"), "utf-8")) as { name?: string };
|
||||||
|
if (parsed.name === "@runfusion/fusion") {
|
||||||
|
pkgDir = cur;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and keep walking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parent = resolve(cur, "..");
|
||||||
|
if (parent === cur) break;
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pkgDir) {
|
||||||
|
return { status: "missing", reason: "Could not locate @runfusion/fusion package.json from CLI module" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let pkgJson: { pi?: { extensions?: unknown }; version?: string };
|
||||||
|
try {
|
||||||
|
pkgJson = JSON.parse(readFileSync(resolve(pkgDir, "package.json"), "utf-8")) as typeof pkgJson;
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "missing", reason: `Failed to read @runfusion/fusion package.json: ${err instanceof Error ? err.message : String(err)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer src/extension.ts in dev (always fresh); fall back to pi.extensions[0]
|
||||||
|
// (dist/extension.js in production).
|
||||||
|
const srcEntry = resolve(pkgDir, "src", "extension.ts");
|
||||||
|
if (existsSync(srcEntry)) {
|
||||||
|
return { status: "ok", path: srcEntry, packageVersion: pkgJson.version ?? "unknown" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensions = pkgJson.pi?.extensions;
|
||||||
|
if (!Array.isArray(extensions) || extensions.length === 0) {
|
||||||
|
return { status: "missing", reason: "@runfusion/fusion package.json has no pi.extensions array" };
|
||||||
|
}
|
||||||
|
const rawEntry = extensions[0];
|
||||||
|
if (typeof rawEntry !== "string" || rawEntry.length === 0) {
|
||||||
|
return { status: "missing", reason: "@runfusion/fusion pi.extensions[0] is not a valid path string" };
|
||||||
|
}
|
||||||
|
const entryPath = resolve(pkgDir, rawEntry);
|
||||||
|
if (!existsSync(entryPath)) {
|
||||||
|
return { status: "missing", reason: `@runfusion/fusion extension file not found at ${entryPath}` };
|
||||||
|
}
|
||||||
|
return { status: "ok", path: entryPath, packageVersion: pkgJson.version ?? "unknown" };
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
||||||
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
|
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
|
||||||
import { ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
|
import { ProjectEngineManager, PeerExchangeService, setHostExtensionPaths } from "@fusion/engine";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
DefaultPackageManager,
|
DefaultPackageManager,
|
||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
resolveClaudeCliExtensionPaths,
|
resolveClaudeCliExtensionPaths,
|
||||||
setCachedClaudeCliResolution,
|
setCachedClaudeCliResolution,
|
||||||
} from "./claude-cli-extension.js";
|
} from "./claude-cli-extension.js";
|
||||||
|
import { resolveSelfExtension } from "./self-extension.js";
|
||||||
|
|
||||||
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -468,8 +469,18 @@ export async function runServe(
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Inject the cli's own extension so fn_* tools register globally without
|
||||||
|
// requiring `pi install npm:@runfusion/fusion`.
|
||||||
|
const selfExtension = resolveSelfExtension();
|
||||||
|
const selfExtensionPaths = selfExtension.status === "ok" ? [selfExtension.path] : [];
|
||||||
|
if (selfExtension.status !== "ok") {
|
||||||
|
console.warn(`[extensions] self: ${selfExtension.reason}`);
|
||||||
|
}
|
||||||
|
setHostExtensionPaths(selfExtensionPaths);
|
||||||
|
|
||||||
const extensionsResult = await discoverAndLoadExtensions(
|
const extensionsResult = await discoverAndLoadExtensions(
|
||||||
[
|
[
|
||||||
|
...selfExtensionPaths,
|
||||||
...getEnabledPiExtensionPaths(cwd),
|
...getEnabledPiExtensionPaths(cwd),
|
||||||
...packageExtensionPaths,
|
...packageExtensionPaths,
|
||||||
...claudeCliPaths,
|
...claudeCliPaths,
|
||||||
|
|||||||
@@ -840,11 +840,20 @@ describe("Triage re-pick after restart", () => {
|
|||||||
const task = makeTask("FN-062", "triage");
|
const task = makeTask("FN-062", "triage");
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("FN-062", "triage"));
|
store.getTask.mockResolvedValue(makeTaskDetail("FN-062", "triage"));
|
||||||
|
|
||||||
// Slow agent to keep task in processing
|
// Slow agent to keep task in processing — only the first prompt() blocks;
|
||||||
|
// subsequent calls (e.g. the no-APPROVE reminder loop in triage) resolve
|
||||||
|
// immediately so cleanup can drain.
|
||||||
let resolvePrompt: (() => void) | undefined;
|
let resolvePrompt: (() => void) | undefined;
|
||||||
|
let promptCallCount = 0;
|
||||||
mockedCreateFnAgent.mockResolvedValue({
|
mockedCreateFnAgent.mockResolvedValue({
|
||||||
session: {
|
session: {
|
||||||
prompt: vi.fn().mockImplementation(() => new Promise<void>((r) => { resolvePrompt = r; })),
|
prompt: vi.fn().mockImplementation(() => {
|
||||||
|
promptCallCount += 1;
|
||||||
|
if (promptCallCount === 1) {
|
||||||
|
return new Promise<void>((r) => { resolvePrompt = r; });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopi
|
|||||||
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
||||||
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
||||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||||
export { createFnAgent, promptWithFallback, describeModel, type AgentOptions, type AgentResult } from "./pi.js";
|
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
|
||||||
export {
|
export {
|
||||||
resolveSessionSkills,
|
resolveSessionSkills,
|
||||||
createSkillsOverrideFromSelection,
|
createSkillsOverrideFromSelection,
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ export interface AgentResult {
|
|||||||
sessionFile?: string;
|
sessionFile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-global list of extension paths to inject into every createFnAgent
|
||||||
|
* session. Set once at startup by the host (cli's dashboard/daemon/serve)
|
||||||
|
* before any sessions are created. The paths are passed to pi's
|
||||||
|
* `DefaultResourceLoader` as `additionalExtensionPaths` so the cli's own
|
||||||
|
* `@runfusion/fusion` extension (registering `fn_*` tools) is loaded inside
|
||||||
|
* every agent session — including chat sessions that pass no `customTools`.
|
||||||
|
*/
|
||||||
|
let hostExtensionPaths: string[] = [];
|
||||||
|
|
||||||
|
export function setHostExtensionPaths(paths: readonly string[]): void {
|
||||||
|
hostExtensionPaths = [...paths];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHostExtensionPaths(): readonly string[] {
|
||||||
|
return hostExtensionPaths;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PromptableSession extends AgentSession {
|
export interface PromptableSession extends AgentSession {
|
||||||
promptWithFallback: (prompt: string, options?: unknown) => Promise<void>;
|
promptWithFallback: (prompt: string, options?: unknown) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -1066,6 +1084,10 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
settingsManager,
|
settingsManager,
|
||||||
systemPromptOverride: () => options.systemPrompt,
|
systemPromptOverride: () => options.systemPrompt,
|
||||||
appendSystemPromptOverride: () => [],
|
appendSystemPromptOverride: () => [],
|
||||||
|
// Inject host-supplied extension paths (e.g. cli's own `@runfusion/fusion`
|
||||||
|
// extension that registers `fn_*` tools) so they're loaded inside every
|
||||||
|
// agent session, including chat sessions that don't pass `customTools`.
|
||||||
|
...(hostExtensionPaths.length > 0 ? { additionalExtensionPaths: [...hostExtensionPaths] } : {}),
|
||||||
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
||||||
});
|
});
|
||||||
await resourceLoader.reload();
|
await resourceLoader.reload();
|
||||||
|
|||||||
@@ -1043,6 +1043,44 @@ export class TriageProcessor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before swapping to the fallback model, give the primary one more
|
||||||
|
// shot with a pointed reminder. The model may have written PROMPT.md
|
||||||
|
// but stopped without calling fn_review_spec — that's recoverable
|
||||||
|
// with a nudge, no need to discard the session and pay the cold-start
|
||||||
|
// tax of a new triage on a different model.
|
||||||
|
const MAX_REVIEW_REMINDERS = 2;
|
||||||
|
let reviewReminders = 0;
|
||||||
|
while (
|
||||||
|
specReviewVerdictRef.current !== "APPROVE" &&
|
||||||
|
!this.pauseAborted.has(task.id) &&
|
||||||
|
!this.stuckAborted.has(task.id) &&
|
||||||
|
createdSubtasksRef.current.length === 0 &&
|
||||||
|
reviewReminders < MAX_REVIEW_REMINDERS
|
||||||
|
) {
|
||||||
|
reviewReminders += 1;
|
||||||
|
const verdictDesc =
|
||||||
|
specReviewVerdictRef.current === null
|
||||||
|
? "fn_review_spec was never called"
|
||||||
|
: `verdict was ${specReviewVerdictRef.current}`;
|
||||||
|
triageLog.warn(
|
||||||
|
`${task.id} primary planning model returned without APPROVE (${verdictDesc}) — reminder ${reviewReminders}/${MAX_REVIEW_REMINDERS}`,
|
||||||
|
);
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`Primary planning model returned without APPROVE (${verdictDesc}) — reminder ${reviewReminders}/${MAX_REVIEW_REMINDERS}`,
|
||||||
|
);
|
||||||
|
const reminder =
|
||||||
|
specReviewVerdictRef.current === null
|
||||||
|
? "You wrote the PROMPT.md but did not call `fn_review_spec()`. Call `fn_review_spec()` now to validate the spec. Do not stop until the verdict is APPROVE."
|
||||||
|
: `Spec review verdict was ${specReviewVerdictRef.current}. Address the feedback, rewrite the PROMPT.md as needed, and call \`fn_review_spec()\` again. Do not stop until the verdict is APPROVE.`;
|
||||||
|
stuckDetector?.recordActivity(task.id);
|
||||||
|
await promptWithFallback(session, reminder);
|
||||||
|
checkSessionError(session);
|
||||||
|
if (this.pauseAborted.has(task.id) || this.stuckAborted.has(task.id)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const planningFallbackProvider = settings.planningFallbackProvider;
|
const planningFallbackProvider = settings.planningFallbackProvider;
|
||||||
const planningFallbackModelId = settings.planningFallbackModelId;
|
const planningFallbackModelId = settings.planningFallbackModelId;
|
||||||
const canRetryWithPlanningFallback =
|
const canRetryWithPlanningFallback =
|
||||||
|
|||||||
@@ -444,6 +444,40 @@ describe("createEventBridge", () => {
|
|||||||
expect(event.toolCall.arguments).toEqual({ path: "/foo.ts" });
|
expect(event.toolCall.arguments).toEqual({ path: "/foo.ts" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits {} for parameterless MCP tool calls (no input_json_delta)", () => {
|
||||||
|
// Parameterless MCP tools (e.g. fn_review_spec, schema
|
||||||
|
// {type:"object", properties:{}}) emit ZERO input_json_delta events.
|
||||||
|
// Without the empty-partialJson guard, finalArgs would fall through to
|
||||||
|
// the raw-string fallback ("") and pi's TypeBox validator would reject
|
||||||
|
// the call with "Validation failed for tool ...: root: must be object".
|
||||||
|
const bridge = createBridgeWithStart();
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_01XYZ",
|
||||||
|
name: "mcp__custom-tools__fn_review_spec",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// No content_block_delta with input_json_delta — Claude emits none for
|
||||||
|
// parameterless tools.
|
||||||
|
stream.push.mockClear();
|
||||||
|
stream.events.length = 0;
|
||||||
|
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_stop",
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stream.push).toHaveBeenCalledTimes(1);
|
||||||
|
const event = stream.events[0] as any;
|
||||||
|
expect(event.type).toBe("toolcall_end");
|
||||||
|
expect(event.toolCall.arguments).toEqual({});
|
||||||
|
// The MCP prefix should be stripped: pi sees the bare tool name.
|
||||||
|
expect(event.toolCall.name).toBe("fn_review_spec");
|
||||||
|
});
|
||||||
|
|
||||||
it("tracks multiple tool_use blocks independently by Claude event.index", () => {
|
it("tracks multiple tool_use blocks independently by Claude event.index", () => {
|
||||||
const bridge = createBridgeWithStart();
|
const bridge = createBridgeWithStart();
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe("buildPrompt", () => {
|
|||||||
expect(buildPrompt(context)).toBe("ASSISTANT:\nHi there");
|
expect(buildPrompt(context)).toBe("ASSISTANT:\nHi there");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("produces 'TOOL RESULT (historical {claudeName}):\\n{content}' for a tool result message", () => {
|
it("produces 'TOOL RESULT ({claudeName}):\\n{content}' for a tool result message", () => {
|
||||||
const context = {
|
const context = {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,7 @@ describe("buildPrompt", () => {
|
|||||||
} as unknown as any;
|
} as unknown as any;
|
||||||
// Pi tool name "read" should be mapped to Claude name "Read" in the label
|
// Pi tool name "read" should be mapped to Claude name "Read" in the label
|
||||||
expect(buildPrompt(context)).toBe(
|
expect(buildPrompt(context)).toBe(
|
||||||
"TOOL RESULT (historical Read):\nfile contents here",
|
"TOOL RESULT (Read):\nfile contents here",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ describe("buildPrompt", () => {
|
|||||||
"What is in file.ts?",
|
"What is in file.ts?",
|
||||||
"ASSISTANT:",
|
"ASSISTANT:",
|
||||||
"Let me read that file.",
|
"Let me read that file.",
|
||||||
"TOOL RESULT (historical Read):",
|
"TOOL RESULT (Read):",
|
||||||
"export const x = 1;",
|
"export const x = 1;",
|
||||||
"USER:",
|
"USER:",
|
||||||
"Now explain it.",
|
"Now explain it.",
|
||||||
@@ -109,7 +109,7 @@ describe("buildPrompt", () => {
|
|||||||
// Tool name should be mapped from pi "read" to Claude "Read"
|
// Tool name should be mapped from pi "read" to Claude "Read"
|
||||||
// Arg "path" should be mapped from pi format to Claude "file_path"
|
// Arg "path" should be mapped from pi format to Claude "file_path"
|
||||||
expect(result).toContain(
|
expect(result).toContain(
|
||||||
'Historical tool call (non-executable): Read args={"file_path":"/file.ts"}',
|
'[Prior tool call — already executed; result follows in TOOL RESULT (Read):] args={"file_path":"/file.ts"}',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ describe("buildPrompt", () => {
|
|||||||
const result = buildPrompt(context);
|
const result = buildPrompt(context);
|
||||||
// Pi "bash" maps to Claude "Bash"
|
// Pi "bash" maps to Claude "Bash"
|
||||||
expect(result).toContain(
|
expect(result).toContain(
|
||||||
"Historical tool call (non-executable): Bash args={}",
|
"[Prior tool call — already executed; result follows in TOOL RESULT (Bash):] args={}",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ describe("buildPrompt", () => {
|
|||||||
} as unknown as any;
|
} as unknown as any;
|
||||||
|
|
||||||
const result = buildPrompt(context);
|
const result = buildPrompt(context);
|
||||||
expect(result).toBe("TOOL RESULT (historical Bash):\nline 1\nline 2");
|
expect(result).toBe("TOOL RESULT (Bash):\nline 1\nline 2");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("tool name and argument reverse mapping", () => {
|
describe("tool name and argument reverse mapping", () => {
|
||||||
@@ -237,7 +237,7 @@ describe("buildPrompt", () => {
|
|||||||
} as unknown as any;
|
} as unknown as any;
|
||||||
|
|
||||||
const result = buildPrompt(context);
|
const result = buildPrompt(context);
|
||||||
expect(result).toContain("TOOL RESULT (historical Read):");
|
expect(result).toContain("TOOL RESULT (Read):");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefixes custom (non-built-in) tool names with MCP prefix", () => {
|
it("prefixes custom (non-built-in) tool names with MCP prefix", () => {
|
||||||
@@ -281,7 +281,8 @@ describe("buildPrompt", () => {
|
|||||||
|
|
||||||
const result = buildPrompt(context);
|
const result = buildPrompt(context);
|
||||||
// String arguments should be serialized as JSON string
|
// String arguments should be serialized as JSON string
|
||||||
expect(result).toContain('Read args="raw string args"');
|
expect(result).toContain('TOOL RESULT (Read):');
|
||||||
|
expect(result).toContain('args="raw string args"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -688,7 +689,7 @@ describe("custom tool history replay", () => {
|
|||||||
} as unknown as any;
|
} as unknown as any;
|
||||||
|
|
||||||
const result = buildPrompt(context);
|
const result = buildPrompt(context);
|
||||||
expect(result).toContain("TOOL RESULT (historical Read):");
|
expect(result).toContain("TOOL RESULT (Read):");
|
||||||
expect(result).not.toContain("mcp__custom-tools__");
|
expect(result).not.toContain("mcp__custom-tools__");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1019,7 +1020,7 @@ describe("buildResumePrompt", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = buildResumePrompt(context) as string;
|
const result = buildResumePrompt(context) as string;
|
||||||
expect(result).toContain("TOOL RESULT (historical Read):");
|
expect(result).toContain("TOOL RESULT (Read):");
|
||||||
expect(result).toContain("file contents here");
|
expect(result).toContain("file contents here");
|
||||||
expect(result).toContain("Now explain it");
|
expect(result).toContain("Now explain it");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -323,13 +323,24 @@ export function createEventBridge(
|
|||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
} else if (block.type === "tool_use") {
|
} else if (block.type === "tool_use") {
|
||||||
// Final JSON parse with fallback to raw string
|
// Final JSON parse with fallback to raw string.
|
||||||
|
// Special case: parameterless MCP tools (e.g. fn_review_spec, schema
|
||||||
|
// `{type:"object", properties:{}}`) emit ZERO input_json_delta events,
|
||||||
|
// so `partialJson` stays "". Without this guard we'd JSON.parse("")
|
||||||
|
// → throw → fall through to `finalArgs = ""` (raw string), and pi's
|
||||||
|
// TypeBox validator then rejects with "root: must be object" because
|
||||||
|
// an empty string is not an object. Default to `{}` so the call lands.
|
||||||
let finalArgs: Record<string, unknown> | string;
|
let finalArgs: Record<string, unknown> | string;
|
||||||
try {
|
const trimmedJson = block.partialJson.trim();
|
||||||
const parsed = JSON.parse(block.partialJson);
|
if (trimmedJson === "") {
|
||||||
finalArgs = translateClaudeArgsToPi(block.claudeName, parsed);
|
finalArgs = {};
|
||||||
} catch {
|
} else {
|
||||||
finalArgs = block.partialJson;
|
try {
|
||||||
|
const parsed = JSON.parse(trimmedJson);
|
||||||
|
finalArgs = translateClaudeArgsToPi(block.claudeName, parsed);
|
||||||
|
} catch {
|
||||||
|
finalArgs = block.partialJson;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update output.content with final arguments
|
// Update output.content with final arguments
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ type AnthropicContentBlock =
|
|||||||
* Each message is labeled with its role:
|
* Each message is labeled with its role:
|
||||||
* - USER: for user messages
|
* - USER: for user messages
|
||||||
* - ASSISTANT: for assistant messages
|
* - ASSISTANT: for assistant messages
|
||||||
* - TOOL RESULT (historical {toolName}): for tool result messages
|
* - TOOL RESULT ({toolName}): for tool result messages
|
||||||
*/
|
*/
|
||||||
/** Module-level counter for placeholder images, reset per buildPrompt call. */
|
/** Module-level counter for placeholder images, reset per buildPrompt call. */
|
||||||
let placeholderImageCount = 0;
|
let placeholderImageCount = 0;
|
||||||
@@ -213,7 +213,7 @@ export function buildResumePrompt(context: PiContext): string | AnthropicContent
|
|||||||
const claudeToolName = msg.toolName
|
const claudeToolName = msg.toolName
|
||||||
? mapPiToolNameToClaude(msg.toolName)
|
? mapPiToolNameToClaude(msg.toolName)
|
||||||
: "unknown";
|
: "unknown";
|
||||||
parts.push(`TOOL RESULT (historical ${claudeToolName}):`);
|
parts.push(`TOOL RESULT (${claudeToolName}):`);
|
||||||
}
|
}
|
||||||
parts.push(toolResultContentToText(msg.content));
|
parts.push(toolResultContentToText(msg.content));
|
||||||
} else if (msg.role === "user") {
|
} else if (msg.role === "user") {
|
||||||
@@ -283,7 +283,7 @@ export function buildPrompt(context: PiContext): string | AnthropicContentBlock[
|
|||||||
const claudeToolName = message.toolName
|
const claudeToolName = message.toolName
|
||||||
? mapPiToolNameToClaude(message.toolName)
|
? mapPiToolNameToClaude(message.toolName)
|
||||||
: "unknown";
|
: "unknown";
|
||||||
historyParts.push(`TOOL RESULT (historical ${claudeToolName}):`);
|
historyParts.push(`TOOL RESULT (${claudeToolName}):`);
|
||||||
}
|
}
|
||||||
// Extract text portion of tool result
|
// Extract text portion of tool result
|
||||||
historyParts.push(toolResultContentToText(message.content));
|
historyParts.push(toolResultContentToText(message.content));
|
||||||
@@ -347,7 +347,7 @@ export function buildPrompt(context: PiContext): string | AnthropicContentBlock[
|
|||||||
const claudeToolName = message.toolName
|
const claudeToolName = message.toolName
|
||||||
? mapPiToolNameToClaude(message.toolName)
|
? mapPiToolNameToClaude(message.toolName)
|
||||||
: "unknown";
|
: "unknown";
|
||||||
parts.push(`TOOL RESULT (historical ${claudeToolName}):`);
|
parts.push(`TOOL RESULT (${claudeToolName}):`);
|
||||||
}
|
}
|
||||||
parts.push(toolResultContentToText(message.content));
|
parts.push(toolResultContentToText(message.content));
|
||||||
}
|
}
|
||||||
@@ -451,8 +451,6 @@ function rewriteCustomToolReferences(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = prompt;
|
let result = prompt;
|
||||||
let totalRewrites = 0;
|
|
||||||
const rewritten: string[] = [];
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
if (BUILT_IN_PI_TOOLS.has(tool.name)) continue;
|
if (BUILT_IN_PI_TOOLS.has(tool.name)) continue;
|
||||||
// \b doesn't treat `_` as a word boundary the way we want here, so anchor
|
// \b doesn't treat `_` as a word boundary the way we want here, so anchor
|
||||||
@@ -464,19 +462,7 @@ function rewriteCustomToolReferences(
|
|||||||
`(?<![A-Za-z0-9_])(?<!mcp__custom-tools__)${escaped}(?![A-Za-z0-9_])`,
|
`(?<![A-Za-z0-9_])(?<!mcp__custom-tools__)${escaped}(?![A-Za-z0-9_])`,
|
||||||
"g",
|
"g",
|
||||||
);
|
);
|
||||||
const before = result;
|
|
||||||
result = result.replace(pattern, `mcp__custom-tools__${tool.name}`);
|
result = result.replace(pattern, `mcp__custom-tools__${tool.name}`);
|
||||||
if (result !== before) {
|
|
||||||
const matches = before.match(pattern);
|
|
||||||
const count = matches?.length ?? 0;
|
|
||||||
totalRewrites += count;
|
|
||||||
rewritten.push(`${tool.name}×${count}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (totalRewrites > 0) {
|
|
||||||
console.error(
|
|
||||||
`[pi-claude-cli] system prompt: rewrote ${totalRewrites} custom tool ref(s) [${rewritten.join(", ")}]`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -580,7 +566,7 @@ function contentToText(content: string | unknown[]): string {
|
|||||||
: typeof rawArgs === "string"
|
: typeof rawArgs === "string"
|
||||||
? JSON.stringify(rawArgs)
|
? JSON.stringify(rawArgs)
|
||||||
: "{}";
|
: "{}";
|
||||||
return `Historical tool call (non-executable): ${claudeName} args=${argsStr}`;
|
return `[Prior tool call — already executed; result follows in TOOL RESULT (${claudeName}):] args=${argsStr}`;
|
||||||
}
|
}
|
||||||
// Unknown block types are represented as a placeholder
|
// Unknown block types are represented as a placeholder
|
||||||
return `[${String(block.type)}]`;
|
return `[${String(block.type)}]`;
|
||||||
|
|||||||
@@ -136,11 +136,6 @@ export function streamViaCli(
|
|||||||
});
|
});
|
||||||
const getStderr = captureStderr(proc);
|
const getStderr = captureStderr(proc);
|
||||||
const spawnTime = Date.now();
|
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
|
// Register in global process registry for teardown cleanup
|
||||||
registerProcess(proc);
|
registerProcess(proc);
|
||||||
@@ -231,12 +226,8 @@ export function streamViaCli(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle subprocess close -- surface crashes with stderr and exit code
|
// 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);
|
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 (broken) return; // Break-early kill, expected
|
||||||
if (code !== 0 && code !== null) {
|
if (code !== 0 && code !== null) {
|
||||||
const stderr = getStderr();
|
const stderr = getStderr();
|
||||||
@@ -250,9 +241,6 @@ export function streamViaCli(
|
|||||||
// Start inactivity timer after writing user message
|
// Start inactivity timer after writing user message
|
||||||
resetInactivityTimer();
|
resetInactivityTimer();
|
||||||
|
|
||||||
let firstLineLoggedAt = 0;
|
|
||||||
let lineCount = 0;
|
|
||||||
|
|
||||||
// Process NDJSON lines from stdout using event-based callback
|
// Process NDJSON lines from stdout using event-based callback
|
||||||
// NOTE: Using 'line' event instead of `for await` because the async
|
// NOTE: Using 'line' event instead of `for await` because the async
|
||||||
// iterator batches lines, breaking real-time streaming to pi.
|
// iterator batches lines, breaking real-time streaming to pi.
|
||||||
@@ -261,36 +249,10 @@ export function streamViaCli(
|
|||||||
|
|
||||||
// Reset inactivity timer on each line of output
|
// Reset inactivity timer on each line of output
|
||||||
resetInactivityTimer();
|
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);
|
const msg = parseLine(line);
|
||||||
if (!msg) return;
|
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") {
|
if (msg.type === "stream_event") {
|
||||||
// Only forward top-level events to pi's event bridge.
|
// Only forward top-level events to pi's event bridge.
|
||||||
// Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
|
// 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"
|
msg.event.content_block?.type === "tool_use"
|
||||||
) {
|
) {
|
||||||
const toolName = msg.event.content_block.name;
|
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)) {
|
if (toolName && isPiKnownClaudeTool(toolName)) {
|
||||||
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
|
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
|
||||||
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
|
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
|
||||||
@@ -325,9 +282,6 @@ export function streamViaCli(
|
|||||||
msg.event.type === "message_stop" &&
|
msg.event.type === "message_stop" &&
|
||||||
sawBuiltInOrCustomTool
|
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
|
broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
|
||||||
clearTimeout(inactivityTimer);
|
clearTimeout(inactivityTimer);
|
||||||
// Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
|
// Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
|
||||||
|
|||||||
Reference in New Issue
Block a user