@vanillagreen/pi-claude-bridge 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,334 @@
1
+ import { type AssistantMessage, type Context } from "@earendil-works/pi-ai";
2
+ import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
3
+ import { createHash } from "crypto";
4
+ import { realpathSync, statSync } from "fs";
5
+ import { resolve as pathResolve } from "path";
6
+ import { extensionApi, piUI, reportSyntheticToolResultRepair, setSharedSession, sharedSession, type SessionState } from "./bridge-state.js";
7
+ import { convertPiMessages } from "./convert.js";
8
+ import { DEBUG, DEBUG_LOG_PATH, debug, diagDump } from "./debug.js";
9
+ import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
10
+ import { findUnpairedToolUses, insertLostToolResultPlaceholders } from "./tool-pairing-audit.js";
11
+
12
+ // --- Session persistence ---
13
+
14
+ const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
15
+
16
+ interface PersistedBridgeSessionState extends SessionState {
17
+ fingerprint: string;
18
+ piSessionId?: string;
19
+ updatedAt: string;
20
+ }
21
+
22
+ function fingerprintMessages(messages: Context["messages"]): string {
23
+ const normalized = messages.map((message) => {
24
+ if (message.role === "assistant") {
25
+ return {
26
+ role: message.role,
27
+ provider: (message as AssistantMessage).provider,
28
+ model: (message as AssistantMessage).model,
29
+ content: (message as AssistantMessage).content,
30
+ };
31
+ }
32
+ return message;
33
+ });
34
+ return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
35
+ }
36
+
37
+ function readBuiltSessionContext(sessionManager: unknown): { messages: Context["messages"] } | undefined {
38
+ const built = typeof (sessionManager as any)?.buildSessionContext === "function" ? (sessionManager as any).buildSessionContext() : undefined;
39
+ return Array.isArray(built?.messages) ? built as { messages: Context["messages"] } : undefined;
40
+ }
41
+
42
+ function latestPersistedBridgeSession(sessionManager: unknown): PersistedBridgeSessionState | undefined {
43
+ const entries = typeof (sessionManager as any)?.getEntries === "function" ? (sessionManager as any).getEntries() : [];
44
+ if (!Array.isArray(entries)) return undefined;
45
+ for (let i = entries.length - 1; i >= 0; i--) {
46
+ const entry = entries[i];
47
+ if (entry?.type !== "custom" || entry.customType !== BRIDGE_SESSION_CUSTOM_TYPE) continue;
48
+ const data = entry.data as Partial<PersistedBridgeSessionState> | undefined;
49
+ if (!data || typeof data.sessionId !== "string" || typeof data.cursor !== "number" || typeof data.cwd !== "string" || typeof data.fingerprint !== "string") continue;
50
+ return data as PersistedBridgeSessionState;
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ function claudeSessionExists(sessionId: string, cwd: string): boolean {
56
+ try {
57
+ const session = openSession({ sessionId, projectPath: cwd, claudeDir: process.env.CLAUDE_CONFIG_DIR });
58
+ statSync(session.jsonlPath);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function canonicalize(p: string | undefined): string | undefined {
66
+ if (!p) return undefined;
67
+ try { return realpathSync.native(p); } catch { return pathResolve(p); }
68
+ }
69
+
70
+ // Decides whether a persisted bridge-session marker is safe to restore.
71
+ //
72
+ // The fork case is the load-bearing one: pi/core's createBranchedSession copies
73
+ // every non-label entry from root→leaf into the new session file. That includes
74
+ // our claude-bridge-session markers from the parent. Restoring from them would
75
+ // --resume parent's Claude jsonl on the fork's first turn, leaking conversation
76
+ // past the fork point.
77
+ //
78
+ // Returns undefined when the entry is safe to use, or a short rejection reason
79
+ // for diagnostic logging. Old entries without piSessionId always reject, which
80
+ // degrades safely to the rebuild path.
81
+ export function shouldRestorePersistedBridgeEntry(
82
+ persisted: { piSessionId?: string; cwd: string },
83
+ currentPiSessionId: string | undefined,
84
+ currentCwd: string | undefined,
85
+ ): string | undefined {
86
+ if (!persisted.piSessionId) return "missing piSessionId";
87
+ if (currentPiSessionId && persisted.piSessionId !== currentPiSessionId) {
88
+ return `piSessionId mismatch (persisted=${persisted.piSessionId} current=${currentPiSessionId})`;
89
+ }
90
+ if (currentCwd && canonicalize(persisted.cwd) !== canonicalize(currentCwd)) {
91
+ return `cwd mismatch (persisted=${persisted.cwd} current=${currentCwd})`;
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ export function restoreSharedSessionFromPi(ctx: { sessionManager?: unknown; cwd?: string }): void {
97
+ const persisted = latestPersistedBridgeSession(ctx.sessionManager);
98
+ if (!persisted) return;
99
+ const currentPiSessionId = typeof (ctx.sessionManager as any)?.getSessionId === "function" ? (ctx.sessionManager as any).getSessionId() : undefined;
100
+ const currentCwd = typeof (ctx.sessionManager as any)?.getCwd === "function" ? (ctx.sessionManager as any).getCwd() : ctx.cwd;
101
+ const rejection = shouldRestorePersistedBridgeEntry(persisted, currentPiSessionId, currentCwd);
102
+ if (rejection) {
103
+ debug(`restoreSharedSession: ${rejection} — forcing rebuild`);
104
+ return;
105
+ }
106
+ const built = readBuiltSessionContext(ctx.sessionManager);
107
+ if (!built) return;
108
+ const cursor = Math.max(0, Math.min(persisted.cursor, built.messages.length));
109
+ const fingerprint = fingerprintMessages(built.messages.slice(0, cursor));
110
+ if (fingerprint !== persisted.fingerprint) {
111
+ debug(`restoreSharedSession: fingerprint mismatch for ${persisted.sessionId.slice(0, 8)}`);
112
+ return;
113
+ }
114
+ if (!claudeSessionExists(persisted.sessionId, persisted.cwd)) {
115
+ debug(`restoreSharedSession: Claude session missing for ${persisted.sessionId.slice(0, 8)}`);
116
+ return;
117
+ }
118
+ setSharedSession({ sessionId: persisted.sessionId, cursor, cwd: persisted.cwd });
119
+ debug(`restoreSharedSession: restored ${persisted.sessionId.slice(0, 8)}, cursor=${cursor}`);
120
+ }
121
+
122
+ export function schedulePersistSharedSession(ctxLike?: { sessionManager?: unknown }): void {
123
+ if (!extensionApi || !sharedSession || !ctxLike?.sessionManager) return;
124
+ const snapshot = { ...sharedSession };
125
+ const timer = setTimeout(() => {
126
+ try {
127
+ const built = readBuiltSessionContext(ctxLike.sessionManager);
128
+ if (!built) return;
129
+ const cursor = Math.max(0, Math.min(snapshot.cursor, built.messages.length));
130
+ const data: PersistedBridgeSessionState = {
131
+ ...snapshot,
132
+ cursor,
133
+ fingerprint: fingerprintMessages(built.messages.slice(0, cursor)),
134
+ piSessionId: typeof (ctxLike.sessionManager as any)?.getSessionId === "function" ? (ctxLike.sessionManager as any).getSessionId() : undefined,
135
+ updatedAt: new Date().toISOString(),
136
+ };
137
+ extensionApi?.appendEntry(BRIDGE_SESSION_CUSTOM_TYPE, data);
138
+ debug(`persistSharedSession: saved ${data.sessionId.slice(0, 8)}, cursor=${data.cursor}`);
139
+ } catch (error) {
140
+ debug("persistSharedSession failed:", error);
141
+ }
142
+ }, 0);
143
+ timer.unref?.();
144
+ }
145
+
146
+ // Convert pi messages to Anthropic API format for session import.
147
+ // Lossy: non-Anthropic thinking blocks are dropped (no valid signature). User and
148
+ // tool-result image blocks are preserved when possible. If assistant blocks are
149
+ // otherwise incompatible, convertPiMessages emits a text placeholder so the record
150
+ // sequence stays valid before repairToolPairing runs.
151
+ function convertAndImportMessages(
152
+ session: ReturnType<typeof createSession>,
153
+ messages: Context["messages"],
154
+ customToolNameToSdk?: Map<string, string>,
155
+ cwd?: string,
156
+ ): void {
157
+ const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
158
+
159
+ debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
160
+ debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
161
+ const c = m.content;
162
+ if (typeof c === "string") return `[${i}]${m.role}:text`;
163
+ if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
164
+ return `[${i}]${m.role}:?`;
165
+ }).join(" "));
166
+ if (sanitizedIds.size > 0) {
167
+ debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
168
+ [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
169
+ }
170
+ // Pre-repair: pair every orphaned tool_use with an EXPLICIT bridge-authored
171
+ // error result before cc-session-io's repairToolPairing can backfill its bare
172
+ // "[no tool result recorded]" placeholder — which the model reads as tool
173
+ // output and silently reasons on. Ours is is_error and says what to do.
174
+ // repairToolPairing still runs after (idempotent; finds nothing left).
175
+ const missingToolResults = findUnpairedToolUses(anthropicMessages);
176
+ if (missingToolResults.length > 0) insertLostToolResultPlaceholders(anthropicMessages, missingToolResults);
177
+ const repaired = repairToolPairing(anthropicMessages);
178
+ if (missingToolResults.length > 0) {
179
+ reportSyntheticToolResultRepair(missingToolResults, {
180
+ cwd,
181
+ messageCount: messages.length,
182
+ anthropicMessageCount: anthropicMessages.length,
183
+ sessionId: session.sessionId,
184
+ jsonlPath: session.jsonlPath,
185
+ });
186
+ }
187
+ if (repaired.length !== anthropicMessages.length) {
188
+ debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
189
+ }
190
+ if (repaired.length) session.importMessages(repaired);
191
+ }
192
+
193
+ interface SyncResult {
194
+ sessionId: string | null;
195
+ }
196
+
197
+ /**
198
+ * Ensure the shared session has all messages up to (but not including) the last user message.
199
+ * Returns session ID to resume from, or null if no resume needed.
200
+ */
201
+ // Read the session file we just wrote and sanity-check it. Warns instead of
202
+ // throwing — CC may be more tolerant than our checks, so a false positive
203
+ // shouldn't block the user. Pure logic is in session-verify.js; this wrapper
204
+ // fans each warning out to debug log + piUI notify + diagDump.
205
+ function verifyWrittenSession(
206
+ jsonlPath: string,
207
+ expectedSessionId: string,
208
+ expectedRecordCount: number,
209
+ cwd: string,
210
+ ): void {
211
+ const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
212
+ for (const msg of warnings) {
213
+ debug(`WARNING session verify: ${msg}`);
214
+ piUI?.notify(
215
+ `Session file issue: ${msg}\n` +
216
+ `cwd=${cwd} realpath=${safeRealpath(cwd)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
217
+ `Please copy and paste this message into a new issue at https://github.com/elidickinson/pi-claude-bridge/issues/new` +
218
+ (DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
219
+ "warning",
220
+ );
221
+ diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null });
222
+ }
223
+ }
224
+
225
+ function safeRealpath(p: string): string {
226
+ try { return realpathSync(p); } catch (e) { return `<failed: ${(e as Error).message}>`; }
227
+ }
228
+
229
+ // Diagnostic snapshot of where a session file was just written. Catches the
230
+ // class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
231
+ // from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
232
+ function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
233
+ const realCwd = safeRealpath(cwd);
234
+ let fileSize: number | null = null;
235
+ let fileExists = false;
236
+ try {
237
+ const st = statSync(jsonlPath);
238
+ fileExists = true;
239
+ fileSize = st.size;
240
+ } catch { /* file may not exist yet */ }
241
+ debug(`${label}: cwd=${cwd}`);
242
+ if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
243
+ debug(`${label}: jsonlPath=${jsonlPath}`);
244
+ debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
245
+ debug(`${label}: env.CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
246
+ }
247
+
248
+ // Two semantic paths:
249
+ // REUSE — pi's history is in sync with the existing sharedSession (or drifted
250
+ // only by the trailing final-assistant message that pi appends after
251
+ // streamSimple returns, which CC's own persisted session already has).
252
+ // Returns the existing sessionId. Keeps CC's prompt cache warm.
253
+ // REBUILD — no session yet, or pi's history has diverged (non-trailing
254
+ // missed messages, e.g. another provider took a turn). Wipes the existing
255
+ // session file (if any) and writes a fresh one containing all prior
256
+ // messages, reusing the same sessionId across rebuilds so UUIDs stay
257
+ // stable for the lifetime of pi's session.
258
+ //
259
+ // Why a full rebuild rather than patching:
260
+ // Injecting deltas into an existing session creates a branch that CC's
261
+ // --resume doesn't follow (documented attempt prior to this). A complete
262
+ // overwrite at the same path is simpler and correct.
263
+ //
264
+ // Why reuse the sessionId across rebuilds:
265
+ // CC re-reads the JSONL on every --resume call — no in-process UUID
266
+ // caching. Validated in tests/exp-session-clear.mjs, including the case
267
+ // where CC had appended its own tool_use/tool_result records between
268
+ // rebuilds. Preserving the UUID means stable log correlation across
269
+ // provider switches and no orphaned session files.
270
+ //
271
+ // Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
272
+ // int-session-resume.mjs) keep grepping the same anchors.
273
+ export function syncSharedSession(
274
+ messages: Context["messages"],
275
+ cwd: string,
276
+ customToolNameToSdk?: Map<string, string>,
277
+ modelId?: string,
278
+ ): SyncResult {
279
+ const priorMessages = messages.slice(0, -1); // everything before the new user prompt
280
+
281
+ // REUSE path
282
+ if (sharedSession && !sharedSession.needsRebuild) {
283
+ const missed = priorMessages.slice(sharedSession.cursor);
284
+ const trailingAssistantOnly =
285
+ missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
286
+ if (missed.length === 0 || trailingAssistantOnly) {
287
+ if (trailingAssistantOnly) {
288
+ setSharedSession({ ...sharedSession, cursor: priorMessages.length, cwd });
289
+ }
290
+ debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
291
+ debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
292
+ return { sessionId: sharedSession.sessionId };
293
+ }
294
+ }
295
+
296
+ // REBUILD path
297
+ if (priorMessages.length === 0) {
298
+ debug(`Case 1: clean start, ${messages.length} total messages`);
299
+ debug(`syncResult: path=clean-start`);
300
+ return { sessionId: null };
301
+ }
302
+ const previousSessionId = sharedSession?.sessionId;
303
+ const previousCursor = sharedSession?.cursor ?? 0;
304
+ // preserveId: rebuild in place (deleteSession + createSession with the
305
+ // existing UUID), so prompt-cache UUIDs stay stable for log correlation
306
+ // and for any tools that key off them. Skipped only when there's a
307
+ // concurrent writer we shouldn't race — see forceRotate docs above.
308
+ const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
309
+ if (preserveId) {
310
+ // Wipe prior jsonl + companion dir (no-op if nothing to wipe).
311
+ deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
312
+ }
313
+ const session = createSession({
314
+ projectPath: cwd,
315
+ claudeDir: process.env.CLAUDE_CONFIG_DIR,
316
+ ...(preserveId ? { sessionId: previousSessionId } : {}),
317
+ ...(modelId ? { model: modelId } : {}),
318
+ });
319
+ convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
320
+ session.save();
321
+ verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
322
+ setSharedSession({ sessionId: session.sessionId, cursor: priorMessages.length, cwd });
323
+ if (previousSessionId === undefined) {
324
+ debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
325
+ } else if (preserveId) {
326
+ const missedCount = priorMessages.length - previousCursor;
327
+ debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
328
+ } else {
329
+ debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.messages.length} records`);
330
+ }
331
+ debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
332
+ debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
333
+ return { sessionId: session.sessionId };
334
+ }
@@ -0,0 +1,134 @@
1
+ import { type AssistantMessage, type AssistantMessageEventStream } from "@earendil-works/pi-ai";
2
+ import { type QueryContext } from "./query-state.js";
3
+
4
+ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
5
+ export const STREAM_IDLE_BACKOFF_HINT_MS = 60_000;
6
+ export const STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
7
+
8
+ type TimerHandle = ReturnType<typeof setTimeout>;
9
+
10
+ export interface StreamIdleWatchdogState {
11
+ activeQuery: unknown | null;
12
+ currentPiStream: AssistantMessageEventStream | null;
13
+ turnOutput: AssistantMessage | null;
14
+ turnSawStreamEvent: boolean;
15
+ turnStarted: boolean;
16
+ }
17
+
18
+ export interface StreamIdleTimeoutInfo {
19
+ idleMs: number;
20
+ timeoutMs: number;
21
+ }
22
+
23
+ export interface StreamIdleWatchdog {
24
+ dispose: () => void;
25
+ noteChunk: () => void;
26
+ refresh: () => void;
27
+ timedOut: () => boolean;
28
+ }
29
+
30
+ export const activeStreamIdleWatchdogs = new WeakMap<QueryContext, StreamIdleWatchdog>();
31
+
32
+ function parseDurationLiteralMs(value: string, defaultUnit: "ms" | "s" = "s"): number | undefined {
33
+ const text = value.trim().toLowerCase();
34
+ if (!text) return undefined;
35
+ if (["off", "false", "disabled", "disable"].includes(text)) return 0;
36
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
37
+ if (!match) return undefined;
38
+ const amount = Number(match[1]);
39
+ if (!Number.isFinite(amount) || amount < 0) return undefined;
40
+ const unit = (match[2] ?? defaultUnit).toLowerCase();
41
+ const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit)
42
+ ? 1
43
+ : ["s", "sec", "secs", "second", "seconds"].includes(unit)
44
+ ? 1000
45
+ : ["m", "min", "mins", "minute", "minutes"].includes(unit)
46
+ ? 60_000
47
+ : undefined;
48
+ if (multiplier === undefined) return undefined;
49
+ const ms = Math.round(amount * multiplier);
50
+ return Number.isFinite(ms) ? ms : undefined;
51
+ }
52
+
53
+ export function streamIdleTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
54
+ const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
55
+ if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
56
+ return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
57
+ }
58
+
59
+ export function formatDurationShort(ms: number): string {
60
+ if (ms < 180_000 && ms % 1000 === 0) return `${ms / 1000}s`;
61
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
62
+ if (ms % 1000 === 0) return `${ms / 1000}s`;
63
+ return `${ms}ms`;
64
+ }
65
+
66
+ export function buildStreamIdleTimeoutErrorMessage(timeoutMs: number): string {
67
+ return `Claude Code stream idle timeout after ${formatDurationShort(timeoutMs)} with no assistant/tool output; treating stalled stream as retryable 529 overloaded/rate limit condition. Retry after ${formatDurationShort(STREAM_IDLE_BACKOFF_HINT_MS)}.`;
68
+ }
69
+
70
+ export function createStreamIdleWatchdog({
71
+ clearTimer = (timer: TimerHandle) => clearTimeout(timer),
72
+ getState,
73
+ now = () => Date.now(),
74
+ onTimeout,
75
+ setTimer = (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
76
+ timeoutMs,
77
+ }: {
78
+ clearTimer?: (timer: TimerHandle) => void;
79
+ getState: () => StreamIdleWatchdogState;
80
+ now?: () => number;
81
+ onTimeout: (info: StreamIdleTimeoutInfo) => void;
82
+ setTimer?: (fn: () => void, delayMs: number) => TimerHandle;
83
+ timeoutMs: number;
84
+ }): StreamIdleWatchdog {
85
+ let disposed = false;
86
+ let lastChunkAt = now();
87
+ let timer: TimerHandle | null = null;
88
+ let didTimeout = false;
89
+
90
+ const clear = () => {
91
+ if (!timer) return;
92
+ try { clearTimer(timer); } catch { /* best effort */ }
93
+ timer = null;
94
+ };
95
+
96
+ const shouldMonitor = (state: StreamIdleWatchdogState): boolean => Boolean(
97
+ timeoutMs > 0
98
+ && state.activeQuery
99
+ && state.currentPiStream
100
+ && state.turnOutput
101
+ && !state.turnStarted
102
+ && !state.turnSawStreamEvent,
103
+ );
104
+
105
+ const schedule = () => {
106
+ clear();
107
+ if (disposed || didTimeout || timeoutMs <= 0) return;
108
+ const state = getState();
109
+ if (!shouldMonitor(state)) return;
110
+ const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
111
+ const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
112
+ const idleMs = Math.max(0, now() - idleStartedAt);
113
+ if (idleMs >= timeoutMs) {
114
+ didTimeout = true;
115
+ onTimeout({ idleMs, timeoutMs });
116
+ return;
117
+ }
118
+ timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
119
+ (timer as { unref?: () => void }).unref?.();
120
+ };
121
+
122
+ return {
123
+ dispose: () => {
124
+ disposed = true;
125
+ clear();
126
+ },
127
+ noteChunk: () => {
128
+ lastChunkAt = now();
129
+ schedule();
130
+ },
131
+ refresh: schedule,
132
+ timedOut: () => didTimeout,
133
+ };
134
+ }
@@ -0,0 +1,53 @@
1
+ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
2
+
3
+ const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
4
+ read: "read", write: "write", edit: "edit", bash: "bash",
5
+ };
6
+
7
+ // --- Provider helpers: tool name mapping ---
8
+
9
+ export function mapToolName(name: string, customToolNameToPi?: Map<string, string>): string {
10
+ const normalized = name.toLowerCase();
11
+ const builtin = SDK_TO_PI_TOOL_NAME[normalized];
12
+ if (builtin) return builtin;
13
+ if (customToolNameToPi) {
14
+ const mapped = customToolNameToPi.get(name) ?? customToolNameToPi.get(normalized);
15
+ if (mapped) return mapped;
16
+ }
17
+ for (const prefix of [
18
+ MCP_TOOL_PREFIX,
19
+ `mcp__${MCP_SERVER_NAME.replace(/-/g, "_")}__`,
20
+ `mcp/${MCP_SERVER_NAME}/`,
21
+ `mcp/${MCP_SERVER_NAME.replace(/-/g, "_")}/`,
22
+ ]) {
23
+ if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
24
+ }
25
+ return name;
26
+ }
27
+
28
+ // Renames for Claude Code SDK param names that differ from pi's native names.
29
+ // Keys not listed here pass through unchanged, so new pi params work automatically.
30
+ const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
31
+ read: { file_path: "path" },
32
+ write: { file_path: "path" },
33
+ edit: { file_path: "path", old_string: "oldText", new_string: "newText", old_text: "oldText", new_text: "newText" },
34
+ };
35
+
36
+ // Maps SDK tool args to pi tool args via key renaming + pass-through.
37
+ // Pi's own prepareArguments hooks handle any structural transforms (e.g. edit oldText/newText → edits[]).
38
+ export function mapToolArgs(
39
+ toolName: string, args: Record<string, unknown> | undefined,
40
+ ): Record<string, unknown> {
41
+ const input = args ?? {};
42
+ const renames = SDK_KEY_RENAMES[toolName.toLowerCase()];
43
+ const result: Record<string, unknown> = {};
44
+ for (const [key, value] of Object.entries(input)) {
45
+ const piKey = renames?.[key] ?? key;
46
+ if (!(piKey in result)) result[piKey] = value; // first alias wins
47
+ }
48
+ // Pi bash has no default timeout; add a safety default
49
+ if (toolName.toLowerCase() === "bash" && result.timeout == null) {
50
+ result.timeout = 120;
51
+ }
52
+ return result;
53
+ }
@@ -52,6 +52,54 @@ export function findUnpairedToolUses(messages: Array<{ role?: string; content?:
52
52
  return missing;
53
53
  }
54
54
 
55
+ export const LOST_TOOL_RESULT_TEXT =
56
+ "Claude bridge: the result of this tool call was lost before the session was rebuilt "
57
+ + "(the turn was interrupted). Treat the call as failed — it may or may not have executed. "
58
+ + "Re-run the tool if its output is still needed.";
59
+
60
+ /**
61
+ * Insert explicit, bridge-authored error results for every unpaired tool_use,
62
+ * IN PLACE, before cc-session-io's repairToolPairing runs.
63
+ *
64
+ * repairToolPairing backfills with a bare "[no tool result recorded]" — a
65
+ * placeholder the model reads as tool OUTPUT and keeps reasoning on (observed:
66
+ * two bash calls in the 2026-07-28 token test, silently treated as if they had
67
+ * returned). An is_error result that says what happened and what to do turns a
68
+ * silent correctness hazard into a recoverable failure.
69
+ *
70
+ * Results are prepended to the immediately following user message (tool_result
71
+ * blocks must lead a user message), or a new user message is inserted when none
72
+ * follows. `missing` must come from findUnpairedToolUses on the same array.
73
+ */
74
+ export function insertLostToolResultPlaceholders(
75
+ messages: Array<{ role?: string; content?: unknown }>,
76
+ missing: MissingToolResult[],
77
+ ): void {
78
+ const block = (id: string) => ({ type: "tool_result", tool_use_id: id, content: LOST_TOOL_RESULT_TEXT, is_error: true });
79
+ const byAssistant = new Map<number, MissingToolResult[]>();
80
+ for (const item of missing) {
81
+ const group = byAssistant.get(item.assistantIndex) ?? [];
82
+ group.push(item);
83
+ byAssistant.set(item.assistantIndex, group);
84
+ }
85
+ // Descending order so inserting a new user message never shifts an index a
86
+ // later (earlier-in-array) group still needs.
87
+ for (const assistantIndex of [...byAssistant.keys()].sort((a, b) => b - a)) {
88
+ const group = byAssistant.get(assistantIndex)!;
89
+ const blocks = group.map((item) => block(item.id));
90
+ const userIndex = group[0].userIndex;
91
+ if (userIndex != null && messages[userIndex]?.role === "user") {
92
+ const user = messages[userIndex] as { role: string; content: unknown };
93
+ const existing = typeof user.content === "string"
94
+ ? (user.content ? [{ type: "text", text: user.content }] : [])
95
+ : Array.isArray(user.content) ? user.content : [];
96
+ user.content = [...blocks, ...existing];
97
+ } else {
98
+ messages.splice(assistantIndex + 1, 0, { role: "user", content: blocks });
99
+ }
100
+ }
101
+ }
102
+
55
103
  export function summarizeMissingToolNames(missing: MissingToolResult[]): Array<{ name: string; count: number }> {
56
104
  const counts = new Map<string, number>();
57
105
  for (const item of missing) counts.set(item.toolName, (counts.get(item.toolName) ?? 0) + 1);
@@ -28,9 +28,15 @@ export function jsonSchemaPropertyToZod(prop: Record<string, unknown>): z.ZodTyp
28
28
  }
29
29
  case "object": {
30
30
  if (prop.properties && typeof prop.properties === "object" && !Array.isArray(prop.properties)) {
31
- base = z.object(jsonSchemaToZodShape(prop));
32
- if (prop.additionalProperties === false) base = (base as z.ZodObject<Record<string, z.ZodTypeAny>>).strict();
33
- else if (prop.additionalProperties === true) base = (base as z.ZodObject<Record<string, z.ZodTypeAny>>).passthrough();
31
+ const obj = z.object(jsonSchemaToZodShape(prop));
32
+ // JSON Schema's default is PERMISSIVE (additionalProperties omitted
33
+ // means allowed); zod's default is to silently STRIP unknown keys.
34
+ // Stripping matters here: the MCP handler compares its validated
35
+ // input against the raw streamed tool_use input to claim a call id,
36
+ // so a silently dropped key made the two diverge and the claim fail
37
+ // (stranding the call — see claimToolCall). Only an explicit
38
+ // additionalProperties:false may reject/strip.
39
+ base = prop.additionalProperties === false ? obj.strict() : obj.passthrough();
34
40
  } else {
35
41
  base = z.record(z.string(), z.unknown());
36
42
  }