@vanillagreen/pi-claude-bridge 1.9.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.
- package/README.md +24 -11
- package/bundle/connector-inventory.js +10 -0
- package/bundle/index.js +2418 -1794
- package/package.json +6 -6
- package/src/assistant-stream.ts +313 -46
- package/src/auth-presence.ts +6 -50
- package/src/bridge-state.ts +43 -1
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +118 -0
- package/src/connector-inventory.ts +52 -0
- package/src/connectors.ts +142 -1
- package/src/convert.ts +14 -0
- package/src/index.ts +330 -172
- package/src/native-provider.ts +89 -0
- package/src/query-state.ts +218 -9
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +42 -10
- package/src/session-persistence.ts +7 -2
- package/src/tool-pairing-audit.ts +48 -0
- package/src/typebox-to-zod.ts +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-claude-bridge",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -142,18 +142,18 @@
|
|
|
142
142
|
}
|
|
143
143
|
},
|
|
144
144
|
"dependencies": {
|
|
145
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
145
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
|
146
146
|
"@anthropic-ai/sdk": "^0.112.4",
|
|
147
147
|
"cc-session-io": "^0.3.1",
|
|
148
148
|
"change-case": "^5.4.4"
|
|
149
149
|
},
|
|
150
150
|
"peerDependencies": {
|
|
151
|
-
"@earendil-works/pi-ai": "
|
|
152
|
-
"@earendil-works/pi-coding-agent": "
|
|
151
|
+
"@earendil-works/pi-ai": ">=0.81.0",
|
|
152
|
+
"@earendil-works/pi-coding-agent": ">=0.81.0"
|
|
153
153
|
},
|
|
154
154
|
"devDependencies": {
|
|
155
|
-
"@earendil-works/pi-ai": "^0.
|
|
156
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
155
|
+
"@earendil-works/pi-ai": "^0.82.1",
|
|
156
|
+
"@earendil-works/pi-coding-agent": "^0.82.1",
|
|
157
157
|
"@types/node": "^24.3.0",
|
|
158
158
|
"esbuild": "^0.28.0",
|
|
159
159
|
"tsx": "^4.21.0",
|
package/src/assistant-stream.ts
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import { calculateCost, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import { type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { appendIntegrityEntry, safeNotify } from "./bridge-state.js";
|
|
4
|
+
import { connectorResultByteSize, recordConnectorCallResult } from "./connector-audit.js";
|
|
5
|
+
import { isChildExecutedTool } from "./connectors.js";
|
|
6
|
+
import { debug, diagDump } from "./debug.js";
|
|
7
|
+
import { ctx, type QueryContext } from "./query-state.js";
|
|
5
8
|
import { mapToolArgs, mapToolName } from "./tool-mapping.js";
|
|
6
9
|
|
|
7
10
|
// --- Usage helpers ---
|
|
8
11
|
|
|
9
12
|
function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// Anthropic reports per-message counters and RE-reports them as the message
|
|
14
|
+
// grows, so the in-flight message's figures are replaced, not added. What is
|
|
15
|
+
// added is every child message already finished in this Pi turn — see
|
|
16
|
+
// `turnUsageCarry` in query-state.ts for why a turn can span several.
|
|
17
|
+
const c = ctx();
|
|
18
|
+
const current = c.currentMessageUsage;
|
|
19
|
+
const carry = c.turnUsageCarry;
|
|
20
|
+
if (usage.input_tokens != null) current.input = usage.input_tokens;
|
|
21
|
+
if (usage.output_tokens != null) current.output = usage.output_tokens;
|
|
22
|
+
if (usage.cache_read_input_tokens != null) current.cacheRead = usage.cache_read_input_tokens;
|
|
23
|
+
if (usage.cache_creation_input_tokens != null) current.cacheWrite = usage.cache_creation_input_tokens;
|
|
24
|
+
output.usage.input = carry.input + current.input;
|
|
25
|
+
output.usage.output = carry.output + current.output;
|
|
26
|
+
output.usage.cacheRead = carry.cacheRead + current.cacheRead;
|
|
27
|
+
output.usage.cacheWrite = carry.cacheWrite + current.cacheWrite;
|
|
14
28
|
output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
15
29
|
calculateCost(model, output.usage);
|
|
16
30
|
const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
|
|
@@ -33,21 +47,105 @@ export function parsePartialJson(input: string, fallback: Record<string, unknown
|
|
|
33
47
|
try { return JSON.parse(input); } catch { return fallback; }
|
|
34
48
|
}
|
|
35
49
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
// Both take the query context explicitly (defaulting to the live one) so the
|
|
51
|
+
// completion/teardown closures in index.ts can finalize the stream of the query
|
|
52
|
+
// they were created for — under reentrancy the live ctx() is the subagent's.
|
|
53
|
+
export function ensureTurnStarted(c: QueryContext = ctx()): void {
|
|
54
|
+
if (!c.turnStarted && c.currentPiStream && c.turnOutput) {
|
|
55
|
+
c.currentPiStream.push({ type: "start", partial: c.turnOutput });
|
|
56
|
+
c.turnStarted = true;
|
|
40
57
|
}
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
export function finalizeCurrentStream(stopReason?: string): void {
|
|
44
|
-
if (!
|
|
45
|
-
debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason:
|
|
46
|
-
if (!
|
|
60
|
+
export function finalizeCurrentStream(stopReason?: string, c: QueryContext = ctx()): void {
|
|
61
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
62
|
+
debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: c.turnOutput.stopReason, error: c.turnOutput.errorMessage})}`);
|
|
63
|
+
if (!c.turnStarted) ensureTurnStarted(c);
|
|
47
64
|
const reason = stopReason === "length" ? "length" : "stop";
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
c.currentPiStream.push({ type: "done", reason, message: c.turnOutput });
|
|
66
|
+
c.currentPiStream.end();
|
|
67
|
+
c.currentPiStream = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Tool-use turn end: deferred to the stream's terminal events ---
|
|
71
|
+
//
|
|
72
|
+
// The Claude Code CLI dispatches MCP tool calls (and the SDK yields the
|
|
73
|
+
// completed assistant message) BEFORE the stream's message_delta arrives — and
|
|
74
|
+
// message_delta is what carries the message's REAL output-token count (measured:
|
|
75
|
+
// handler invoked ~45ms before message_delta on every tool-use turn). Ending
|
|
76
|
+
// the pi stream at either of those early signals therefore froze usage at the
|
|
77
|
+
// message_start placeholder values, which is why pi sessions recorded 1–7
|
|
78
|
+
// output tokens per tool-use turn while the final text turn recorded hundreds
|
|
79
|
+
// (2026-07-28 token test, both bridge panes).
|
|
80
|
+
//
|
|
81
|
+
// So the turn now ends at message_stop, exactly like the streamed-text case,
|
|
82
|
+
// and the early signals only ARM a grace timer. The timer is the deadlock
|
|
83
|
+
// backstop for the one observed case where the terminal events never arrive
|
|
84
|
+
// (pi 0.80 steer draining): pi cannot execute tools before the stream ends, and
|
|
85
|
+
// the MCP handler cannot resolve before pi executes, so a stream that has gone
|
|
86
|
+
// silent must be ended by force — just 1.5s later instead of immediately.
|
|
87
|
+
|
|
88
|
+
const TOOL_USE_END_GRACE_MS = 1500;
|
|
89
|
+
|
|
90
|
+
/** End the current pi stream as a tool_use turn boundary. Safe to call when the
|
|
91
|
+
* turn already ended (no-op). */
|
|
92
|
+
export function endToolUseTurn(c: QueryContext): void {
|
|
93
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
94
|
+
cancelScheduledToolUseEnd(c);
|
|
95
|
+
c.turnOutput.stopReason = "toolUse";
|
|
96
|
+
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
97
|
+
c.currentPiStream.end();
|
|
98
|
+
c.currentPiStream = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function cancelScheduledToolUseEnd(c: QueryContext): void {
|
|
102
|
+
if (!c.scheduledToolUseEnd) return;
|
|
103
|
+
clearTimeout(c.scheduledToolUseEnd.timer);
|
|
104
|
+
c.scheduledToolUseEnd = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Arm the grace timer that force-ends the current tool_use turn if the stream's
|
|
109
|
+
* terminal events never arrive. First arming per stream wins; message_stop (or
|
|
110
|
+
* resetTurnState) disarms it. `action` runs only if the SAME stream is still
|
|
111
|
+
* current when the grace elapses — a turn that ended normally makes it a no-op.
|
|
112
|
+
*/
|
|
113
|
+
export function scheduleToolUseTurnEnd(c: QueryContext, action: () => void, source: string): void {
|
|
114
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
115
|
+
if (c.scheduledToolUseEnd?.stream === c.currentPiStream) return;
|
|
116
|
+
cancelScheduledToolUseEnd(c);
|
|
117
|
+
const stream = c.currentPiStream;
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
if (c.currentPiStream !== stream) return;
|
|
120
|
+
debug(`scheduleToolUseTurnEnd: no terminal stream event within ${TOOL_USE_END_GRACE_MS}ms (${source}) — force-ending tool_use turn`);
|
|
121
|
+
c.scheduledToolUseEnd = null;
|
|
122
|
+
action();
|
|
123
|
+
}, TOOL_USE_END_GRACE_MS);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
c.scheduledToolUseEnd = { stream, timer };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
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.
|
|
136
|
+
*/
|
|
137
|
+
export function reapStaleQueuedResults(c: QueryContext): void {
|
|
138
|
+
const stale = c.takeStaleQueuedResults();
|
|
139
|
+
if (stale.length === 0) return;
|
|
140
|
+
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 });
|
|
144
|
+
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.`,
|
|
147
|
+
"warning",
|
|
148
|
+
);
|
|
51
149
|
}
|
|
52
150
|
|
|
53
151
|
export function updateTurnOutputModel(modelId: unknown): void {
|
|
@@ -58,6 +156,53 @@ export function updateTurnOutputModel(modelId: unknown): void {
|
|
|
58
156
|
c.turnOutput.model = modelId;
|
|
59
157
|
}
|
|
60
158
|
|
|
159
|
+
/** Force-finalizes the current pi turn as a tool_use boundary when its terminal
|
|
160
|
+
* stream events never arrived (the grace-timer action armed by an MCP handler
|
|
161
|
+
* invocation — see scheduleToolUseTurnEnd).
|
|
162
|
+
*
|
|
163
|
+
* Observed with Claude Code under pi 0.80's steer draining (tool result and
|
|
164
|
+
* drained steer arrive in one provider call): the NEXT tool turn's tool_use
|
|
165
|
+
* streams in, the SDK invokes the MCP handler — and neither terminal event
|
|
166
|
+
* 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. */
|
|
171
|
+
export function finalizeToolUseTurnFromMcpInvocation(
|
|
172
|
+
queryCtx: QueryContext,
|
|
173
|
+
toolCallId: string,
|
|
174
|
+
toolName: string,
|
|
175
|
+
mappedArgs: Record<string, unknown>,
|
|
176
|
+
): void {
|
|
177
|
+
if (!queryCtx.currentPiStream || !queryCtx.turnOutput) return;
|
|
178
|
+
let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
|
|
179
|
+
if (idx >= 0) {
|
|
180
|
+
const block = queryCtx.turnBlocks[idx] as any;
|
|
181
|
+
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));
|
|
185
|
+
queryCtx.updateToolCallArgs(block.id, block.arguments);
|
|
186
|
+
delete block.partialJson;
|
|
187
|
+
delete block.index;
|
|
188
|
+
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// The invocation can arrive before the tool_use is streamed at all
|
|
192
|
+
// (observed after a tool-result+steer provider call reset the turn):
|
|
193
|
+
// synthesize the toolCall from the claim — the MCP call carries the
|
|
194
|
+
// authoritative id, name, and arguments.
|
|
195
|
+
queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
|
|
196
|
+
idx = queryCtx.turnBlocks.length - 1;
|
|
197
|
+
const block = queryCtx.turnBlocks[idx] as any;
|
|
198
|
+
queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
|
|
199
|
+
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
|
|
200
|
+
}
|
|
201
|
+
queryCtx.turnSawToolCall = true;
|
|
202
|
+
debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — terminal stream events never arrived`);
|
|
203
|
+
endToolUseTurn(queryCtx);
|
|
204
|
+
}
|
|
205
|
+
|
|
61
206
|
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
62
207
|
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
63
208
|
export function processStreamEvent(
|
|
@@ -75,7 +220,14 @@ export function processStreamEvent(
|
|
|
75
220
|
}
|
|
76
221
|
|
|
77
222
|
if (event?.type === "message_start") {
|
|
223
|
+
// The child moving to a new message proves every result for the previous
|
|
224
|
+
// one reached it; anything still queued can never be consumed.
|
|
225
|
+
reapStaleQueuedResults(c);
|
|
78
226
|
c.resetToolTracking();
|
|
227
|
+
// A new child message begins: bank what the previous one billed before its
|
|
228
|
+
// counters are replaced. No-op on the turn's first, and no-op if this same
|
|
229
|
+
// message was already declared (see beginChildMessage).
|
|
230
|
+
c.beginChildMessage(event.message?.id);
|
|
79
231
|
updateTurnOutputModel(event.message?.model);
|
|
80
232
|
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
|
|
81
233
|
return;
|
|
@@ -84,6 +236,18 @@ export function processStreamEvent(
|
|
|
84
236
|
if (event?.type === "content_block_start") {
|
|
85
237
|
c.turnSawStreamEvent = true;
|
|
86
238
|
ensureTurnStarted();
|
|
239
|
+
// 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.
|
|
242
|
+
c.childExecutedStreamIndexes.delete(event.index);
|
|
243
|
+
if (event.content_block?.type === "tool_use" && isChildExecutedTool(event.content_block.name)) {
|
|
244
|
+
// The child runs this one itself — mirroring it into the Pi stream would
|
|
245
|
+
// make Pi's agent loop dispatch a tool it does not have. See
|
|
246
|
+
// isChildExecutedTool.
|
|
247
|
+
c.noteChildExecutedToolCall(event.content_block.id, event.content_block.name, event.index);
|
|
248
|
+
debug(`processStreamEvent: child-executed tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
87
251
|
if (event.content_block?.type === "text") {
|
|
88
252
|
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
89
253
|
c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
@@ -108,6 +272,14 @@ export function processStreamEvent(
|
|
|
108
272
|
}
|
|
109
273
|
|
|
110
274
|
if (event?.type === "content_block_delta") {
|
|
275
|
+
// A child-executed tool's argument deltas have no Pi block to land in. Skip
|
|
276
|
+
// them here rather than letting the lookup below miss, so the "unmatched"
|
|
277
|
+
// warning keeps meaning "something is wrong". Unlike that stale-event case
|
|
278
|
+
// this IS a live event for the current message, so it still counts as one.
|
|
279
|
+
if (c.childExecutedStreamIndexes.has(event.index)) {
|
|
280
|
+
c.turnSawStreamEvent = true;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
111
283
|
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
112
284
|
const block = c.turnBlocks[index];
|
|
113
285
|
if (!block) {
|
|
@@ -134,6 +306,12 @@ export function processStreamEvent(
|
|
|
134
306
|
}
|
|
135
307
|
|
|
136
308
|
if (event?.type === "content_block_stop") {
|
|
309
|
+
// Same as the delta case: the block was never mirrored, so there is nothing
|
|
310
|
+
// to seal and nothing unmatched about it.
|
|
311
|
+
if (c.childExecutedStreamIndexes.has(event.index)) {
|
|
312
|
+
c.turnSawStreamEvent = true;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
137
315
|
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
138
316
|
const block = c.turnBlocks[index];
|
|
139
317
|
if (!block) {
|
|
@@ -165,14 +343,13 @@ export function processStreamEvent(
|
|
|
165
343
|
}
|
|
166
344
|
|
|
167
345
|
if (event?.type === "message_stop" && c.turnSawToolCall) {
|
|
168
|
-
// Tool call complete — end this pi stream
|
|
169
|
-
// assistant
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
c
|
|
175
|
-
c.currentPiStream = null;
|
|
346
|
+
// Tool call complete — end this pi stream, disarming any grace timer the
|
|
347
|
+
// MCP-invocation or assistant-boundary path armed. This is the NORMAL end
|
|
348
|
+
// for a tool-use turn: message_delta already delivered the message's real
|
|
349
|
+
// usage just above, so the done event carries correct output tokens. The
|
|
350
|
+
// MCP handler blocks the generator until pi delivers the tool result via
|
|
351
|
+
// the next streamSimple call.
|
|
352
|
+
endToolUseTurn(c);
|
|
176
353
|
|
|
177
354
|
// Cursor is updated by the next streamSimple call (tool result delivery path)
|
|
178
355
|
// which sets cursor = context.messages.length with the post-tool-result context.
|
|
@@ -200,6 +377,14 @@ function appendMissingToolUsesFromAssistant(
|
|
|
200
377
|
let sawToolUse = false;
|
|
201
378
|
for (const block of assistantMsg.content) {
|
|
202
379
|
if (block.type !== "tool_use") continue;
|
|
380
|
+
if (isChildExecutedTool(block.name)) {
|
|
381
|
+
// Not a Pi tool call, so it is NOT a turn boundary either: `sawToolUse`
|
|
382
|
+
// stays false for it and the caller keeps streaming this Pi message. The
|
|
383
|
+
// child neither blocks on Pi nor needs a result from it.
|
|
384
|
+
c.noteChildExecutedToolCall(block.id, block.name);
|
|
385
|
+
debug(`assistant message: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
203
388
|
sawToolUse = true;
|
|
204
389
|
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
205
390
|
const name = mapToolName(block.name, customToolNameToPi);
|
|
@@ -229,38 +414,101 @@ function appendMissingToolUsesFromAssistant(
|
|
|
229
414
|
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
230
415
|
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
231
416
|
}
|
|
232
|
-
|
|
417
|
+
// Only while the stream is still live: the SDK's assistant yields carry the
|
|
418
|
+
// message_start placeholder usage (output ≈ 1–7), and once the done event has
|
|
419
|
+
// delivered turnOutput to pi, overwriting its usage with those placeholders
|
|
420
|
+
// would corrupt the very figure message_delta got right.
|
|
421
|
+
if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
233
422
|
return sawToolUse;
|
|
234
423
|
}
|
|
235
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Record that a child-executed tool call came back, from the SDK's `user` message
|
|
427
|
+
* carrying the child's own `tool_result` blocks.
|
|
428
|
+
*
|
|
429
|
+
* This is the only place the bridge ever OBSERVES one of these results, and it is
|
|
430
|
+
* deliberately observation-only: the result already reached the model inside the
|
|
431
|
+
* child, which is the conversation of record for a bridge turn, so re-delivering
|
|
432
|
+
* it would double it. What the bridge could not do before this existed was say
|
|
433
|
+
* anything true about these calls at all — the Pi transcript claimed they failed
|
|
434
|
+
* and nothing anywhere claimed otherwise.
|
|
435
|
+
*
|
|
436
|
+
* The payload is NEVER logged or recorded, only its shape: a connector result is
|
|
437
|
+
* live account data (mail, messages, documents) and the bridge's debug log sits
|
|
438
|
+
* outside a host app's redaction boundary.
|
|
439
|
+
*
|
|
440
|
+
* Observing it is also what makes the call auditable: each one appends a session
|
|
441
|
+
* `CustomEntry` (connector-audit.ts) so the Pi session records that the call
|
|
442
|
+
* happened, without a content block Pi's agent loop could try to dispatch.
|
|
443
|
+
*/
|
|
444
|
+
export function noteChildExecutedToolResults(message: SDKMessage): void {
|
|
445
|
+
const c = ctx();
|
|
446
|
+
if (c.childExecutedToolCalls.size === 0) return;
|
|
447
|
+
const content = (message as SDKMessage & { message?: { content?: unknown } }).message?.content;
|
|
448
|
+
if (!Array.isArray(content)) return;
|
|
449
|
+
for (const block of content) {
|
|
450
|
+
if (block?.type !== "tool_result") continue;
|
|
451
|
+
const name = c.childExecutedToolCalls.get(block.tool_use_id);
|
|
452
|
+
if (!name) continue;
|
|
453
|
+
const isError = block.is_error === true;
|
|
454
|
+
const byteSize = connectorResultByteSize(block.content);
|
|
455
|
+
const audited = recordConnectorCallResult(c, block.tool_use_id, name, isError, byteSize);
|
|
456
|
+
debug(`child-executed tool result: ${name} [${block.tool_use_id}] isError=${isError} byteSize=${byteSize ?? "unknown"} audited=${audited}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
236
460
|
export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
|
|
237
461
|
const c = ctx();
|
|
238
462
|
const assistantMsg = (message as any).message;
|
|
239
463
|
if (!assistantMsg?.content) return;
|
|
240
464
|
updateTurnOutputModel(assistantMsg.model);
|
|
241
465
|
if (c.turnSawStreamEvent) {
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
466
|
+
// The SDK yields the completed assistant message BEFORE the stream's
|
|
467
|
+
// message_delta/message_stop on every tool-use turn (measured — this is
|
|
468
|
+
// the norm, not a fallback). Record any tool_use blocks the stream hasn't
|
|
469
|
+
// delivered yet, but do NOT end the pi stream here: message_delta, which
|
|
470
|
+
// arrives tens of ms later, carries the message's real output-token count,
|
|
471
|
+
// and message_stop is the normal turn end. Ending here froze usage at the
|
|
472
|
+
// message_start placeholders (1–7 output tokens per tool turn). The grace
|
|
473
|
+
// timer force-ends the turn if the terminal events never arrive, so pi
|
|
474
|
+
// still gets to execute the tools and unblock the MCP handlers.
|
|
248
475
|
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
|
|
249
476
|
c.turnSawToolCall = true;
|
|
250
|
-
|
|
251
|
-
c.turnOutput.stopReason = "toolUse";
|
|
252
|
-
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
253
|
-
c.currentPiStream.end();
|
|
254
|
-
c.currentPiStream = null;
|
|
255
|
-
debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
|
|
256
|
-
}
|
|
477
|
+
scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
|
|
257
478
|
}
|
|
258
479
|
return;
|
|
259
480
|
}
|
|
260
|
-
|
|
261
|
-
|
|
481
|
+
// The SDK yields the SAME assistant message more than once (per-block
|
|
482
|
+
// partial copies and the completed message share one id). With stream
|
|
483
|
+
// events, the streamed path already renders content and the duplicates are
|
|
484
|
+
// naturally ignored; on this no-stream-events path each yield used to be
|
|
485
|
+
// re-rendered wholesale — a rate-limited turn printed "You've hit your
|
|
486
|
+
// weekly limit" twice. Same-message yields keep the turn's tracking (a
|
|
487
|
+
// reset mid-message would wipe live tool-claim state) and render only
|
|
488
|
+
// blocks not already rendered.
|
|
489
|
+
const sameMessage = typeof assistantMsg.id === "string" && assistantMsg.id.length > 0 && assistantMsg.id === c.currentMessageId;
|
|
490
|
+
if (!sameMessage) {
|
|
491
|
+
reapStaleQueuedResults(c);
|
|
492
|
+
c.resetToolTracking();
|
|
493
|
+
}
|
|
494
|
+
// The no-stream-events path also sees a message boundary. It is keyed on the
|
|
495
|
+
// message ID rather than trusted blindly, because this branch is ALSO reached
|
|
496
|
+
// for a message whose `message_start` already streamed — any message that
|
|
497
|
+
// produced no content blocks, since `turnSawStreamEvent` only tracks those.
|
|
498
|
+
c.beginChildMessage(assistantMsg.id);
|
|
499
|
+
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}${sameMessage ? " (same message re-yield)" : ""}`);
|
|
500
|
+
// Deduped against the WHOLE current turn, not just same-id re-yields: a
|
|
501
|
+
// rejected turn's synthesized error message ("You've hit your weekly limit")
|
|
502
|
+
// arrives as multiple assistant yields whose ids DIFFER or are absent
|
|
503
|
+
// (measured 2026-07-28: one pi message, two byte-identical text blocks), so
|
|
504
|
+
// an id-keyed guard alone still rendered it twice. A model legitimately
|
|
505
|
+
// producing two byte-identical full blocks in one turn is vanishingly rare;
|
|
506
|
+
// rendering such a duplicate once is the better failure mode.
|
|
507
|
+
const alreadyRendered = (type: string, content: string): boolean =>
|
|
508
|
+
c.turnBlocks.some((b: any) => b.type === type && (type === "text" ? b.text : b.thinking) === content);
|
|
262
509
|
for (const block of assistantMsg.content) {
|
|
263
510
|
if (block.type === "text" && block.text) {
|
|
511
|
+
if (alreadyRendered("text", block.text)) continue;
|
|
264
512
|
ensureTurnStarted();
|
|
265
513
|
c.turnBlocks.push({ type: "text", text: block.text });
|
|
266
514
|
const idx = c.turnBlocks.length - 1;
|
|
@@ -268,6 +516,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
268
516
|
c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
|
|
269
517
|
c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
|
|
270
518
|
} else if (block.type === "thinking") {
|
|
519
|
+
if (alreadyRendered("thinking", block.thinking ?? "")) continue;
|
|
271
520
|
ensureTurnStarted();
|
|
272
521
|
c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
|
|
273
522
|
const idx = c.turnBlocks.length - 1;
|
|
@@ -275,11 +524,29 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
275
524
|
if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
|
|
276
525
|
c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
|
|
277
526
|
} else if (block.type === "tool_use") {
|
|
527
|
+
if (isChildExecutedTool(block.name)) {
|
|
528
|
+
// Same as the streamed path: the child owns this call, so it never
|
|
529
|
+
// becomes a Pi tool call and never ends the turn.
|
|
530
|
+
c.noteChildExecutedToolCall(block.id, block.name);
|
|
531
|
+
debug(`processAssistantMessage fallback: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
278
534
|
ensureTurnStarted();
|
|
279
535
|
c.turnSawToolCall = true;
|
|
280
536
|
const mappedName = mapToolName(block.name, customToolNameToPi);
|
|
281
537
|
const mappedArgs = mapToolArgs(mappedName, block.input);
|
|
282
538
|
c.recordToolCall(block.id, mappedName, mappedArgs);
|
|
539
|
+
// A same-message re-yield of an already-mirrored call refreshes its
|
|
540
|
+
// arguments in place — a second toolCall block would make pi dispatch
|
|
541
|
+
// the tool twice.
|
|
542
|
+
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
543
|
+
if (existingIdx >= 0) {
|
|
544
|
+
const existing = c.turnBlocks[existingIdx] as any;
|
|
545
|
+
existing.name = mappedName;
|
|
546
|
+
existing.arguments = mappedArgs;
|
|
547
|
+
c.updateToolCallArgs(block.id, mappedArgs);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
283
550
|
c.turnBlocks.push({
|
|
284
551
|
type: "toolCall", id: block.id,
|
|
285
552
|
name: mappedName,
|
|
@@ -297,11 +564,11 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
297
564
|
}
|
|
298
565
|
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
299
566
|
|
|
300
|
-
// End the stream on tool_use
|
|
567
|
+
// End the stream on tool_use. Immediate (no grace deferral) ON PURPOSE: this
|
|
568
|
+
// branch only runs when NO content blocks streamed for the message, so there
|
|
569
|
+
// is no reason to expect terminal stream events either, and the completed
|
|
570
|
+
// message's own usage — applied just above — is the best figure available.
|
|
301
571
|
if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
|
|
302
|
-
c
|
|
303
|
-
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
304
|
-
c.currentPiStream.end();
|
|
305
|
-
c.currentPiStream = null;
|
|
572
|
+
endToolUseTurn(c);
|
|
306
573
|
}
|
|
307
574
|
}
|
package/src/auth-presence.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
// "not-used"` as "configured" and the provider would look connected while every
|
|
7
7
|
// request fails at spawn time.
|
|
8
8
|
//
|
|
9
|
-
// This module answers
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// This module answers one pure question: hasClaudeCredentials() — are real
|
|
10
|
+
// credentials present RIGHT NOW? Since 2.0 it feeds the native provider's
|
|
11
|
+
// auth check/resolve (native-provider.ts) and the pre-spawn fail-fast, rather
|
|
12
|
+
// than gating register/unregister transitions (the 1.x decideRegistration
|
|
13
|
+
// state machine is gone — registration is unconditional and pi hides
|
|
14
|
+
// unconfigured providers' models itself).
|
|
13
15
|
//
|
|
14
16
|
// SECURITY: this module only ever checks for the EXISTENCE of credentials — a
|
|
15
17
|
// file's presence, an env var being non-empty, a settings key being a non-empty
|
|
@@ -110,49 +112,3 @@ export function hasClaudeCredentials(
|
|
|
110
112
|
return false;
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
/**
|
|
114
|
-
* Snapshot of the inputs to a registration decision.
|
|
115
|
-
*
|
|
116
|
-
* The bridge keeps two process-global tokens (Symbol.for): a PRIMARY-instance
|
|
117
|
-
* token, claimed unconditionally by the first-loaded module instance, and the
|
|
118
|
-
* stream-guard token holding the registered instance's streamSimple. ONLY the
|
|
119
|
-
* primary instance may ever register/unregister or claim the stream guard — this
|
|
120
|
-
* prevents a subagent module reload (a fresh, non-primary instance) from
|
|
121
|
-
* stealing ownership and registering ITS streamSimple, which would split-brain
|
|
122
|
-
* the shared session/ctx and break tool-result delivery.
|
|
123
|
-
*/
|
|
124
|
-
export interface RegistrationState {
|
|
125
|
-
/** Does the machine have Claude credentials right now? */
|
|
126
|
-
credentialed: boolean;
|
|
127
|
-
/** Is THIS module instance the primary (first-loaded) instance? */
|
|
128
|
-
isPrimary: boolean;
|
|
129
|
-
/** Has this instance already registered (owns the stream guard)? */
|
|
130
|
-
registered: boolean;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export type RegistrationDecision = "register" | "unregister" | "noop";
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Pure decision for extension load, every session_start re-check, and the
|
|
137
|
-
* pre-spawn fail-fast path.
|
|
138
|
-
*
|
|
139
|
-
* Rules:
|
|
140
|
-
* - Not the primary instance → NOOP (never touch registration).
|
|
141
|
-
* - Primary + credentialed + not registered → REGISTER (claim guard + register).
|
|
142
|
-
* - Primary + credentialed + already registered → NOOP.
|
|
143
|
-
* - Primary + uncredentialed → UNREGISTER (defensive).
|
|
144
|
-
*
|
|
145
|
-
* The uncredentialed primary always returns UNREGISTER rather than NOOP:
|
|
146
|
-
* pi.unregisterProvider is idempotent ("Has no effect if the provider was never
|
|
147
|
-
* registered"), and a defensive call is the ONLY way to retract a registration
|
|
148
|
-
* that survived a /reload — the ModelRegistry's registeredProviders is a
|
|
149
|
-
* process-lifetime Map and module reload does NOT clear it. (At extension-load
|
|
150
|
-
* time this defensive unregister only filters the pending-registration queue and
|
|
151
|
-
* cannot mutate the persistent registry; the authoritative retraction happens on
|
|
152
|
-
* the post-load session_start re-check — see applyProviderRegistration.)
|
|
153
|
-
*/
|
|
154
|
-
export function decideRegistration(state: RegistrationState): RegistrationDecision {
|
|
155
|
-
if (!state.isPrimary) return "noop";
|
|
156
|
-
if (state.credentialed) return state.registered ? "noop" : "register";
|
|
157
|
-
return "unregister";
|
|
158
|
-
}
|
package/src/bridge-state.ts
CHANGED
|
@@ -56,6 +56,33 @@ export function safeToolCallSummary(calls: Array<{ id: string; toolName: string;
|
|
|
56
56
|
return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export const INTEGRITY_CUSTOM_TYPE = "claude-bridge-integrity";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Persist a bridge integrity event into the pi session transcript.
|
|
63
|
+
*
|
|
64
|
+
* The diag log and a piUI toast both die with the machine or the render cycle:
|
|
65
|
+
* the 2026-07-28 post-mortem found `Error: Claude bridge: …` messages that were
|
|
66
|
+
* SHOWN but existed nowhere in the pi session file, making analysis from the
|
|
67
|
+
* session alone impossible. A `CustomEntry` closes that gap the same way the
|
|
68
|
+
* connector-call audit does — persisted, never part of built context, never
|
|
69
|
+
* dispatchable by pi's agent loop. Payloads must stay compact metadata (ids,
|
|
70
|
+
* counts, tool names), never tool output.
|
|
71
|
+
*
|
|
72
|
+
* Never throws; returns whether the entry was appended (false outside a pi
|
|
73
|
+
* session — tests, embedded hosts without extensionApi).
|
|
74
|
+
*/
|
|
75
|
+
export function appendIntegrityEntry(label: string, data: Record<string, unknown>): boolean {
|
|
76
|
+
try {
|
|
77
|
+
if (!extensionApi) return false;
|
|
78
|
+
extensionApi.appendEntry(INTEGRITY_CUSTOM_TYPE, { label, at: new Date().toISOString(), ...data });
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
debug("appendIntegrityEntry failed:", error);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
|
|
60
87
|
const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
|
|
61
88
|
if (names.length > limit) shown.push(`+${names.length - limit} more`);
|
|
@@ -75,8 +102,13 @@ export function reportSyntheticToolResultRepair(missing: MissingToolResult[], co
|
|
|
75
102
|
missing: missing.slice(0, 50),
|
|
76
103
|
...context,
|
|
77
104
|
});
|
|
105
|
+
appendIntegrityEntry("repair_tool_pairing_synthetic_results", {
|
|
106
|
+
count: missing.length,
|
|
107
|
+
toolNames,
|
|
108
|
+
sampledToolCallIds: sampledToolCallIds.slice(0, 12),
|
|
109
|
+
});
|
|
78
110
|
safeNotify(
|
|
79
|
-
`Claude bridge: ${missing.length} missing tool result(s) repaired with
|
|
111
|
+
`Claude bridge: ${missing.length} missing tool result(s) repaired with an explicit error placeholder` +
|
|
80
112
|
`${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
|
|
81
113
|
`Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
|
|
82
114
|
"error",
|
|
@@ -111,6 +143,16 @@ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string,
|
|
|
111
143
|
forceRotate: sharedSession.forceRotate === true,
|
|
112
144
|
} : null,
|
|
113
145
|
});
|
|
146
|
+
appendIntegrityEntry("tool_result_delivery_mismatch", {
|
|
147
|
+
reason,
|
|
148
|
+
toolNames: progress.toolNames,
|
|
149
|
+
expectedCount: progress.expectedCount,
|
|
150
|
+
deliveredCount: progress.deliveredCount,
|
|
151
|
+
resolvedCount: progress.resolvedCount,
|
|
152
|
+
waitingIds: progress.waitingIds,
|
|
153
|
+
queuedIds: progress.queuedIds,
|
|
154
|
+
unmatchedResultIds: progress.unmatchedResultIds,
|
|
155
|
+
});
|
|
114
156
|
safeNotify(
|
|
115
157
|
`Claude bridge: tool result delivery interrupted during ${reason}; ` +
|
|
116
158
|
`delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
|