@vanillagreen/pi-claude-bridge 2.0.0 → 3.2.2

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.
@@ -4,17 +4,16 @@ import { appendIntegrityEntry, safeNotify } from "./bridge-state.js";
4
4
  import { connectorResultByteSize, recordConnectorCallResult } from "./connector-audit.js";
5
5
  import { isChildExecutedTool } from "./connectors.js";
6
6
  import { debug, diagDump } from "./debug.js";
7
- import { ctx, type QueryContext } from "./query-state.js";
7
+ import { ctx, failStrandedToolCall, type QueryContext } from "./query-state.js";
8
8
  import { mapToolArgs, mapToolName } from "./tool-mapping.js";
9
9
 
10
10
  // --- Usage helpers ---
11
11
 
12
- function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
12
+ function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>, c: QueryContext): void {
13
13
  // Anthropic reports per-message counters and RE-reports them as the message
14
14
  // grows, so the in-flight message's figures are replaced, not added. What is
15
15
  // added is every child message already finished in this Pi turn — see
16
16
  // `turnUsageCarry` in query-state.ts for why a turn can span several.
17
- const c = ctx();
18
17
  const current = c.currentMessageUsage;
19
18
  const carry = c.turnUsageCarry;
20
19
  if (usage.input_tokens != null) current.input = usage.input_tokens;
@@ -88,10 +87,28 @@ export function finalizeCurrentStream(stopReason?: string, c: QueryContext = ctx
88
87
  const TOOL_USE_END_GRACE_MS = 1500;
89
88
 
90
89
  /** End the current pi stream as a tool_use turn boundary. Safe to call when the
91
- * turn already ended (no-op). */
90
+ * turn already ended (no-op). Every end path funnels here, so this is where
91
+ * two invariants are enforced by construction (vstack#1469): a block still
92
+ * carrying partialJson never ships — Pi executes the done message's content,
93
+ * and truncated arguments must never execute — and every call that DOES ship
94
+ * is stamped forwarded so no lagging replay can dispatch it again. */
92
95
  export function endToolUseTurn(c: QueryContext): void {
93
96
  if (!c.currentPiStream || !c.turnOutput) return;
94
97
  cancelScheduledToolUseEnd(c);
98
+ const partial = (c.turnOutput.content as Array<any>).filter((b) => b?.type === "toolCall" && "partialJson" in b);
99
+ if (partial.length > 0) {
100
+ const calls = partial.map((b) => ({ id: b.id, name: b.name }));
101
+ debug(`endToolUseTurn: pruning ${partial.length} still-partial tool call(s) — truncated arguments never execute:`, calls.map((entry) => `${entry.name} [${entry.id}]`).join(", "));
102
+ diagDump("partial_tool_calls_pruned", { count: partial.length, calls });
103
+ appendIntegrityEntry("partial_tool_calls_pruned", { count: partial.length, calls });
104
+ c.turnOutput.content = (c.turnOutput.content as Array<any>).filter((b) => !(b?.type === "toolCall" && "partialJson" in b));
105
+ }
106
+ // Every tool call Pi is about to execute from this turn is owed a result and
107
+ // must never be dispatched again: a lagging stream replays the same tool_use
108
+ // into the NEXT turn, whose per-message dedup cannot see it (vstack#1469).
109
+ for (const block of c.turnOutput.content as Array<{ type?: string; id?: unknown }>) {
110
+ if (block?.type === "toolCall" && typeof block.id === "string") c.forwardedToolCallIds.add(block.id);
111
+ }
95
112
  c.turnOutput.stopReason = "toolUse";
96
113
  c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
97
114
  c.currentPiStream.end();
@@ -126,36 +143,39 @@ export function scheduleToolUseTurnEnd(c: QueryContext, action: () => void, sour
126
143
  }
127
144
 
128
145
  /**
129
- * Drop queued tool results that no handler can ever consume again, and say so
130
- * everywhere it matters. Runs at a child message boundary by then every
131
- * handler for the previous message has either resolved (directly or from this
132
- * queue) or already returned an error, so anything still queued is the real
133
- * output of a call whose handler gave up. See takeStaleQueuedResults for why
134
- * leaving them queued poisoned every later mismatch report and forced a
135
- * session rebuild per turn.
146
+ * Park queued tool results whose handler has not fired by a child message
147
+ * boundary, and say so everywhere it matters. The boundary is where stale
148
+ * entries would start poisoning mismatch reports but it does NOT prove the
149
+ * handler gave up: the SDK staggers handler invocations, and in the 2026-08-17
150
+ * deadlock session three of five parallel handlers fired after this point
151
+ * (vstack#1469). Parked results stay consumable through
152
+ * takeQueuedOrParkedResult; one that is never consumed belongs to a call the
153
+ * SDK abandoned client-side (permission denial), which is exactly what the
154
+ * notice describes.
136
155
  */
137
156
  export function reapStaleQueuedResults(c: QueryContext): void {
138
157
  const stale = c.takeStaleQueuedResults();
139
158
  if (stale.length === 0) return;
140
159
  const names = stale.map((entry) => entry.toolName);
141
- debug(`reapStaleQueuedResults: dropping ${stale.length} queued tool result(s) with no possible consumer:`, names.join(", "));
142
- diagDump("stale_queued_tool_results_dropped", { count: stale.length, stale });
143
- appendIntegrityEntry("stale_queued_tool_results_dropped", { count: stale.length, stale });
160
+ debug(`reapStaleQueuedResults: parked ${stale.length} early tool result(s) awaiting a late handler:`, names.join(", "));
161
+ diagDump("stale_queued_tool_results_parked", { count: stale.length, stale });
162
+ appendIntegrityEntry("stale_queued_tool_results_parked", { count: stale.length, stale });
144
163
  safeNotify(
145
- `Claude bridge: dropped ${stale.length} tool result(s) whose handler never matched (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
146
- `The model saw an error for these calls and may retry them.`,
164
+ `Claude bridge: parked ${stale.length} early tool result(s) whose handler has not arrived (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
165
+ `A late handler can still consume them.`,
147
166
  "warning",
148
167
  );
149
168
  }
150
169
 
151
- export function updateTurnOutputModel(modelId: unknown): void {
152
- const c = ctx();
170
+ export function updateTurnOutputModel(modelId: unknown, c: QueryContext = ctx()): void {
153
171
  if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
154
172
  if (c.turnOutput.model === modelId) return;
155
173
  debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
156
174
  c.turnOutput.model = modelId;
157
175
  }
158
176
 
177
+ export const FINALIZE_MAX_REARMS = 3;
178
+
159
179
  /** Force-finalizes the current pi turn as a tool_use boundary when its terminal
160
180
  * stream events never arrived (the grace-timer action armed by an MCP handler
161
181
  * invocation — see scheduleToolUseTurnEnd).
@@ -164,29 +184,60 @@ export function updateTurnOutputModel(modelId: unknown): void {
164
184
  * drained steer arrive in one provider call): the NEXT tool turn's tool_use
165
185
  * streams in, the SDK invokes the MCP handler — and neither terminal event
166
186
  * ever arrives. The invocation itself proves the assistant turn is committed,
167
- * so end the pi stream here exactly like the `message_stop` path; otherwise
168
- * the handler blocks on a result pi will never deliver (deadlock). No-op when
169
- * the turn already ended (stream null) or the tool call isn't part of the
170
- * currently streamed turn. */
187
+ * so end the pi stream like the `message_stop` path — with this handler's
188
+ * schema-validated arguments, never a partial parse after settling every
189
+ * sibling whose handler has fired and giving a merely-lagging stream up to
190
+ * FINALIZE_MAX_REARMS extra grace periods for the rest (vstack#1469).
191
+ *
192
+ * The dead-stream guard is a backstop: the grace timer's own stream-identity
193
+ * check means this normally never runs after the turn ended. The primary
194
+ * recovery for a handler whose call missed its turn is the generation-guarded
195
+ * drainStrandedToolCalls at the next provider callback. */
171
196
  export function finalizeToolUseTurnFromMcpInvocation(
172
197
  queryCtx: QueryContext,
173
198
  toolCallId: string,
174
199
  toolName: string,
175
200
  mappedArgs: Record<string, unknown>,
201
+ rearmCount = 0,
176
202
  ): void {
177
- if (!queryCtx.currentPiStream || !queryCtx.turnOutput) return;
203
+ if (!queryCtx.currentPiStream || !queryCtx.turnOutput) {
204
+ // The turn ended without this call. An unforwarded handler here can never
205
+ // be answered — the yield that could have replayed its call was consumed
206
+ // against a null stream, and the dead-mark below suppresses any replay
207
+ // that has not happened yet. Failing it now is what turns the observed
208
+ // multi-hour session deadlock into one retryable error (vstack#1469).
209
+ if (failStrandedToolCall(queryCtx, toolCallId)) {
210
+ debug(`mcp handler: ${toolName} [${toolCallId}] stranded — turn ended before its call reached Pi; resolved with error`);
211
+ diagDump("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
212
+ appendIntegrityEntry("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
213
+ }
214
+ return;
215
+ }
178
216
  let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
179
217
  if (idx >= 0) {
180
218
  const block = queryCtx.turnBlocks[idx] as any;
181
219
  if ("partialJson" in block) {
182
- // Stream ended before content_block_stop settle the args from the
183
- // partial JSON the same way content_block_stop would have.
184
- block.arguments = mapToolArgs(block.name, parsePartialJson(block.partialJson, block.arguments));
220
+ // Stream ended before content_block_stop. The SDK invoked this handler
221
+ // with the COMPLETE schema-validated input, so the handler's copy is
222
+ // authoritative the streamed partial JSON is by definition behind it.
223
+ // Settling from the partial is what forwarded `{}`-argument calls that
224
+ // Pi then executed and errored (vstack#1469), exactly the divergence
225
+ // the synthesize branch below never had.
226
+ block.arguments = mappedArgs;
185
227
  queryCtx.updateToolCallArgs(block.id, block.arguments);
186
228
  delete block.partialJson;
187
229
  delete block.index;
188
230
  queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
189
231
  }
232
+ } else if (queryCtx.forwardedToolCallIds.has(toolCallId) || queryCtx.deadToolCallIds.has(toolCallId)) {
233
+ // Pi already executed this call in an earlier turn (its result arrives or
234
+ // sits parked), or the handler was already failed — either way a second
235
+ // dispatch is the one outcome worse than waiting. Do NOT return: this
236
+ // firing consumed the stream's only grace timer, so the current turn's own
237
+ // blocks must still be settled and the turn still ended below, or a turn
238
+ // that loses its terminal events afterwards has no backstop left and Pi
239
+ // sits busy until manual abort.
240
+ debug(`mcp handler: ${toolName} [${toolCallId}] already ${queryCtx.forwardedToolCallIds.has(toolCallId) ? "forwarded" : "dead"} — not re-emitting`);
190
241
  } else {
191
242
  // The invocation can arrive before the tool_use is streamed at all
192
243
  // (observed after a tool-result+steer provider call reset the turn):
@@ -198,6 +249,46 @@ export function finalizeToolUseTurnFromMcpInvocation(
198
249
  queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
199
250
  queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
200
251
  }
252
+ // Settle every OTHER still-partial block whose handler has fired: each
253
+ // waiting handler carries the authoritative args for its own call.
254
+ for (let i = 0; i < queryCtx.turnBlocks.length; i++) {
255
+ const sibling = queryCtx.turnBlocks[i] as any;
256
+ if (sibling.type !== "toolCall" || !("partialJson" in sibling)) continue;
257
+ const waiting = queryCtx.pendingToolCalls.get(sibling.id);
258
+ if (!waiting) continue;
259
+ sibling.arguments = waiting.args;
260
+ queryCtx.updateToolCallArgs(sibling.id, sibling.arguments);
261
+ delete sibling.partialJson;
262
+ delete sibling.index;
263
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: i, toolCall: sibling, partial: queryCtx.turnOutput });
264
+ }
265
+ // A block still partial here has NO fired handler: its complete arguments
266
+ // exist nowhere on this side of the boundary yet. Ending the turn now would
267
+ // hand Pi truncated arguments to execute — a truncated bash command is not a
268
+ // hypothetical hazard — so give the lagging stream more grace first.
269
+ const unsettled = queryCtx.turnBlocks.filter((b: any) => b.type === "toolCall" && "partialJson" in b);
270
+ if (unsettled.length > 0 && rearmCount < FINALIZE_MAX_REARMS) {
271
+ debug(`mcp handler: ${unsettled.length} sibling tool call(s) still streaming — re-arming grace (${rearmCount + 1}/${FINALIZE_MAX_REARMS})`);
272
+ scheduleToolUseTurnEnd(
273
+ queryCtx,
274
+ () => finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, toolName, mappedArgs, rearmCount + 1),
275
+ `finalize-rearm:${toolName}`,
276
+ );
277
+ return;
278
+ }
279
+ // Grace exhausted with blocks still partial: endToolUseTurn prunes them —
280
+ // truncated arguments never execute; each pruned call replays complete on
281
+ // the SDK's assistant yield in a later turn, or its handler eventually
282
+ // fires and the stranded drain fails it with a retryable error. When the
283
+ // turn holds NOTHING executable (this call suppressed as forwarded/dead and
284
+ // no settled siblings), leave the stream to its own terminal events — an
285
+ // empty tool_use turn would make Pi execute nothing and record an empty
286
+ // assistant message for it.
287
+ const executable = queryCtx.turnBlocks.some((b: any) => b.type === "toolCall" && !("partialJson" in b));
288
+ if (!executable) {
289
+ debug(`mcp handler: nothing executable in this turn after suppression — leaving the stream to its own terminal events (${toolName} [${toolCallId}])`);
290
+ return;
291
+ }
201
292
  queryCtx.turnSawToolCall = true;
202
293
  debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — terminal stream events never arrived`);
203
294
  endToolUseTurn(queryCtx);
@@ -209,8 +300,11 @@ export function processStreamEvent(
209
300
  message: SDKMessage,
210
301
  customToolNameToPi: Map<string, string>,
211
302
  model: Model<any>,
303
+ // The consuming query's CAPTURED context, never the live ctx() (see the C4
304
+ // note in consumeQuery): a reentrant subagent can be pushed while the parent
305
+ // iterator is suspended, and live-state reads would hit the wrong query.
306
+ c: QueryContext = ctx(),
212
307
  ): void {
213
- const c = ctx();
214
308
  if (!c.currentPiStream || !c.turnOutput) return;
215
309
  const event = (message as SDKMessage & { event: any }).event;
216
310
  if (event?.type === "ping") return;
@@ -228,18 +322,20 @@ export function processStreamEvent(
228
322
  // counters are replaced. No-op on the turn's first, and no-op if this same
229
323
  // message was already declared (see beginChildMessage).
230
324
  c.beginChildMessage(event.message?.id);
231
- updateTurnOutputModel(event.message?.model);
232
- if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
325
+ updateTurnOutputModel(event.message?.model, c);
326
+ if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model, c);
233
327
  return;
234
328
  }
235
329
 
236
330
  if (event?.type === "content_block_start") {
237
331
  c.turnSawStreamEvent = true;
238
- ensureTurnStarted();
332
+ ensureTurnStarted(c);
239
333
  // A new block owns this index from here on, so release any child-executed
240
- // claim on it. Belt-and-braces against a missed message_start: without this
241
- // a stale index could silently swallow a later text block's deltas.
334
+ // or suppressed claim on it. Belt-and-braces against a missed
335
+ // message_start: without this a stale index could silently swallow a later
336
+ // text block's deltas.
242
337
  c.childExecutedStreamIndexes.delete(event.index);
338
+ c.suppressedStreamIndexes.delete(event.index);
243
339
  if (event.content_block?.type === "tool_use" && isChildExecutedTool(event.content_block.name)) {
244
340
  // The child runs this one itself — mirroring it into the Pi stream would
245
341
  // make Pi's agent loop dispatch a tool it does not have. See
@@ -255,6 +351,24 @@ export function processStreamEvent(
255
351
  c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
256
352
  c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
257
353
  } else if (event.content_block?.type === "tool_use") {
354
+ const streamedId: unknown = event.content_block.id;
355
+ if (typeof streamedId === "string" && (c.forwardedToolCallIds.has(streamedId) || c.deadToolCallIds.has(streamedId))) {
356
+ // A lagging stream replaying a call Pi already executed — or one whose
357
+ // handler was already failed as stranded — into a later turn. Mirroring
358
+ // it would make Pi dispatch it a second time (vstack#1469).
359
+ c.suppressedStreamIndexes.add(event.index);
360
+ debug(`processStreamEvent: tool_use ${streamedId} already ${c.forwardedToolCallIds.has(streamedId) ? "forwarded" : "dead"} — suppressing duplicate stream block`);
361
+ return;
362
+ }
363
+ if (typeof streamedId === "string" && c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === streamedId)) {
364
+ // Same turn, same id: the completed-message yield beat the stream (its
365
+ // block is already recorded with complete arguments). A second
366
+ // partialJson copy would ship the id twice in one done message — and
367
+ // if its stop never arrives, ship it truncated (vstack#1469).
368
+ c.suppressedStreamIndexes.add(event.index);
369
+ debug(`processStreamEvent: tool_use ${streamedId} already recorded in this turn — suppressing duplicate stream block`);
370
+ return;
371
+ }
258
372
  c.turnSawToolCall = true;
259
373
  const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
260
374
  c.recordToolCall(event.content_block.id, mappedName, {});
@@ -276,7 +390,8 @@ export function processStreamEvent(
276
390
  // them here rather than letting the lookup below miss, so the "unmatched"
277
391
  // warning keeps meaning "something is wrong". Unlike that stale-event case
278
392
  // this IS a live event for the current message, so it still counts as one.
279
- if (c.childExecutedStreamIndexes.has(event.index)) {
393
+ // Suppressed duplicate/dead blocks skip identically.
394
+ if (c.childExecutedStreamIndexes.has(event.index) || c.suppressedStreamIndexes.has(event.index)) {
280
395
  c.turnSawStreamEvent = true;
281
396
  return;
282
397
  }
@@ -308,7 +423,7 @@ export function processStreamEvent(
308
423
  if (event?.type === "content_block_stop") {
309
424
  // Same as the delta case: the block was never mirrored, so there is nothing
310
425
  // to seal and nothing unmatched about it.
311
- if (c.childExecutedStreamIndexes.has(event.index)) {
426
+ if (c.childExecutedStreamIndexes.has(event.index) || c.suppressedStreamIndexes.has(event.index)) {
312
427
  c.turnSawStreamEvent = true;
313
428
  return;
314
429
  }
@@ -338,7 +453,7 @@ export function processStreamEvent(
338
453
 
339
454
  if (event?.type === "message_delta") {
340
455
  c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
341
- if (event.usage) updateUsage(c.turnOutput, event.usage, model);
456
+ if (event.usage) updateUsage(c.turnOutput, event.usage, model, c);
342
457
  return;
343
458
  }
344
459
 
@@ -371,9 +486,16 @@ function appendMissingToolUsesFromAssistant(
371
486
  assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
372
487
  model: Model<any>,
373
488
  customToolNameToPi: Map<string, string>,
489
+ c: QueryContext,
374
490
  ): boolean {
375
- const c = ctx();
376
491
  if (!assistantMsg?.content) return false;
492
+ // With a dead stream, ids are still RECORDED (claims and result matching
493
+ // need them) but the content is never touched: turnBlocks IS the content
494
+ // array of a turnOutput that endToolUseTurn already handed Pi BY REFERENCE
495
+ // in its done event, so a push here appends calls into a delivered message
496
+ // behind Pi's back — whether Pi's dispatch enumerates before or after the
497
+ // push is a microtask race (vstack#1469).
498
+ const streamLive = Boolean(c.currentPiStream && c.turnOutput);
377
499
  let sawToolUse = false;
378
500
  for (const block of assistantMsg.content) {
379
501
  if (block.type !== "tool_use") continue;
@@ -385,11 +507,19 @@ function appendMissingToolUsesFromAssistant(
385
507
  debug(`assistant message: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
386
508
  continue;
387
509
  }
388
- sawToolUse = true;
389
510
  const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
511
+ if (existingIdx < 0 && (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
512
+ // Completed-message replay of a call Pi already executed in an earlier
513
+ // turn (or one already failed as stranded). Not a live Pi turn boundary:
514
+ // sawToolUse stays false for it, and no block is emitted (vstack#1469).
515
+ debug(`assistant message: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
516
+ continue;
517
+ }
518
+ sawToolUse = true;
390
519
  const name = mapToolName(block.name, customToolNameToPi);
391
520
  const mappedArgs = mapToolArgs(name, block.input);
392
521
  c.recordToolCall(block.id, name, mappedArgs);
522
+ if (!streamLive) continue;
393
523
  if (existingIdx >= 0) {
394
524
  const existing = c.turnBlocks[existingIdx] as any;
395
525
  existing.name = name;
@@ -403,7 +533,7 @@ function appendMissingToolUsesFromAssistant(
403
533
  continue;
404
534
  }
405
535
 
406
- ensureTurnStarted();
536
+ ensureTurnStarted(c);
407
537
  c.turnBlocks.push({
408
538
  type: "toolCall", id: block.id,
409
539
  name,
@@ -418,7 +548,7 @@ function appendMissingToolUsesFromAssistant(
418
548
  // message_start placeholder usage (output ≈ 1–7), and once the done event has
419
549
  // delivered turnOutput to pi, overwriting its usage with those placeholders
420
550
  // would corrupt the very figure message_delta got right.
421
- if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model);
551
+ if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
422
552
  return sawToolUse;
423
553
  }
424
554
 
@@ -441,8 +571,7 @@ function appendMissingToolUsesFromAssistant(
441
571
  * `CustomEntry` (connector-audit.ts) so the Pi session records that the call
442
572
  * happened, without a content block Pi's agent loop could try to dispatch.
443
573
  */
444
- export function noteChildExecutedToolResults(message: SDKMessage): void {
445
- const c = ctx();
574
+ export function noteChildExecutedToolResults(message: SDKMessage, c: QueryContext = ctx()): void {
446
575
  if (c.childExecutedToolCalls.size === 0) return;
447
576
  const content = (message as SDKMessage & { message?: { content?: unknown } }).message?.content;
448
577
  if (!Array.isArray(content)) return;
@@ -457,11 +586,10 @@ export function noteChildExecutedToolResults(message: SDKMessage): void {
457
586
  }
458
587
  }
459
588
 
460
- export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
461
- const c = ctx();
589
+ export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext = ctx()): void {
462
590
  const assistantMsg = (message as any).message;
463
591
  if (!assistantMsg?.content) return;
464
- updateTurnOutputModel(assistantMsg.model);
592
+ updateTurnOutputModel(assistantMsg.model, c);
465
593
  if (c.turnSawStreamEvent) {
466
594
  // The SDK yields the completed assistant message BEFORE the stream's
467
595
  // message_delta/message_stop on every tool-use turn (measured — this is
@@ -472,7 +600,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
472
600
  // message_start placeholders (1–7 output tokens per tool turn). The grace
473
601
  // timer force-ends the turn if the terminal events never arrive, so pi
474
602
  // still gets to execute the tools and unblock the MCP handlers.
475
- if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
603
+ if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi, c)) {
476
604
  c.turnSawToolCall = true;
477
605
  scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
478
606
  }
@@ -509,7 +637,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
509
637
  for (const block of assistantMsg.content) {
510
638
  if (block.type === "text" && block.text) {
511
639
  if (alreadyRendered("text", block.text)) continue;
512
- ensureTurnStarted();
640
+ ensureTurnStarted(c);
513
641
  c.turnBlocks.push({ type: "text", text: block.text });
514
642
  const idx = c.turnBlocks.length - 1;
515
643
  c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
@@ -517,7 +645,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
517
645
  c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
518
646
  } else if (block.type === "thinking") {
519
647
  if (alreadyRendered("thinking", block.thinking ?? "")) continue;
520
- ensureTurnStarted();
648
+ ensureTurnStarted(c);
521
649
  c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
522
650
  const idx = c.turnBlocks.length - 1;
523
651
  c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
@@ -531,7 +659,18 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
531
659
  debug(`processAssistantMessage fallback: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
532
660
  continue;
533
661
  }
534
- ensureTurnStarted();
662
+ if (!c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === block.id)
663
+ && (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
664
+ // A cross-turn replay of a call Pi already executed (or one whose
665
+ // handler was already failed as stranded). This exact path produced
666
+ // the observed duplicate dispatches: the completed-message yield lands
667
+ // in the callback AFTER a grace finalize already ended the call's
668
+ // turn, and per-message dedup cannot see across turns (vstack#1469).
669
+ // Not recorded either — a forwarded call must not be claimable again.
670
+ debug(`processAssistantMessage fallback: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
671
+ continue;
672
+ }
673
+ ensureTurnStarted(c);
535
674
  c.turnSawToolCall = true;
536
675
  const mappedName = mapToolName(block.name, customToolNameToPi);
537
676
  const mappedArgs = mapToolArgs(mappedName, block.input);
@@ -557,12 +696,12 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
557
696
  c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
558
697
  c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
559
698
  } else if (block.type === "fallback") {
560
- updateTurnOutputModel(block.to?.model);
699
+ updateTurnOutputModel(block.to?.model, c);
561
700
  } else {
562
701
  debug("processAssistantMessage: unhandled block type", block.type);
563
702
  }
564
703
  }
565
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
704
+ if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
566
705
 
567
706
  // End the stream on tool_use. Immediate (no grace deferral) ON PURPOSE: this
568
707
  // branch only runs when NO content blocks streamed for the message, so there
@@ -0,0 +1,86 @@
1
+ // The /pi-claude command surface: settings/status UI and the deterministic
2
+ // connector-inventory report. Extracted from index.ts (pure move).
3
+
4
+ import { type Model } from "@earendil-works/pi-ai";
5
+ import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
6
+ import { accountSessionScope, resolveClaudeAccountRouter } from "./account-router.js";
7
+ import { loadConfig } from "./config.js";
8
+ import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory.js";
9
+ import { connectorCredentialEnv, readCredentialFile } from "./connector-runtime.js";
10
+
11
+ const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
12
+
13
+ function commandCwd(ctx: unknown): string {
14
+ const value = (ctx as { cwd?: unknown })?.cwd;
15
+ return typeof value === "string" && value.length > 0 ? value : process.cwd();
16
+ }
17
+
18
+ async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
19
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
20
+ const openQuickSettings = host[Symbol.for("vstack.pi.extension-manager.open-quick-settings")];
21
+ if (typeof openQuickSettings !== "function") return false;
22
+ try {
23
+ await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
31
+ const config = loadConfig(commandCwd(ctx));
32
+ ctx.ui.notify([
33
+ `Pi Claude: ${config.enabled === false ? "disabled" : "enabled"}`,
34
+ "Claude account billing settings (including Extra Usage) are managed in Claude.",
35
+ ].join("\n"), "info");
36
+ }
37
+
38
+ // Deterministic connector enumeration for the host app (vstack#838). Reports the
39
+ // failure reason rather than an empty list, so "no connectors" and "could not
40
+ // check" stay distinguishable.
41
+ async function reportConnectorInventory(ctx: {
42
+ ui: ExtensionUIContext;
43
+ model?: Model<any>;
44
+ sessionManager?: { getSessionId?: () => string };
45
+ }): Promise<void> {
46
+ // With a router active, enumerate the CURRENT route's account rather than
47
+ // whatever the process env points at.
48
+ const account = ctx.model
49
+ ? resolveClaudeAccountRouter()?.current(ctx.model.id, ctx.sessionManager?.getSessionId?.())
50
+ : undefined;
51
+ const credentials = resolveClaudeOAuth(readCredentialFile, connectorCredentialEnv(account ? accountSessionScope(account).claudeConfigDir : undefined));
52
+ if (!credentials) {
53
+ ctx.ui.notify("Pi Claude: no Claude OAuth credentials found — cannot enumerate connectors.", "error");
54
+ return;
55
+ }
56
+ const inventory = await listAccountConnectors({ credentials });
57
+ if (!inventory.ok) {
58
+ ctx.ui.notify(`Pi Claude: connector enumeration failed — ${inventory.reason}`, "error");
59
+ return;
60
+ }
61
+ if (inventory.connectors.length === 0) {
62
+ ctx.ui.notify("Pi Claude: this account has no connectors installed.", "info");
63
+ return;
64
+ }
65
+ const names = inventory.connectors.map((c) => c.name).join(", ");
66
+ ctx.ui.notify(`Pi Claude: ${inventory.connectors.length} connector(s) installed — ${names}`, "info");
67
+ }
68
+
69
+ export function registerBridgeCommands(pi: ExtensionAPI): void {
70
+ const guard = pi as unknown as Record<PropertyKey, unknown>;
71
+ if (guard[COMMANDS_REGISTERED_KEY]) return;
72
+ guard[COMMANDS_REGISTERED_KEY] = true;
73
+
74
+ pi.registerCommand("pi-claude", {
75
+ description: "Open Pi Claude settings/status",
76
+ handler: async (args: string, ctx) => {
77
+ if (args.trim()) ctx.ui.notify("Unknown /pi-claude argument.", "warning");
78
+ if (await tryOpenExtensionManagerSettings(ctx)) return;
79
+ showBridgeStatus(ctx);
80
+ },
81
+ });
82
+ pi.registerCommand("pi-claude:connectors", {
83
+ description: "List the Claude account's installed claude.ai connectors",
84
+ handler: async (_args: string, ctx) => reportConnectorInventory(ctx),
85
+ });
86
+ }