@vanillagreen/pi-claude-bridge 1.8.0 → 1.9.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 +60 -10
- package/bundle/connector-inventory.js +137 -0
- package/bundle/index.js +8682 -8287
- package/package.json +6 -2
- package/src/agents-md.ts +5 -7
- package/src/assistant-stream.ts +307 -0
- package/src/bridge-state.ts +136 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +13 -7
- package/src/connector-inventory.ts +281 -0
- package/src/connectors.ts +359 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +109 -1579
- package/src/models.ts +22 -1
- package/src/query-state.ts +42 -0
- package/src/rate-limit.ts +63 -0
- package/src/session-persistence.ts +329 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-claude-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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": [
|
|
@@ -160,7 +160,7 @@
|
|
|
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,307 @@
|
|
|
1
|
+
import { calculateCost, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { debug } from "./debug.js";
|
|
4
|
+
import { ctx } from "./query-state.js";
|
|
5
|
+
import { mapToolArgs, mapToolName } from "./tool-mapping.js";
|
|
6
|
+
|
|
7
|
+
// --- Usage helpers ---
|
|
8
|
+
|
|
9
|
+
function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
|
|
10
|
+
if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
|
|
11
|
+
if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
|
|
12
|
+
if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
|
|
13
|
+
if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
|
|
14
|
+
output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
15
|
+
calculateCost(model, output.usage);
|
|
16
|
+
const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
|
|
17
|
+
const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
|
|
18
|
+
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}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Provider helpers: misc ---
|
|
22
|
+
|
|
23
|
+
function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
|
|
24
|
+
switch (reason) {
|
|
25
|
+
case "tool_use": return "toolUse";
|
|
26
|
+
case "max_tokens": return "length";
|
|
27
|
+
case "end_turn": default: return "stop";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
|
|
32
|
+
if (!input) return fallback;
|
|
33
|
+
try { return JSON.parse(input); } catch { return fallback; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ensureTurnStarted(): void {
|
|
37
|
+
if (!ctx().turnStarted && ctx().currentPiStream && ctx().turnOutput) {
|
|
38
|
+
ctx().currentPiStream!.push({ type: "start", partial: ctx().turnOutput });
|
|
39
|
+
ctx().turnStarted = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function finalizeCurrentStream(stopReason?: string): void {
|
|
44
|
+
if (!ctx().currentPiStream || !ctx().turnOutput) return;
|
|
45
|
+
debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: ctx().turnOutput!.stopReason, error: ctx().turnOutput!.errorMessage})}`);
|
|
46
|
+
if (!ctx().turnStarted) ensureTurnStarted();
|
|
47
|
+
const reason = stopReason === "length" ? "length" : "stop";
|
|
48
|
+
ctx().currentPiStream!.push({ type: "done", reason, message: ctx().turnOutput });
|
|
49
|
+
ctx().currentPiStream!.end();
|
|
50
|
+
ctx().currentPiStream = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function updateTurnOutputModel(modelId: unknown): void {
|
|
54
|
+
const c = ctx();
|
|
55
|
+
if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
|
|
56
|
+
if (c.turnOutput.model === modelId) return;
|
|
57
|
+
debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
|
|
58
|
+
c.turnOutput.model = modelId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
62
|
+
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
63
|
+
export function processStreamEvent(
|
|
64
|
+
message: SDKMessage,
|
|
65
|
+
customToolNameToPi: Map<string, string>,
|
|
66
|
+
model: Model<any>,
|
|
67
|
+
): void {
|
|
68
|
+
const c = ctx();
|
|
69
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
70
|
+
const event = (message as SDKMessage & { event: any }).event;
|
|
71
|
+
if (event?.type === "ping") return;
|
|
72
|
+
if (event?.type === "message_stop" && !c.turnSawToolCall) {
|
|
73
|
+
debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (event?.type === "message_start") {
|
|
78
|
+
c.resetToolTracking();
|
|
79
|
+
updateTurnOutputModel(event.message?.model);
|
|
80
|
+
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (event?.type === "content_block_start") {
|
|
85
|
+
c.turnSawStreamEvent = true;
|
|
86
|
+
ensureTurnStarted();
|
|
87
|
+
if (event.content_block?.type === "text") {
|
|
88
|
+
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
89
|
+
c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
90
|
+
} else if (event.content_block?.type === "thinking") {
|
|
91
|
+
c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
|
|
92
|
+
c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
93
|
+
} else if (event.content_block?.type === "tool_use") {
|
|
94
|
+
c.turnSawToolCall = true;
|
|
95
|
+
const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
|
|
96
|
+
c.recordToolCall(event.content_block.id, mappedName, {});
|
|
97
|
+
c.turnBlocks.push({
|
|
98
|
+
type: "toolCall", id: event.content_block.id,
|
|
99
|
+
name: mappedName,
|
|
100
|
+
arguments: (event.content_block.input as Record<string, unknown>) ?? {},
|
|
101
|
+
partialJson: "", index: event.index,
|
|
102
|
+
});
|
|
103
|
+
c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
104
|
+
} else {
|
|
105
|
+
debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (event?.type === "content_block_delta") {
|
|
111
|
+
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
112
|
+
const block = c.turnBlocks[index];
|
|
113
|
+
if (!block) {
|
|
114
|
+
debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
c.turnSawStreamEvent = true;
|
|
118
|
+
if (event.delta?.type === "text_delta" && block.type === "text") {
|
|
119
|
+
block.text += event.delta.text;
|
|
120
|
+
c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
|
|
121
|
+
} else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
|
|
122
|
+
block.thinking += event.delta.thinking;
|
|
123
|
+
c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
|
|
124
|
+
} else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
|
|
125
|
+
block.partialJson += event.delta.partial_json;
|
|
126
|
+
block.arguments = parsePartialJson(block.partialJson, block.arguments);
|
|
127
|
+
c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
|
|
128
|
+
} else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
|
|
129
|
+
block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
|
|
130
|
+
} else {
|
|
131
|
+
debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (event?.type === "content_block_stop") {
|
|
137
|
+
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
138
|
+
const block = c.turnBlocks[index];
|
|
139
|
+
if (!block) {
|
|
140
|
+
debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
c.turnSawStreamEvent = true;
|
|
144
|
+
delete block.index;
|
|
145
|
+
if (block.type === "text") {
|
|
146
|
+
c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
|
|
147
|
+
} else if (block.type === "thinking") {
|
|
148
|
+
c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
|
|
149
|
+
} else if (block.type === "toolCall") {
|
|
150
|
+
c.turnSawToolCall = true;
|
|
151
|
+
block.arguments = mapToolArgs(
|
|
152
|
+
block.name, parsePartialJson(block.partialJson, block.arguments),
|
|
153
|
+
);
|
|
154
|
+
c.updateToolCallArgs(block.id, block.arguments);
|
|
155
|
+
delete block.partialJson;
|
|
156
|
+
c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (event?.type === "message_delta") {
|
|
162
|
+
c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
|
|
163
|
+
if (event.usage) updateUsage(c.turnOutput, event.usage, model);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (event?.type === "message_stop" && c.turnSawToolCall) {
|
|
168
|
+
// Tool call complete — end this pi stream. The SDK will still yield an
|
|
169
|
+
// assistant message for this turn, but currentPiStream=null causes
|
|
170
|
+
// consumeQuery to skip it. The MCP handler blocks the generator until
|
|
171
|
+
// pi delivers the tool result via the next streamSimple call.
|
|
172
|
+
c.turnOutput.stopReason = "toolUse";
|
|
173
|
+
c.currentPiStream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
174
|
+
c.currentPiStream!.end();
|
|
175
|
+
c.currentPiStream = null;
|
|
176
|
+
|
|
177
|
+
// Cursor is updated by the next streamSimple call (tool result delivery path)
|
|
178
|
+
// which sets cursor = context.messages.length with the post-tool-result context.
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (event?.type !== "message_stop" && event?.type !== "ping") {
|
|
183
|
+
debug("processStreamEvent: unhandled event type", event?.type);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The SDK always yields `assistant` messages (completed content blocks) after streaming.
|
|
188
|
+
// When stream_events already delivered the content, this is a no-op. But after
|
|
189
|
+
// resetTurnState (e.g. tool result delivery), if the next turn's assistant message
|
|
190
|
+
// arrives before any stream_events, this is the primary content path. Must maintain
|
|
191
|
+
// the same stream lifecycle as processStreamEvent — including ending the stream on
|
|
192
|
+
// tool_use to prevent deadlock with the MCP handler.
|
|
193
|
+
function appendMissingToolUsesFromAssistant(
|
|
194
|
+
assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
|
|
195
|
+
model: Model<any>,
|
|
196
|
+
customToolNameToPi: Map<string, string>,
|
|
197
|
+
): boolean {
|
|
198
|
+
const c = ctx();
|
|
199
|
+
if (!assistantMsg?.content) return false;
|
|
200
|
+
let sawToolUse = false;
|
|
201
|
+
for (const block of assistantMsg.content) {
|
|
202
|
+
if (block.type !== "tool_use") continue;
|
|
203
|
+
sawToolUse = true;
|
|
204
|
+
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
205
|
+
const name = mapToolName(block.name, customToolNameToPi);
|
|
206
|
+
const mappedArgs = mapToolArgs(name, block.input);
|
|
207
|
+
c.recordToolCall(block.id, name, mappedArgs);
|
|
208
|
+
if (existingIdx >= 0) {
|
|
209
|
+
const existing = c.turnBlocks[existingIdx] as any;
|
|
210
|
+
existing.name = name;
|
|
211
|
+
existing.arguments = mappedArgs;
|
|
212
|
+
c.updateToolCallArgs(block.id, mappedArgs);
|
|
213
|
+
if ("partialJson" in existing) {
|
|
214
|
+
delete existing.partialJson;
|
|
215
|
+
delete existing.index;
|
|
216
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
ensureTurnStarted();
|
|
222
|
+
c.turnBlocks.push({
|
|
223
|
+
type: "toolCall", id: block.id,
|
|
224
|
+
name,
|
|
225
|
+
arguments: mappedArgs,
|
|
226
|
+
});
|
|
227
|
+
const idx = c.turnBlocks.length - 1;
|
|
228
|
+
const toolBlock = c.turnBlocks[idx];
|
|
229
|
+
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
230
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
231
|
+
}
|
|
232
|
+
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
233
|
+
return sawToolUse;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
|
|
237
|
+
const c = ctx();
|
|
238
|
+
const assistantMsg = (message as any).message;
|
|
239
|
+
if (!assistantMsg?.content) return;
|
|
240
|
+
updateTurnOutputModel(assistantMsg.model);
|
|
241
|
+
if (c.turnSawStreamEvent) {
|
|
242
|
+
// Claude Agent SDK can yield the completed assistant message before (or
|
|
243
|
+
// instead of) a stream_event message_stop for a tool-use turn. Treat that
|
|
244
|
+
// assistant message as a hard turn boundary so Pi executes the tool calls
|
|
245
|
+
// and the MCP handlers stay blocked until real tool results are delivered.
|
|
246
|
+
// Without this fallback, Claude Code can continue internally with empty MCP
|
|
247
|
+
// results and Pi only sees the real outputs one render cycle later.
|
|
248
|
+
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
|
|
249
|
+
c.turnSawToolCall = true;
|
|
250
|
+
if (c.currentPiStream && c.turnOutput) {
|
|
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
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
c.resetToolTracking();
|
|
261
|
+
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
|
|
262
|
+
for (const block of assistantMsg.content) {
|
|
263
|
+
if (block.type === "text" && block.text) {
|
|
264
|
+
ensureTurnStarted();
|
|
265
|
+
c.turnBlocks.push({ type: "text", text: block.text });
|
|
266
|
+
const idx = c.turnBlocks.length - 1;
|
|
267
|
+
c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
|
|
268
|
+
c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
|
|
269
|
+
c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
|
|
270
|
+
} else if (block.type === "thinking") {
|
|
271
|
+
ensureTurnStarted();
|
|
272
|
+
c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
|
|
273
|
+
const idx = c.turnBlocks.length - 1;
|
|
274
|
+
c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
|
|
275
|
+
if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
|
|
276
|
+
c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
|
|
277
|
+
} else if (block.type === "tool_use") {
|
|
278
|
+
ensureTurnStarted();
|
|
279
|
+
c.turnSawToolCall = true;
|
|
280
|
+
const mappedName = mapToolName(block.name, customToolNameToPi);
|
|
281
|
+
const mappedArgs = mapToolArgs(mappedName, block.input);
|
|
282
|
+
c.recordToolCall(block.id, mappedName, mappedArgs);
|
|
283
|
+
c.turnBlocks.push({
|
|
284
|
+
type: "toolCall", id: block.id,
|
|
285
|
+
name: mappedName,
|
|
286
|
+
arguments: mappedArgs,
|
|
287
|
+
});
|
|
288
|
+
const idx = c.turnBlocks.length - 1;
|
|
289
|
+
const toolBlock = c.turnBlocks[idx];
|
|
290
|
+
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
291
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
292
|
+
} else if (block.type === "fallback") {
|
|
293
|
+
updateTurnOutputModel(block.to?.model);
|
|
294
|
+
} else {
|
|
295
|
+
debug("processAssistantMessage: unhandled block type", block.type);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
299
|
+
|
|
300
|
+
// End the stream on tool_use, same as processStreamEvent's message_stop handler.
|
|
301
|
+
if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
|
|
302
|
+
c.turnOutput.stopReason = "toolUse";
|
|
303
|
+
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
304
|
+
c.currentPiStream.end();
|
|
305
|
+
c.currentPiStream = null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { debug, diagDump, diagLogPath } from "./debug.js";
|
|
3
|
+
import { type QueryContext } from "./query-state.js";
|
|
4
|
+
import { summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
|
|
5
|
+
|
|
6
|
+
export interface SessionState {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
cursor: number;
|
|
9
|
+
cwd: string;
|
|
10
|
+
// Force the next syncSharedSession call down the REBUILD path. Set when
|
|
11
|
+
// pi has mutated its messages array out from under us (compact, tree
|
|
12
|
+
// navigation) or after an abort left the JSONL in an indeterminate state.
|
|
13
|
+
// REBUILD wipes and rewrites the file to match pi's current history.
|
|
14
|
+
needsRebuild?: boolean;
|
|
15
|
+
// Set ONLY after an abort. The killed CC subprocess may still be flushing
|
|
16
|
+
// a late "[Request interrupted by user]" record to the session JSONL.
|
|
17
|
+
// Reusing the same sessionId/path would race that orphan write into our
|
|
18
|
+
// fresh file and break CC's parent-uuid chain on the next resume. When
|
|
19
|
+
// this flag is set, REBUILD takes a fresh UUID and skips deleteSession
|
|
20
|
+
// so the orphan writes land on a dead inode. Compact/tree do NOT set
|
|
21
|
+
// this — there's no concurrent CC writer during those events, so
|
|
22
|
+
// in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
|
|
23
|
+
forceRotate?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Shared mutable bridge state. Lives in its own module so the extracted
|
|
27
|
+
// modules and index.ts observe the SAME state: ESM live bindings let every
|
|
28
|
+
// importer READ these `let` bindings live, but only this module may assign
|
|
29
|
+
// them — cross-module writes must go through the setters below.
|
|
30
|
+
export let sharedSession: SessionState | null = null;
|
|
31
|
+
export let extensionApi: ExtensionAPI | undefined;
|
|
32
|
+
export let piUI: ExtensionUIContext | undefined;
|
|
33
|
+
|
|
34
|
+
export function setSharedSession(next: SessionState | null): void {
|
|
35
|
+
sharedSession = next;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setExtensionApi(next: ExtensionAPI | undefined): void {
|
|
39
|
+
extensionApi = next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setPiUI(next: ExtensionUIContext | undefined): void {
|
|
43
|
+
piUI = next;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function safeNotify(message: string, level: "info" | "warning" | "error" = "warning"): void {
|
|
47
|
+
try { piUI?.notify(message, level); }
|
|
48
|
+
catch (error) { debug("notify failed:", error); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function argKeys(args: Record<string, unknown> | undefined): string[] {
|
|
52
|
+
return Object.keys(args ?? {}).sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function safeToolCallSummary(calls: Array<{ id: string; toolName: string; arguments?: Record<string, unknown> }>): Array<{ id: string; toolName: string; argKeys: string[] }> {
|
|
56
|
+
return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
|
|
60
|
+
const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
|
|
61
|
+
if (names.length > limit) shown.push(`+${names.length - limit} more`);
|
|
62
|
+
return shown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function reportSyntheticToolResultRepair(missing: MissingToolResult[], context: Record<string, unknown>): void {
|
|
66
|
+
try {
|
|
67
|
+
if (missing.length === 0) return;
|
|
68
|
+
const toolNames = summarizeMissingToolNames(missing);
|
|
69
|
+
const toolNameSummary = compactToolNameSummary(toolNames);
|
|
70
|
+
const sampledToolCallIds = missing.slice(0, 50).map((item) => item.id);
|
|
71
|
+
diagDump("repair_tool_pairing_synthetic_results", {
|
|
72
|
+
count: missing.length,
|
|
73
|
+
toolNames,
|
|
74
|
+
sampledToolCallIds,
|
|
75
|
+
missing: missing.slice(0, 50),
|
|
76
|
+
...context,
|
|
77
|
+
});
|
|
78
|
+
safeNotify(
|
|
79
|
+
`Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
|
|
80
|
+
`${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
|
|
81
|
+
`Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
|
|
82
|
+
"error",
|
|
83
|
+
);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
debug("reportSyntheticToolResultRepair failed:", error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
|
|
90
|
+
try {
|
|
91
|
+
if (queryCtx.reportedToolResultMismatch) return false;
|
|
92
|
+
const progress = queryCtx.toolResultProgress();
|
|
93
|
+
const hasMismatch = progress.expectedCount > 0
|
|
94
|
+
? progress.unresolvedIds.length > 0 || progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0
|
|
95
|
+
: progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
|
|
96
|
+
if (!hasMismatch) return false;
|
|
97
|
+
queryCtx.reportedToolResultMismatch = true;
|
|
98
|
+
if (sharedSession) {
|
|
99
|
+
sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
|
|
100
|
+
}
|
|
101
|
+
const toolNameSummary = compactToolNameSummary(progress.toolNames);
|
|
102
|
+
diagDump("tool_result_delivery_mismatch", {
|
|
103
|
+
reason,
|
|
104
|
+
cwd,
|
|
105
|
+
progress,
|
|
106
|
+
activeQueryExists: queryCtx.activeQuery !== null,
|
|
107
|
+
sharedSession: sharedSession ? {
|
|
108
|
+
sessionId: sharedSession.sessionId.slice(0, 8),
|
|
109
|
+
cursor: sharedSession.cursor,
|
|
110
|
+
needsRebuild: sharedSession.needsRebuild === true,
|
|
111
|
+
forceRotate: sharedSession.forceRotate === true,
|
|
112
|
+
} : null,
|
|
113
|
+
});
|
|
114
|
+
safeNotify(
|
|
115
|
+
`Claude bridge: tool result delivery interrupted during ${reason}; ` +
|
|
116
|
+
`delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
|
|
117
|
+
`waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
|
|
118
|
+
`${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
|
|
119
|
+
`Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
|
|
120
|
+
"error",
|
|
121
|
+
);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
debug("reportToolResultMismatch failed:", error);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
|
|
130
|
+
if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
|
|
131
|
+
if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
|
|
135
|
+
return { sharedSession };
|
|
136
|
+
}
|