@vanillagreen/pi-claude-bridge 1.6.2 → 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 +87 -6
- package/bundle/connector-inventory.js +137 -0
- package/bundle/index.js +28385 -22407
- package/package.json +10 -6
- package/src/agents-md.ts +12 -4
- package/src/assistant-stream.ts +307 -0
- package/src/auth-presence.ts +158 -0
- package/src/bridge-state.ts +136 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +83 -5
- package/src/connector-inventory.ts +281 -0
- package/src/connectors.ts +359 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +339 -1428
- package/src/models.ts +22 -1
- package/src/prompt-context.ts +3 -8
- 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": [
|
|
@@ -142,8 +142,8 @@
|
|
|
142
142
|
}
|
|
143
143
|
},
|
|
144
144
|
"dependencies": {
|
|
145
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
146
|
-
"@anthropic-ai/sdk": "0.
|
|
145
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.215",
|
|
146
|
+
"@anthropic-ai/sdk": "^0.112.4",
|
|
147
147
|
"cc-session-io": "^0.3.1",
|
|
148
148
|
"change-case": "^5.4.4"
|
|
149
149
|
},
|
|
@@ -152,15 +152,15 @@
|
|
|
152
152
|
"@earendil-works/pi-coding-agent": "*"
|
|
153
153
|
},
|
|
154
154
|
"devDependencies": {
|
|
155
|
-
"@earendil-works/pi-ai": "^0.
|
|
156
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
155
|
+
"@earendil-works/pi-ai": "^0.80.10",
|
|
156
|
+
"@earendil-works/pi-coding-agent": "^0.80.10",
|
|
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
|
@@ -2,20 +2,28 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Pi uses AGENTS.md for long-lived instructions; Claude Code reads the same
|
|
4
4
|
// content under "# CLAUDE.md". We walk up from cwd looking for AGENTS.md,
|
|
5
|
-
// fall back to ~/.pi/agent/AGENTS.md
|
|
5
|
+
// fall back to <piUserDir>/AGENTS.md (~/.pi/agent/AGENTS.md unless
|
|
6
|
+
// PI_CODING_AGENT_DIR points elsewhere), and rewrite pi-specific references
|
|
6
7
|
// (~/.pi, .pi/, .pi, pi) to their Claude Code equivalents so any paths or
|
|
7
8
|
// references in the file still resolve inside the CC subprocess.
|
|
9
|
+
//
|
|
10
|
+
// In isolated mode (CLAUDE_BRIDGE_ISOLATED=1), all AGENTS.md discovery is
|
|
11
|
+
// disabled. Embedding hosts provide their instruction surface explicitly.
|
|
8
12
|
|
|
9
13
|
import { existsSync, readFileSync } from "fs";
|
|
10
|
-
import { homedir } from "os";
|
|
11
14
|
import { dirname, join, resolve } from "path";
|
|
15
|
+
import { isolatedFromEnv, piUserDir } from "./config.js";
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
function globalAgentsPath(): string {
|
|
18
|
+
return join(piUserDir(), "AGENTS.md");
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
export function resolveAgentsMdPath(): string | undefined {
|
|
22
|
+
if (isolatedFromEnv()) return undefined;
|
|
16
23
|
const fromCwd = findAgentsMdInParents(process.cwd());
|
|
17
24
|
if (fromCwd) return fromCwd;
|
|
18
|
-
|
|
25
|
+
const globalPath = globalAgentsPath();
|
|
26
|
+
if (existsSync(globalPath)) return globalPath;
|
|
19
27
|
return undefined;
|
|
20
28
|
}
|
|
21
29
|
|
|
@@ -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,158 @@
|
|
|
1
|
+
// --- Claude credential presence (availability honesty) ---
|
|
2
|
+
//
|
|
3
|
+
// The bridge may only advertise claude-bridge models when the machine actually
|
|
4
|
+
// has Claude credentials the Claude Agent SDK can authenticate with. Otherwise
|
|
5
|
+
// pi's ModelRegistry.hasConfiguredAuth() would treat the dummy `apiKey:
|
|
6
|
+
// "not-used"` as "configured" and the provider would look connected while every
|
|
7
|
+
// request fails at spawn time.
|
|
8
|
+
//
|
|
9
|
+
// This module answers two pure questions used to gate registration:
|
|
10
|
+
// 1. hasClaudeCredentials() — are real credentials present RIGHT NOW?
|
|
11
|
+
// 2. decideRegistration() — given credential presence + the primary-instance
|
|
12
|
+
// / stream-guard tokens, should we register / unregister / do nothing?
|
|
13
|
+
//
|
|
14
|
+
// SECURITY: this module only ever checks for the EXISTENCE of credentials — a
|
|
15
|
+
// file's presence, an env var being non-empty, a settings key being a non-empty
|
|
16
|
+
// string. It NEVER opens or logs `.credentials.json`, and where it must parse
|
|
17
|
+
// `settings.json` (for apiKeyHelper) it reads only whether the key is a
|
|
18
|
+
// non-empty string and never logs its value. Credential CONTENTS are never read
|
|
19
|
+
// or logged.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync } from "fs";
|
|
22
|
+
import { homedir, platform as osPlatform } from "os";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the Claude config directory the same way the bundled cc-session-io
|
|
27
|
+
* (getClaudeDir) and Claude Code itself resolve it: an explicit
|
|
28
|
+
* CLAUDE_CONFIG_DIR wins, otherwise ~/.claude.
|
|
29
|
+
*
|
|
30
|
+
* Deliberate divergence from cc-session-io's plain `env ?? default`: we treat a
|
|
31
|
+
* SET-BUT-EMPTY/whitespace CLAUDE_CONFIG_DIR as unset and fall back to ~/.claude
|
|
32
|
+
* (an empty string would otherwise resolve credential probes to the process cwd
|
|
33
|
+
* root). The returned value is trimmed so downstream joins never carry stray
|
|
34
|
+
* whitespace.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveClaudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
37
|
+
const configured = env.CLAUDE_CONFIG_DIR;
|
|
38
|
+
if (typeof configured === "string" && configured.trim().length > 0) return configured.trim();
|
|
39
|
+
return join(homedir(), ".claude");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function nonEmptyEnv(value: string | undefined): boolean {
|
|
43
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Matches how Claude Code interprets its boolean provider-routing env flags:
|
|
47
|
+
// only "1" / "true" (case-insensitive) enable them.
|
|
48
|
+
function envTruthy(value: string | undefined): boolean {
|
|
49
|
+
const v = value?.trim().toLowerCase();
|
|
50
|
+
return v === "1" || v === "true";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when `${configDir}/settings.json` exists, parses as JSON, and carries a
|
|
55
|
+
* non-empty `apiKeyHelper` string (an enterprise/custom auth command that
|
|
56
|
+
* produces a key). Parse/read errors are tolerated as "not present". The helper
|
|
57
|
+
* VALUE is never logged — only its presence/non-emptiness is used.
|
|
58
|
+
*/
|
|
59
|
+
function hasApiKeyHelper(configDir: string): boolean {
|
|
60
|
+
try {
|
|
61
|
+
const settingsPath = join(configDir, "settings.json");
|
|
62
|
+
if (!existsSync(settingsPath)) return false;
|
|
63
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { apiKeyHelper?: unknown };
|
|
64
|
+
return typeof parsed?.apiKeyHelper === "string" && parsed.apiKeyHelper.trim().length > 0;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* True when real Claude credentials are present in any location the Claude Agent
|
|
72
|
+
* SDK would authenticate from, WITHOUT reading credential contents. Checked in
|
|
73
|
+
* cheap-first order:
|
|
74
|
+
* - env: non-empty CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY /
|
|
75
|
+
* ANTHROPIC_AUTH_TOKEN, or any truthy cloud-provider routing flag the Claude
|
|
76
|
+
* Code SDK recognizes — CLAUDE_CODE_USE_BEDROCK / _VERTEX / _FOUNDRY /
|
|
77
|
+
* _ANTHROPIC_AWS / _MANTLE (such routing needs no local key file);
|
|
78
|
+
* - `.credentials.json` in the resolved config dir (existence only — the file
|
|
79
|
+
* is never opened; written mode 0600 by `claude login`);
|
|
80
|
+
* - `settings.json` apiKeyHelper (presence only — see hasApiKeyHelper).
|
|
81
|
+
*
|
|
82
|
+
* PLATFORM ASYMMETRY: on macOS the `claude` CLI stores OAuth tokens in the login
|
|
83
|
+
* Keychain, NOT in `.credentials.json`, so file-absence is NOT evidence of
|
|
84
|
+
* logged-out and we cannot cheaply/safely probe the Keychain here. On darwin,
|
|
85
|
+
* when no other signal is present, we default to credentialed=true — preserving
|
|
86
|
+
* the pre-fix "always available" behavior for Mac subscription users. Honesty
|
|
87
|
+
* enforcement therefore applies on Linux/Windows, where `.credentials.json`
|
|
88
|
+
* existence is an observable, truthful proxy (empirically, `claude auth logout`
|
|
89
|
+
* unlinks it).
|
|
90
|
+
*/
|
|
91
|
+
export function hasClaudeCredentials(
|
|
92
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
93
|
+
platform: NodeJS.Platform = osPlatform(),
|
|
94
|
+
): boolean {
|
|
95
|
+
if (nonEmptyEnv(env.CLAUDE_CODE_OAUTH_TOKEN)) return true;
|
|
96
|
+
if (nonEmptyEnv(env.ANTHROPIC_API_KEY)) return true;
|
|
97
|
+
if (nonEmptyEnv(env.ANTHROPIC_AUTH_TOKEN)) return true;
|
|
98
|
+
if (envTruthy(env.CLAUDE_CODE_USE_BEDROCK)) return true;
|
|
99
|
+
if (envTruthy(env.CLAUDE_CODE_USE_VERTEX)) return true;
|
|
100
|
+
if (envTruthy(env.CLAUDE_CODE_USE_FOUNDRY)) return true;
|
|
101
|
+
if (envTruthy(env.CLAUDE_CODE_USE_ANTHROPIC_AWS)) return true;
|
|
102
|
+
if (envTruthy(env.CLAUDE_CODE_USE_MANTLE)) return true;
|
|
103
|
+
|
|
104
|
+
const configDir = resolveClaudeConfigDir(env);
|
|
105
|
+
if (existsSync(join(configDir, ".credentials.json"))) return true;
|
|
106
|
+
if (hasApiKeyHelper(configDir)) return true;
|
|
107
|
+
|
|
108
|
+
if (platform === "darwin") return true;
|
|
109
|
+
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
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
|
+
}
|