@vanillagreen/pi-claude-bridge 1.8.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -15
- package/bundle/connector-inventory.js +147 -0
- package/bundle/index.js +9367 -8348
- package/package.json +11 -7
- package/src/agents-md.ts +5 -7
- package/src/assistant-stream.ts +574 -0
- package/src/auth-presence.ts +6 -50
- package/src/bridge-state.ts +178 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +13 -7
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +118 -0
- package/src/connector-inventory.ts +333 -0
- package/src/connectors.ts +500 -0
- package/src/convert.ts +14 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +424 -1736
- package/src/models.ts +22 -1
- package/src/native-provider.ts +89 -0
- package/src/query-state.ts +260 -9
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +95 -0
- package/src/session-persistence.ts +334 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
- 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,25 +142,25 @@
|
|
|
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",
|
|
160
160
|
"typescript": "^6.0.3"
|
|
161
161
|
},
|
|
162
162
|
"scripts": {
|
|
163
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent",
|
|
163
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent && esbuild src/connector-inventory.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/connector-inventory.js",
|
|
164
164
|
"prepack": "npm run build",
|
|
165
165
|
"test:unit": "node --import tsx --test tests/unit-*.mjs",
|
|
166
166
|
"test": "set -a && [ -f .env.test ] && . ./.env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
|
|
@@ -198,5 +198,9 @@
|
|
|
198
198
|
"@earendil-works/pi-coding-agent": {
|
|
199
199
|
"optional": true
|
|
200
200
|
}
|
|
201
|
+
},
|
|
202
|
+
"exports": {
|
|
203
|
+
".": "./bundle/index.js",
|
|
204
|
+
"./connector-inventory": "./bundle/connector-inventory.js"
|
|
201
205
|
}
|
|
202
206
|
}
|
package/src/agents-md.ts
CHANGED
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
// (~/.pi, .pi/, .pi, pi) to their Claude Code equivalents so any paths or
|
|
8
8
|
// references in the file still resolve inside the CC subprocess.
|
|
9
9
|
//
|
|
10
|
-
// In isolated mode (CLAUDE_BRIDGE_ISOLATED=1)
|
|
11
|
-
//
|
|
12
|
-
// the full instruction surface.
|
|
10
|
+
// In isolated mode (CLAUDE_BRIDGE_ISOLATED=1), all AGENTS.md discovery is
|
|
11
|
+
// disabled. Embedding hosts provide their instruction surface explicitly.
|
|
13
12
|
|
|
14
13
|
import { existsSync, readFileSync } from "fs";
|
|
15
14
|
import { dirname, join, resolve } from "path";
|
|
@@ -20,10 +19,9 @@ function globalAgentsPath(): string {
|
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export function resolveAgentsMdPath(): string | undefined {
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
22
|
+
if (isolatedFromEnv()) return undefined;
|
|
23
|
+
const fromCwd = findAgentsMdInParents(process.cwd());
|
|
24
|
+
if (fromCwd) return fromCwd;
|
|
27
25
|
const globalPath = globalAgentsPath();
|
|
28
26
|
if (existsSync(globalPath)) return globalPath;
|
|
29
27
|
return undefined;
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { calculateCost, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
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";
|
|
8
|
+
import { mapToolArgs, mapToolName } from "./tool-mapping.js";
|
|
9
|
+
|
|
10
|
+
// --- Usage helpers ---
|
|
11
|
+
|
|
12
|
+
function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
|
|
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;
|
|
28
|
+
output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
29
|
+
calculateCost(model, output.usage);
|
|
30
|
+
const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
|
|
31
|
+
const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
|
|
32
|
+
debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens} cachePct=${cachePct}% model=${model.id}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Provider helpers: misc ---
|
|
36
|
+
|
|
37
|
+
function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
|
|
38
|
+
switch (reason) {
|
|
39
|
+
case "tool_use": return "toolUse";
|
|
40
|
+
case "max_tokens": return "length";
|
|
41
|
+
case "end_turn": default: return "stop";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
|
|
46
|
+
if (!input) return fallback;
|
|
47
|
+
try { return JSON.parse(input); } catch { return fallback; }
|
|
48
|
+
}
|
|
49
|
+
|
|
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;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
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);
|
|
64
|
+
const reason = stopReason === "length" ? "length" : "stop";
|
|
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
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function updateTurnOutputModel(modelId: unknown): void {
|
|
152
|
+
const c = ctx();
|
|
153
|
+
if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
|
|
154
|
+
if (c.turnOutput.model === modelId) return;
|
|
155
|
+
debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
|
|
156
|
+
c.turnOutput.model = modelId;
|
|
157
|
+
}
|
|
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
|
+
|
|
206
|
+
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
207
|
+
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
208
|
+
export function processStreamEvent(
|
|
209
|
+
message: SDKMessage,
|
|
210
|
+
customToolNameToPi: Map<string, string>,
|
|
211
|
+
model: Model<any>,
|
|
212
|
+
): void {
|
|
213
|
+
const c = ctx();
|
|
214
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
215
|
+
const event = (message as SDKMessage & { event: any }).event;
|
|
216
|
+
if (event?.type === "ping") return;
|
|
217
|
+
if (event?.type === "message_stop" && !c.turnSawToolCall) {
|
|
218
|
+
debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
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);
|
|
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);
|
|
231
|
+
updateTurnOutputModel(event.message?.model);
|
|
232
|
+
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (event?.type === "content_block_start") {
|
|
237
|
+
c.turnSawStreamEvent = true;
|
|
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
|
+
}
|
|
251
|
+
if (event.content_block?.type === "text") {
|
|
252
|
+
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
253
|
+
c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
254
|
+
} else if (event.content_block?.type === "thinking") {
|
|
255
|
+
c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
|
|
256
|
+
c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
257
|
+
} else if (event.content_block?.type === "tool_use") {
|
|
258
|
+
c.turnSawToolCall = true;
|
|
259
|
+
const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
|
|
260
|
+
c.recordToolCall(event.content_block.id, mappedName, {});
|
|
261
|
+
c.turnBlocks.push({
|
|
262
|
+
type: "toolCall", id: event.content_block.id,
|
|
263
|
+
name: mappedName,
|
|
264
|
+
arguments: (event.content_block.input as Record<string, unknown>) ?? {},
|
|
265
|
+
partialJson: "", index: event.index,
|
|
266
|
+
});
|
|
267
|
+
c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
268
|
+
} else {
|
|
269
|
+
debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
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
|
+
}
|
|
283
|
+
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
284
|
+
const block = c.turnBlocks[index];
|
|
285
|
+
if (!block) {
|
|
286
|
+
debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
c.turnSawStreamEvent = true;
|
|
290
|
+
if (event.delta?.type === "text_delta" && block.type === "text") {
|
|
291
|
+
block.text += event.delta.text;
|
|
292
|
+
c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
|
|
293
|
+
} else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
|
|
294
|
+
block.thinking += event.delta.thinking;
|
|
295
|
+
c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
|
|
296
|
+
} else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
|
|
297
|
+
block.partialJson += event.delta.partial_json;
|
|
298
|
+
block.arguments = parsePartialJson(block.partialJson, block.arguments);
|
|
299
|
+
c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
|
|
300
|
+
} else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
|
|
301
|
+
block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
|
|
302
|
+
} else {
|
|
303
|
+
debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
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
|
+
}
|
|
315
|
+
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
316
|
+
const block = c.turnBlocks[index];
|
|
317
|
+
if (!block) {
|
|
318
|
+
debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
c.turnSawStreamEvent = true;
|
|
322
|
+
delete block.index;
|
|
323
|
+
if (block.type === "text") {
|
|
324
|
+
c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
|
|
325
|
+
} else if (block.type === "thinking") {
|
|
326
|
+
c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
|
|
327
|
+
} else if (block.type === "toolCall") {
|
|
328
|
+
c.turnSawToolCall = true;
|
|
329
|
+
block.arguments = mapToolArgs(
|
|
330
|
+
block.name, parsePartialJson(block.partialJson, block.arguments),
|
|
331
|
+
);
|
|
332
|
+
c.updateToolCallArgs(block.id, block.arguments);
|
|
333
|
+
delete block.partialJson;
|
|
334
|
+
c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (event?.type === "message_delta") {
|
|
340
|
+
c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
|
|
341
|
+
if (event.usage) updateUsage(c.turnOutput, event.usage, model);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (event?.type === "message_stop" && c.turnSawToolCall) {
|
|
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);
|
|
353
|
+
|
|
354
|
+
// Cursor is updated by the next streamSimple call (tool result delivery path)
|
|
355
|
+
// which sets cursor = context.messages.length with the post-tool-result context.
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (event?.type !== "message_stop" && event?.type !== "ping") {
|
|
360
|
+
debug("processStreamEvent: unhandled event type", event?.type);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The SDK always yields `assistant` messages (completed content blocks) after streaming.
|
|
365
|
+
// When stream_events already delivered the content, this is a no-op. But after
|
|
366
|
+
// resetTurnState (e.g. tool result delivery), if the next turn's assistant message
|
|
367
|
+
// arrives before any stream_events, this is the primary content path. Must maintain
|
|
368
|
+
// the same stream lifecycle as processStreamEvent — including ending the stream on
|
|
369
|
+
// tool_use to prevent deadlock with the MCP handler.
|
|
370
|
+
function appendMissingToolUsesFromAssistant(
|
|
371
|
+
assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
|
|
372
|
+
model: Model<any>,
|
|
373
|
+
customToolNameToPi: Map<string, string>,
|
|
374
|
+
): boolean {
|
|
375
|
+
const c = ctx();
|
|
376
|
+
if (!assistantMsg?.content) return false;
|
|
377
|
+
let sawToolUse = false;
|
|
378
|
+
for (const block of assistantMsg.content) {
|
|
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
|
+
}
|
|
388
|
+
sawToolUse = true;
|
|
389
|
+
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
390
|
+
const name = mapToolName(block.name, customToolNameToPi);
|
|
391
|
+
const mappedArgs = mapToolArgs(name, block.input);
|
|
392
|
+
c.recordToolCall(block.id, name, mappedArgs);
|
|
393
|
+
if (existingIdx >= 0) {
|
|
394
|
+
const existing = c.turnBlocks[existingIdx] as any;
|
|
395
|
+
existing.name = name;
|
|
396
|
+
existing.arguments = mappedArgs;
|
|
397
|
+
c.updateToolCallArgs(block.id, mappedArgs);
|
|
398
|
+
if ("partialJson" in existing) {
|
|
399
|
+
delete existing.partialJson;
|
|
400
|
+
delete existing.index;
|
|
401
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
ensureTurnStarted();
|
|
407
|
+
c.turnBlocks.push({
|
|
408
|
+
type: "toolCall", id: block.id,
|
|
409
|
+
name,
|
|
410
|
+
arguments: mappedArgs,
|
|
411
|
+
});
|
|
412
|
+
const idx = c.turnBlocks.length - 1;
|
|
413
|
+
const toolBlock = c.turnBlocks[idx];
|
|
414
|
+
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
415
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
416
|
+
}
|
|
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);
|
|
422
|
+
return sawToolUse;
|
|
423
|
+
}
|
|
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
|
+
|
|
460
|
+
export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
|
|
461
|
+
const c = ctx();
|
|
462
|
+
const assistantMsg = (message as any).message;
|
|
463
|
+
if (!assistantMsg?.content) return;
|
|
464
|
+
updateTurnOutputModel(assistantMsg.model);
|
|
465
|
+
if (c.turnSawStreamEvent) {
|
|
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.
|
|
475
|
+
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
|
|
476
|
+
c.turnSawToolCall = true;
|
|
477
|
+
scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
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);
|
|
509
|
+
for (const block of assistantMsg.content) {
|
|
510
|
+
if (block.type === "text" && block.text) {
|
|
511
|
+
if (alreadyRendered("text", block.text)) continue;
|
|
512
|
+
ensureTurnStarted();
|
|
513
|
+
c.turnBlocks.push({ type: "text", text: block.text });
|
|
514
|
+
const idx = c.turnBlocks.length - 1;
|
|
515
|
+
c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
|
|
516
|
+
c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
|
|
517
|
+
c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
|
|
518
|
+
} else if (block.type === "thinking") {
|
|
519
|
+
if (alreadyRendered("thinking", block.thinking ?? "")) continue;
|
|
520
|
+
ensureTurnStarted();
|
|
521
|
+
c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
|
|
522
|
+
const idx = c.turnBlocks.length - 1;
|
|
523
|
+
c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
|
|
524
|
+
if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
|
|
525
|
+
c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
|
|
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
|
+
}
|
|
534
|
+
ensureTurnStarted();
|
|
535
|
+
c.turnSawToolCall = true;
|
|
536
|
+
const mappedName = mapToolName(block.name, customToolNameToPi);
|
|
537
|
+
const mappedArgs = mapToolArgs(mappedName, block.input);
|
|
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
|
+
}
|
|
550
|
+
c.turnBlocks.push({
|
|
551
|
+
type: "toolCall", id: block.id,
|
|
552
|
+
name: mappedName,
|
|
553
|
+
arguments: mappedArgs,
|
|
554
|
+
});
|
|
555
|
+
const idx = c.turnBlocks.length - 1;
|
|
556
|
+
const toolBlock = c.turnBlocks[idx];
|
|
557
|
+
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
558
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
559
|
+
} else if (block.type === "fallback") {
|
|
560
|
+
updateTurnOutputModel(block.to?.model);
|
|
561
|
+
} else {
|
|
562
|
+
debug("processAssistantMessage: unhandled block type", block.type);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
566
|
+
|
|
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.
|
|
571
|
+
if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
|
|
572
|
+
endToolUseTurn(c);
|
|
573
|
+
}
|
|
574
|
+
}
|