@vanillagreen/pi-claude-bridge 2.0.0 → 4.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.
- package/README.md +44 -139
- package/bundle/connector-inventory.js +6 -3
- package/bundle/index.js +2436 -1127
- package/package.json +16 -25
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +189 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +157 -14
- package/src/config.ts +174 -24
- package/src/connector-cache.ts +45 -15
- package/src/connector-inventory.ts +16 -9
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +289 -43
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +6 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +770 -703
- package/src/models.ts +0 -7
- package/src/native-provider.ts +9 -4
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +296 -40
- package/src/rate-limit.ts +18 -15
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +369 -54
- package/src/tool-pairing-audit.ts +69 -0
package/src/assistant-stream.ts
CHANGED
|
@@ -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
|
|
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 (kendex#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 (kendex#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
|
-
*
|
|
130
|
-
* everywhere it matters.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
+
* (kendex#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:
|
|
142
|
-
diagDump("
|
|
143
|
-
appendIntegrityEntry("
|
|
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:
|
|
146
|
-
`
|
|
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
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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 (kendex#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)
|
|
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 (kendex#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
|
|
183
|
-
//
|
|
184
|
-
|
|
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 (kendex#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
|
|
241
|
-
// a stale index could silently swallow a later
|
|
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 (kendex#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 (kendex#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
|
-
|
|
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 (kendex#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 (kendex#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
|
|
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
|
-
|
|
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 (kendex#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("kendex.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 (kendex#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
|
+
}
|