pi-claude-agent-sdk 0.7.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/LICENSE +21 -0
- package/README.md +114 -0
- package/assets/claude-bridge1.png +0 -0
- package/assets/claude-bridge2.png +0 -0
- package/package.json +68 -0
- package/src/agents-md.ts +20 -0
- package/src/askclaude-ui.ts +90 -0
- package/src/config.ts +78 -0
- package/src/convert.ts +186 -0
- package/src/extract-tool-results.ts +47 -0
- package/src/index.ts +2036 -0
- package/src/mcp-server.ts +86 -0
- package/src/models.ts +95 -0
- package/src/prompt-stream.ts +102 -0
- package/src/query-state.ts +79 -0
- package/src/session-verify.ts +38 -0
- package/src/skills.ts +24 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,2036 @@
|
|
|
1
|
+
import { calculateCost, StringEnum, type AssistantMessage, type AssistantMessageEventStream, type Context, type ImageContent, type Model, type SimpleStreamOptions, type TextContent, type Tool, type UserMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import * as piAi from "@earendil-works/pi-ai";
|
|
3
|
+
import { getModels } from "@earendil-works/pi-ai/compat";
|
|
4
|
+
import { buildSessionContext, compact, keyHint, type CompactionEntry, type ExtensionAPI, type ExtensionContext, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { query, type EffortLevel, type SDKMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
+
import type { Base64ImageSource, ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { createSession, deleteSession, repairToolPairing } from "cc-session-io";
|
|
10
|
+
import { appendFileSync, mkdirSync, realpathSync, statSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { dirname, join } from "path";
|
|
13
|
+
import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
|
|
14
|
+
import { applyLongContext, buildModels, claudeCodeModelId, type LongContextSettings, resolveModel as _resolveModel } from "./models.js";
|
|
15
|
+
import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
|
|
16
|
+
import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
|
|
17
|
+
import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
|
|
18
|
+
import { QueryContext, ctx } from "./query-state.js";
|
|
19
|
+
import { makePromptStream, userMessage, type PromptStream } from "./prompt-stream.js";
|
|
20
|
+
import { claudeCodeSettings, loadConfig, markStartupNoticeShown, type Config } from "./config.js";
|
|
21
|
+
import { extractAgentsAppend } from "./agents-md.js";
|
|
22
|
+
import { createToolServer } from "./mcp-server.js";
|
|
23
|
+
import { buildActionSummary, type ToolCallState } from "./askclaude-ui.js";
|
|
24
|
+
|
|
25
|
+
// Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
|
|
26
|
+
const _piAi = piAi as any;
|
|
27
|
+
const newAssistantMessageEventStream: () => AssistantMessageEventStream =
|
|
28
|
+
typeof _piAi.createAssistantMessageEventStream === "function"
|
|
29
|
+
? _piAi.createAssistantMessageEventStream
|
|
30
|
+
: () => new _piAi.AssistantMessageEventStream();
|
|
31
|
+
|
|
32
|
+
// --- Debug logging ---
|
|
33
|
+
// CLAUDE_BRIDGE_DEBUG=1 enables debug logging to ~/.pi/agent/claude-bridge.log
|
|
34
|
+
|
|
35
|
+
const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
|
|
36
|
+
const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
|
|
37
|
+
const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
|
|
38
|
+
|
|
39
|
+
// CLAUDE_BRIDGE_RECORD_STREAM=<path> appends every SDK message consumeQuery sees,
|
|
40
|
+
// one JSON object per line. Used by tests/lib/record-sdk-streams.mjs to capture
|
|
41
|
+
// replay fixtures, so unit tests assert against message shapes Claude Code really
|
|
42
|
+
// emitted rather than ones we imagined.
|
|
43
|
+
const RECORD_STREAM_PATH = process.env.CLAUDE_BRIDGE_RECORD_STREAM;
|
|
44
|
+
|
|
45
|
+
// Applied to every Claude Code subprocess the bridge spawns — provider, AskClaude
|
|
46
|
+
// and the compact summary. One place, so a guard is added once rather than three
|
|
47
|
+
// times, and so a missing one is visible.
|
|
48
|
+
//
|
|
49
|
+
// - ENABLE_CLAUDEAI_MCP_SERVERS=0: keep the user's claude.ai-connected MCP servers
|
|
50
|
+
// out of a pi session, which serves its own tools.
|
|
51
|
+
// - DISABLE_AUTO_COMPACT=1: pi owns compaction; CC compacting its own copy would
|
|
52
|
+
// diverge from pi's history, which is the source of truth for every rebuild.
|
|
53
|
+
const CC_CHILD_ENV = {
|
|
54
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: "0",
|
|
55
|
+
DISABLE_AUTO_COMPACT: "1",
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
// Ensure log directories exist when debug is enabled
|
|
59
|
+
if (DEBUG) {
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
|
|
62
|
+
mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
|
|
63
|
+
} catch {
|
|
64
|
+
// If directory creation fails, debug functions will throw on first use
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Unique per module evaluation — confirms whether subagents share module state
|
|
69
|
+
const moduleInstanceId = Math.random().toString(36).slice(2, 8);
|
|
70
|
+
|
|
71
|
+
function debug(...args: unknown[]) {
|
|
72
|
+
if (!DEBUG) return;
|
|
73
|
+
const ts = new Date().toISOString();
|
|
74
|
+
const fmt = (a: unknown): string => {
|
|
75
|
+
if (typeof a === "string") return a;
|
|
76
|
+
if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
|
|
77
|
+
return JSON.stringify(a);
|
|
78
|
+
};
|
|
79
|
+
const msg = args.map(fmt).join(" ");
|
|
80
|
+
appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
|
|
84
|
+
// CLI subprocess to write its own debug log to a file we choose, and also
|
|
85
|
+
// forward its stderr into our debug stream. Drops straight into the real SDK's
|
|
86
|
+
// Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
|
|
87
|
+
// stderr). Without this, CC's internal view of the world is invisible to us
|
|
88
|
+
// and "No conversation found" / empty-error reports are unactionable.
|
|
89
|
+
let nextCliDebugSeq = 1;
|
|
90
|
+
function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string; stderr?: (data: string) => void } {
|
|
91
|
+
if (!DEBUG) return {};
|
|
92
|
+
const seq = nextCliDebugSeq++;
|
|
93
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
94
|
+
const logDir = join(dirname(DEBUG_LOG_PATH), "cc-cli-logs");
|
|
95
|
+
try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
|
|
96
|
+
const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
|
|
97
|
+
debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
|
|
98
|
+
return {
|
|
99
|
+
debug: true,
|
|
100
|
+
debugFile,
|
|
101
|
+
stderr: (data: string) => {
|
|
102
|
+
for (const line of data.split(/\r?\n/)) {
|
|
103
|
+
if (line) debug(`[cli-stderr ${tag}#${seq}] ${line}`);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Unconditional diagnostic dump — for "should never happen" paths */
|
|
110
|
+
function diagDump(label: string, data: Record<string, unknown>) {
|
|
111
|
+
const ts = new Date().toISOString();
|
|
112
|
+
const entry = { ts, moduleInstanceId, label, ...data };
|
|
113
|
+
appendFileSync(DIAG_LOG_PATH, JSON.stringify(entry) + "\n");
|
|
114
|
+
debug(`DIAG: ${label} (see ${DIAG_LOG_PATH})`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Constants ---
|
|
118
|
+
|
|
119
|
+
// Global key to prevent re-registration of the provider across module reloads.
|
|
120
|
+
//
|
|
121
|
+
// Extensions like pi-subagents spawn a subagent and it loads this module
|
|
122
|
+
// again. Without this guard, the subagent's call to registerProvider() would
|
|
123
|
+
// overwrite the parent's `streamSimple` function reference in the shared
|
|
124
|
+
// ModelRegistry. When the parent later delivers a tool result, it would call
|
|
125
|
+
// the subagent's `streamSimple` (which has empty state) instead of its own.
|
|
126
|
+
//
|
|
127
|
+
// By storing the active streamSimple in a Symbol.for() global (shared across all
|
|
128
|
+
// module instances), we ensure only the FIRST instance to register takes effect.
|
|
129
|
+
// Subsequent instances wrap the stored function instead of overwriting it.
|
|
130
|
+
//
|
|
131
|
+
// On session_shutdown (including /reload), clearSession() resets this so a fresh
|
|
132
|
+
// registration can occur for the next session.
|
|
133
|
+
const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
|
|
134
|
+
|
|
135
|
+
// Claude Code's own builtin tools, for the AskClaude path where CC really runs
|
|
136
|
+
// them. The provider path never sees these — it starts CC with `tools: []`.
|
|
137
|
+
const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
|
|
138
|
+
read: "read", write: "write", edit: "edit", bash: "bash",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
|
|
142
|
+
const MODELS = buildModels(getModels("anthropic"));
|
|
143
|
+
let providerSettings: NonNullable<Config["provider"]> = {};
|
|
144
|
+
let longContextSettings: LongContextSettings = { plan: "pro", longContextExtraUsage: false };
|
|
145
|
+
|
|
146
|
+
function resolveModel(input: string) {
|
|
147
|
+
return _resolveModel(MODELS, input);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Error handling ---
|
|
151
|
+
|
|
152
|
+
function errorMessage(err: unknown): string {
|
|
153
|
+
if (err instanceof Error) return err.message;
|
|
154
|
+
if (err && typeof err === "object") {
|
|
155
|
+
const obj = err as Record<string, unknown>;
|
|
156
|
+
if (typeof obj.message === "string") return obj.message;
|
|
157
|
+
if (typeof obj.error === "string") return obj.error;
|
|
158
|
+
try { return JSON.stringify(err); } catch {}
|
|
159
|
+
}
|
|
160
|
+
return String(err);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// AskClaude mode presets — controls which CC tools are blocked per mode.
|
|
164
|
+
// Only block tools that can't work (no pi TUI for user interaction).
|
|
165
|
+
// Other CC tools (Agent, SendMessage, RemoteTrigger, Tasks, etc.) are intentionally not blocked.
|
|
166
|
+
const ASKCLAUDE_ALWAYS_BLOCKED = [
|
|
167
|
+
"AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
|
|
168
|
+
"ToolSearch", // probes for blocked tools, wastes tokens
|
|
169
|
+
"ScheduleWakeup", // no harness to fire wakeup from inside a delegated subagent
|
|
170
|
+
];
|
|
171
|
+
const MODE_DISALLOWED_TOOLS: Record<string, string[]> = {
|
|
172
|
+
full: [
|
|
173
|
+
...ASKCLAUDE_ALWAYS_BLOCKED,
|
|
174
|
+
],
|
|
175
|
+
read: [
|
|
176
|
+
...ASKCLAUDE_ALWAYS_BLOCKED,
|
|
177
|
+
"Write", "Edit", "Bash", "NotebookEdit",
|
|
178
|
+
"EnterWorktree", "ExitWorktree", "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
|
|
179
|
+
],
|
|
180
|
+
none: [
|
|
181
|
+
...ASKCLAUDE_ALWAYS_BLOCKED,
|
|
182
|
+
"Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent",
|
|
183
|
+
"NotebookEdit", "EnterWorktree", "ExitWorktree",
|
|
184
|
+
"CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
|
|
185
|
+
"WebFetch", "WebSearch",
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// --- Session persistence ---
|
|
190
|
+
|
|
191
|
+
interface SessionState {
|
|
192
|
+
sessionId: string;
|
|
193
|
+
cursor: number;
|
|
194
|
+
cwd: string;
|
|
195
|
+
// Force the next syncSharedSession call down the REBUILD path. Set when
|
|
196
|
+
// pi has mutated its messages array out from under us (compact, tree
|
|
197
|
+
// navigation) or after an abort left the JSONL in an indeterminate state.
|
|
198
|
+
// REBUILD wipes and rewrites the file to match pi's current history.
|
|
199
|
+
needsRebuild?: boolean;
|
|
200
|
+
// Set ONLY after an abort. The killed CC subprocess may still be flushing
|
|
201
|
+
// a late "[Request interrupted by user]" record to the session JSONL.
|
|
202
|
+
// Reusing the same sessionId/path would race that orphan write into our
|
|
203
|
+
// fresh file and break CC's parent-uuid chain on the next resume. When
|
|
204
|
+
// this flag is set, REBUILD takes a fresh UUID and skips deleteSession
|
|
205
|
+
// so the orphan writes land on a dead inode. Compact/tree do NOT set
|
|
206
|
+
// this — there's no concurrent CC writer during those events, so
|
|
207
|
+
// in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
|
|
208
|
+
forceRotate?: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let sharedSession: SessionState | null = null;
|
|
212
|
+
|
|
213
|
+
// Convert pi messages to Anthropic API format for session import.
|
|
214
|
+
// Lossy: non-Anthropic thinking blocks are dropped (no valid signature), and only
|
|
215
|
+
// text/image/toolCall block types are handled. If all blocks in an assistant message
|
|
216
|
+
// are filtered, the message is dropped — which can create invalid sequences (e.g.
|
|
217
|
+
// two user messages in a row, or tool_result without preceding tool_use).
|
|
218
|
+
function convertAndImportMessages(
|
|
219
|
+
session: ReturnType<typeof createSession>,
|
|
220
|
+
messages: Context["messages"],
|
|
221
|
+
customToolNameToSdk?: Map<string, string>,
|
|
222
|
+
): void {
|
|
223
|
+
const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
|
|
224
|
+
|
|
225
|
+
debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
|
|
226
|
+
debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
|
|
227
|
+
const c = m.content;
|
|
228
|
+
if (typeof c === "string") return `[${i}]${m.role}:text`;
|
|
229
|
+
if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
|
|
230
|
+
return `[${i}]${m.role}:?`;
|
|
231
|
+
}).join(" "));
|
|
232
|
+
if (sanitizedIds.size > 0) {
|
|
233
|
+
debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
|
|
234
|
+
[...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
|
|
235
|
+
}
|
|
236
|
+
// Pre-repair for debug logging; importMessages also repairs internally (idempotent).
|
|
237
|
+
const repaired = repairToolPairing(anthropicMessages);
|
|
238
|
+
if (repaired.length !== anthropicMessages.length) {
|
|
239
|
+
debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
|
|
240
|
+
}
|
|
241
|
+
if (repaired.length) session.importMessages(repaired);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Pi doesn't pass tool results directly — it appends them to the context and calls
|
|
245
|
+
// the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
|
|
246
|
+
// debug logging at the extraction boundary.
|
|
247
|
+
function extractAllToolResults(context: Context): McpResult[] {
|
|
248
|
+
const { results, stopIdx } = _extractAllToolResults(context.messages as unknown as Array<{ role: string; [key: string]: unknown }>);
|
|
249
|
+
debug(`extractAllToolResults: ${results.length} results from ${context.messages.length} msgs, stopped at index ${stopIdx}`);
|
|
250
|
+
debug(`extractAllToolResults: all msg roles:`, context.messages.map((m, i) => `[${i}]${m.role}`).join(" "));
|
|
251
|
+
for (let r = 0; r < results.length; r++) {
|
|
252
|
+
debug(`extractAllToolResults: result[${r}] id=${results[r].toolCallId}${results[r].isError ? " ERROR" : ""} preview:`, JSON.stringify(results[r].content).slice(0, 150));
|
|
253
|
+
}
|
|
254
|
+
return results;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Index of the first message of the current user turn — the trailing run of
|
|
258
|
+
* user messages that has not been written into the Claude Code session yet.
|
|
259
|
+
* Equals messages.length when the last message is not a user message.
|
|
260
|
+
*
|
|
261
|
+
* Single source of truth for the history/prompt split: everything before this
|
|
262
|
+
* index is replayed as session history, everything from it onward becomes the
|
|
263
|
+
* prompt. Deriving both halves from one index is what keeps a message from
|
|
264
|
+
* landing in both — an extension appending a display-only user message after
|
|
265
|
+
* the real one (see issue #34) makes the turn longer than one message. */
|
|
266
|
+
function turnStart(messages: Context["messages"]): number {
|
|
267
|
+
let i = messages.length;
|
|
268
|
+
while (i > 0 && messages[i - 1].role === "user") i--;
|
|
269
|
+
return i;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Extract the current user turn as a prompt string. Returns null if the last message is not a user message. */
|
|
273
|
+
function extractUserPrompt(messages: Context["messages"]): string | null {
|
|
274
|
+
const turn = messages.slice(turnStart(messages)) as UserMessage[];
|
|
275
|
+
if (turn.length === 0) return null;
|
|
276
|
+
// Drop empties before joining so an all-empty turn still yields "" and trips
|
|
277
|
+
// the caller's empty-prompt guard rather than sending bare newlines.
|
|
278
|
+
return turn
|
|
279
|
+
.map((m) => (typeof m.content === "string" ? m.content : messageContentToText(m.content)))
|
|
280
|
+
.filter((text) => text)
|
|
281
|
+
.join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Extract the current user turn as ContentBlockParam[] (preserving images).
|
|
285
|
+
* Returns null if no images — caller should fall back to string prompt. */
|
|
286
|
+
function extractUserPromptBlocks(messages: Context["messages"]): ContentBlockParam[] | null {
|
|
287
|
+
const turn = messages.slice(turnStart(messages)) as UserMessage[];
|
|
288
|
+
if (turn.length === 0) return null;
|
|
289
|
+
|
|
290
|
+
let hasImage = false;
|
|
291
|
+
const blocks: ContentBlockParam[] = [];
|
|
292
|
+
for (const message of turn) {
|
|
293
|
+
const content: (TextContent | ImageContent)[] = typeof message.content === "string"
|
|
294
|
+
? [{ type: "text", text: message.content }]
|
|
295
|
+
: message.content;
|
|
296
|
+
// Off-type content violates UserMessage's contract, so fail rather than
|
|
297
|
+
// degrade — but name the shape, since the cause is almost always another
|
|
298
|
+
// extension appending a malformed message, not this file.
|
|
299
|
+
if (!Array.isArray(content)) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`extractUserPromptBlocks: user message content must be a string or block array, got ${typeof content} — likely a malformed message from another extension`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
for (const block of content) {
|
|
305
|
+
if (block.type === "text" && block.text) {
|
|
306
|
+
blocks.push({ type: "text", text: block.text });
|
|
307
|
+
} else if (block.type === "image") {
|
|
308
|
+
// Guard before logging: data-less image blocks do occur, and reading
|
|
309
|
+
// .length off the missing field in the debug template would throw
|
|
310
|
+
// before this check ever runs (template args evaluate unconditionally).
|
|
311
|
+
if (!block.data || !block.mimeType) {
|
|
312
|
+
debug(`image block missing data or mimeType, skipping: keys=${Object.keys(block).join(",")}`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
debug(`image block: mimeType=${block.mimeType}, data length=${block.data.length}`);
|
|
316
|
+
hasImage = true;
|
|
317
|
+
blocks.push({
|
|
318
|
+
type: "image",
|
|
319
|
+
source: {
|
|
320
|
+
type: "base64",
|
|
321
|
+
media_type: block.mimeType as Base64ImageSource["media_type"],
|
|
322
|
+
data: block.data,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
debug(`extractUserPromptBlocks: ${turn.length} msgs in turn, ${blocks.length} blocks, types=${blocks.map((b) => b.type).join(",")}`);
|
|
329
|
+
return hasImage ? blocks : null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function newAssistantOutput(model: Model<any>, text: string, stopReason: AssistantMessage["stopReason"], errorMessage?: string): AssistantMessage {
|
|
333
|
+
return {
|
|
334
|
+
role: "assistant",
|
|
335
|
+
content: text ? [{ type: "text", text }] : [],
|
|
336
|
+
api: model.api,
|
|
337
|
+
provider: model.provider,
|
|
338
|
+
model: model.id,
|
|
339
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
|
|
340
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
341
|
+
stopReason,
|
|
342
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
343
|
+
timestamp: Date.now(),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function extractIsolatedSummaryPrompt(messages: Context["messages"]): string {
|
|
348
|
+
if (messages.length !== 1 || messages[0].role !== "user") {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`isolatedStreamFn: expected exactly 1 user message, got ${messages.length} ` +
|
|
351
|
+
`(${messages.map((m) => m.role).join(",")})`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
const promptText = extractUserPrompt(messages);
|
|
355
|
+
if (!promptText) throw new Error("isolatedStreamFn: summarization prompt is empty");
|
|
356
|
+
return promptText;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Failure text for an SDK result, or undefined when it succeeded. CC reports API failures
|
|
360
|
+
* (429 capacity, overload, prompt-too-long) with `is_error` on an otherwise success-shaped
|
|
361
|
+
* result; the dedicated error subtypes carry `errors` instead. */
|
|
362
|
+
function resultErrorText(message: SDKMessage): string | undefined {
|
|
363
|
+
const result = message as SDKMessage & { subtype?: string; is_error?: boolean; result?: string; errors?: unknown; error?: unknown };
|
|
364
|
+
if (result.subtype === "success") return result.is_error ? result.result || "Claude Code reported an error" : undefined;
|
|
365
|
+
if (Array.isArray(result.errors) && result.errors.length) return result.errors.map(String).join("\n");
|
|
366
|
+
if (typeof result.error === "string") return result.error;
|
|
367
|
+
return `Claude Code failed: ${result.subtype ?? "unknown result"}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function isolatedStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
|
|
371
|
+
const stream = newAssistantMessageEventStream();
|
|
372
|
+
void runIsolatedSummary(model, context, options, stream);
|
|
373
|
+
return stream;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function runIsolatedSummary(
|
|
377
|
+
model: Model<any>,
|
|
378
|
+
context: Context,
|
|
379
|
+
options: SimpleStreamOptions | undefined,
|
|
380
|
+
stream: AssistantMessageEventStream,
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
let sdkQuery: ReturnType<typeof query> | undefined;
|
|
383
|
+
let wasAborted = false;
|
|
384
|
+
const onAbort = () => {
|
|
385
|
+
wasAborted = true;
|
|
386
|
+
void sdkQuery?.interrupt().catch(() => {});
|
|
387
|
+
try { sdkQuery?.close(); } catch {}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const promptText = extractIsolatedSummaryPrompt(context.messages);
|
|
392
|
+
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
393
|
+
const compactProviderSettings = loadConfig(cwd).provider;
|
|
394
|
+
const claudeExecutable = compactProviderSettings?.pathToClaudeCodeExecutable;
|
|
395
|
+
const cliModel = claudeCodeModelId(model, longContextSettings);
|
|
396
|
+
debug(`compact summary: spawn model=${cliModel} registeredModel=${model.id} promptLen=${promptText.length}`);
|
|
397
|
+
|
|
398
|
+
sdkQuery = query({
|
|
399
|
+
prompt: promptText,
|
|
400
|
+
options: {
|
|
401
|
+
cwd,
|
|
402
|
+
env: { ...process.env, ...CC_CHILD_ENV },
|
|
403
|
+
settings: { autoMemoryEnabled: false },
|
|
404
|
+
tools: [],
|
|
405
|
+
strictMcpConfig: true,
|
|
406
|
+
settingSources: [] as SettingSource[],
|
|
407
|
+
skills: [],
|
|
408
|
+
persistSession: false,
|
|
409
|
+
systemPrompt: context.systemPrompt,
|
|
410
|
+
model: cliModel,
|
|
411
|
+
maxTurns: 1,
|
|
412
|
+
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
413
|
+
...makeCliDebugOptions("compact-summary"),
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (options?.signal) {
|
|
418
|
+
if (options.signal.aborted) onAbort();
|
|
419
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let assistantText = "";
|
|
423
|
+
let finalText = "";
|
|
424
|
+
let errorText: string | undefined;
|
|
425
|
+
let firstEventLogged = false;
|
|
426
|
+
|
|
427
|
+
for await (const message of sdkQuery) {
|
|
428
|
+
if (!firstEventLogged) {
|
|
429
|
+
debug(`compact summary: first event type=${message.type}`);
|
|
430
|
+
firstEventLogged = true;
|
|
431
|
+
}
|
|
432
|
+
if (wasAborted) break;
|
|
433
|
+
|
|
434
|
+
if (message.type === "assistant") {
|
|
435
|
+
for (const block of (message as any).message?.content ?? []) {
|
|
436
|
+
if (block.type === "text" && typeof block.text === "string") assistantText += block.text;
|
|
437
|
+
}
|
|
438
|
+
} else if (message.type === "result") {
|
|
439
|
+
logServedContextWindow("compact summary", message, model);
|
|
440
|
+
errorText = resultErrorText(message);
|
|
441
|
+
if (!errorText && message.subtype === "success") finalText = message.result || assistantText;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (wasAborted) {
|
|
446
|
+
const output = newAssistantOutput(model, "", "aborted", "Operation aborted");
|
|
447
|
+
debug("compact summary: aborted");
|
|
448
|
+
stream.push({ type: "error", reason: "aborted", error: output });
|
|
449
|
+
stream.end();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const text = finalText || assistantText;
|
|
454
|
+
if (errorText || !text.trim()) {
|
|
455
|
+
const msg = errorText ?? "Claude Code summary returned empty text";
|
|
456
|
+
debug(`compact summary: error ${msg}`);
|
|
457
|
+
stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", msg) });
|
|
458
|
+
stream.end();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
debug(`compact summary: done textLen=${text.length}`);
|
|
463
|
+
stream.push({ type: "done", reason: "stop", message: newAssistantOutput(model, text, "stop") });
|
|
464
|
+
stream.end();
|
|
465
|
+
} catch (err) {
|
|
466
|
+
const msg = errorMessage(err);
|
|
467
|
+
debug("runIsolatedSummary threw; pushing terminal error", err);
|
|
468
|
+
stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", msg) });
|
|
469
|
+
stream.end();
|
|
470
|
+
} finally {
|
|
471
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
472
|
+
try { sdkQuery?.close(); } catch {}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function reinjectPriorCompactionFileOps(branchEntries: Array<{ type: string; details?: unknown }>, preparation: { fileOps: { read: Set<string>; edited: Set<string> } }): void {
|
|
477
|
+
const prior = [...branchEntries]
|
|
478
|
+
.reverse()
|
|
479
|
+
.find((entry): entry is CompactionEntry => entry.type === "compaction");
|
|
480
|
+
const details = prior?.details as { readFiles?: unknown; modifiedFiles?: unknown } | undefined;
|
|
481
|
+
if (!Array.isArray(details?.readFiles) || !Array.isArray(details?.modifiedFiles)) return;
|
|
482
|
+
for (const file of details.readFiles) preparation.fileOps.read.add(String(file));
|
|
483
|
+
for (const file of details.modifiedFiles) preparation.fileOps.edited.add(String(file));
|
|
484
|
+
debug(`compact takeover: re-injected prior file ops read=${details.readFiles.length} modified=${details.modifiedFiles.length}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
interface SyncResult {
|
|
488
|
+
sessionId: string | null;
|
|
489
|
+
preserveSharedSession?: boolean;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Ensure the shared session has all messages up to (but not including) the last user message.
|
|
494
|
+
* Returns session ID to resume from, or null if no resume needed.
|
|
495
|
+
*/
|
|
496
|
+
// Read the session file we just wrote and sanity-check it. Warns instead of
|
|
497
|
+
// throwing — CC may be more tolerant than our checks, so a false positive
|
|
498
|
+
// shouldn't block the user. Pure logic is in session-verify.js; this wrapper
|
|
499
|
+
// fans each warning out to debug log + piUI notify + diagDump.
|
|
500
|
+
function verifyWrittenSession(
|
|
501
|
+
jsonlPath: string,
|
|
502
|
+
expectedSessionId: string,
|
|
503
|
+
expectedRecordCount: number,
|
|
504
|
+
cwd: string,
|
|
505
|
+
): void {
|
|
506
|
+
const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
|
|
507
|
+
for (const msg of warnings) {
|
|
508
|
+
debug(`WARNING session verify: ${msg}`);
|
|
509
|
+
piUI?.notify(
|
|
510
|
+
`Session file issue: ${msg}\n` +
|
|
511
|
+
`cwd=${cwd} realpath=${safeRealpath(cwd)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
|
|
512
|
+
`Please copy and paste this message into a new issue at https://github.com/pi-pod/pi-claude-agent-sdk/issues/new` +
|
|
513
|
+
(DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
|
|
514
|
+
"warning",
|
|
515
|
+
);
|
|
516
|
+
diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function safeRealpath(p: string): string {
|
|
521
|
+
try { return realpathSync(p); } catch (e) { return `<failed: ${(e as Error).message}>`; }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Diagnostic snapshot of where a session file was just written. Catches the
|
|
525
|
+
// class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
|
|
526
|
+
// from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
|
|
527
|
+
function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
|
|
528
|
+
const realCwd = safeRealpath(cwd);
|
|
529
|
+
let fileSize: number | null = null;
|
|
530
|
+
let fileExists = false;
|
|
531
|
+
try {
|
|
532
|
+
const st = statSync(jsonlPath);
|
|
533
|
+
fileExists = true;
|
|
534
|
+
fileSize = st.size;
|
|
535
|
+
} catch { /* file may not exist yet */ }
|
|
536
|
+
debug(`${label}: cwd=${cwd}`);
|
|
537
|
+
if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
|
|
538
|
+
debug(`${label}: jsonlPath=${jsonlPath}`);
|
|
539
|
+
debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
|
|
540
|
+
debug(`${label}: env.CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Two semantic paths:
|
|
544
|
+
// REUSE — pi's history is in sync with the existing sharedSession (or drifted
|
|
545
|
+
// only by the trailing final-assistant message that pi appends after
|
|
546
|
+
// streamSimple returns, which CC's own persisted session already has).
|
|
547
|
+
// Returns the existing sessionId. Keeps CC's prompt cache warm.
|
|
548
|
+
// REBUILD — no session yet, or pi's history has diverged (non-trailing
|
|
549
|
+
// missed messages, e.g. another provider took a turn). Wipes the existing
|
|
550
|
+
// session file (if any) and writes a fresh one containing all prior
|
|
551
|
+
// messages, reusing the same sessionId across rebuilds so UUIDs stay
|
|
552
|
+
// stable for the lifetime of pi's session.
|
|
553
|
+
//
|
|
554
|
+
// Why a full rebuild rather than patching:
|
|
555
|
+
// Injecting deltas into an existing session creates a branch that CC's
|
|
556
|
+
// --resume doesn't follow (documented attempt prior to this). A complete
|
|
557
|
+
// overwrite at the same path is simpler and correct.
|
|
558
|
+
//
|
|
559
|
+
// Why reuse the sessionId across rebuilds:
|
|
560
|
+
// CC re-reads the JSONL on every --resume call — no in-process UUID
|
|
561
|
+
// caching. Validated in tests/exp-session-clear.mjs, including the case
|
|
562
|
+
// where CC had appended its own tool_use/tool_result records between
|
|
563
|
+
// rebuilds. Preserving the UUID means stable log correlation across
|
|
564
|
+
// provider switches and no orphaned session files.
|
|
565
|
+
//
|
|
566
|
+
// Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
|
|
567
|
+
// int-session-resume.mjs) keep grepping the same anchors.
|
|
568
|
+
function syncSharedSession(
|
|
569
|
+
messages: Context["messages"],
|
|
570
|
+
cwd: string,
|
|
571
|
+
customToolNameToSdk?: Map<string, string>,
|
|
572
|
+
modelId?: string,
|
|
573
|
+
): SyncResult {
|
|
574
|
+
const priorMessages = messages.slice(0, turnStart(messages)); // everything before the current user turn
|
|
575
|
+
|
|
576
|
+
// REUSE path
|
|
577
|
+
//
|
|
578
|
+
// Guard on priorMessages.length >= cursor: a shorter incoming context cannot
|
|
579
|
+
// be a continuation of the cached session. This is the general invariant for
|
|
580
|
+
// pi-side history rewrites such as /compact and session_tree: without it,
|
|
581
|
+
// missed = [].slice(cursor) can falsely hit REUSE and resume an unrelated
|
|
582
|
+
// longer CC session. See issue #25.
|
|
583
|
+
if (sharedSession && !sharedSession.needsRebuild && priorMessages.length >= sharedSession.cursor) {
|
|
584
|
+
const missed = priorMessages.slice(sharedSession.cursor);
|
|
585
|
+
const trailingAssistantOnly =
|
|
586
|
+
missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
|
|
587
|
+
if (missed.length === 0 || trailingAssistantOnly) {
|
|
588
|
+
if (trailingAssistantOnly) {
|
|
589
|
+
sharedSession = { ...sharedSession, cursor: priorMessages.length, cwd };
|
|
590
|
+
}
|
|
591
|
+
debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
|
|
592
|
+
debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
|
|
593
|
+
return { sessionId: sharedSession.sessionId };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// This is what keeps a reentrant subagent from taking over the parent's
|
|
597
|
+
// session: a subagent starts with priors of its own, shorter than the parent's
|
|
598
|
+
// cursor, so it lands here, gets a fresh session, and the ephemeral session it
|
|
599
|
+
// captures is deleted once its query completes (see preserveSharedSession in
|
|
600
|
+
// the completion handler). Remove this branch and a subagent resumes — then
|
|
601
|
+
// overwrites — the parent's session. The non-isolated AskClaude path reaches it
|
|
602
|
+
// the same way.
|
|
603
|
+
//
|
|
604
|
+
// It is NOT, despite an earlier comment here, the isolated compact-summary
|
|
605
|
+
// path: runIsolatedSummary never calls syncSharedSession at all.
|
|
606
|
+
//
|
|
607
|
+
// Only reachable when needsRebuild is false — user-facing history rewrites
|
|
608
|
+
// (/compact, session_tree, /new, fork) always set needsRebuild or clear
|
|
609
|
+
// sharedSession before the next syncSharedSession call.
|
|
610
|
+
if (sharedSession && !sharedSession.needsRebuild && priorMessages.length < sharedSession.cursor) {
|
|
611
|
+
debug(`Case 1 synthetic: clean start for shorter context, preserving shared session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
|
|
612
|
+
debug(`syncResult: path=clean-start preserve-shared sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
|
|
613
|
+
return { sessionId: null, preserveSharedSession: true };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// REBUILD path
|
|
617
|
+
if (priorMessages.length === 0) {
|
|
618
|
+
debug(`Case 1: clean start, ${messages.length} total messages`);
|
|
619
|
+
debug(`syncResult: path=clean-start`);
|
|
620
|
+
return { sessionId: null };
|
|
621
|
+
}
|
|
622
|
+
const previousSessionId = sharedSession?.sessionId;
|
|
623
|
+
const previousCursor = sharedSession?.cursor ?? 0;
|
|
624
|
+
// preserveId: rebuild in place (deleteSession + createSession with the
|
|
625
|
+
// existing UUID), so prompt-cache UUIDs stay stable for log correlation
|
|
626
|
+
// and for any tools that key off them. Skipped only when there's a
|
|
627
|
+
// concurrent writer we shouldn't race — see forceRotate docs above.
|
|
628
|
+
const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
|
|
629
|
+
if (preserveId) {
|
|
630
|
+
// Wipe prior jsonl + companion dir (no-op if nothing to wipe).
|
|
631
|
+
deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
|
|
632
|
+
}
|
|
633
|
+
const session = createSession({
|
|
634
|
+
projectPath: cwd,
|
|
635
|
+
claudeDir: process.env.CLAUDE_CONFIG_DIR,
|
|
636
|
+
...(preserveId ? { sessionId: previousSessionId } : {}),
|
|
637
|
+
...(modelId ? { model: modelId } : {}),
|
|
638
|
+
});
|
|
639
|
+
convertAndImportMessages(session, priorMessages, customToolNameToSdk);
|
|
640
|
+
session.save();
|
|
641
|
+
verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
|
|
642
|
+
sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
|
|
643
|
+
if (previousSessionId === undefined) {
|
|
644
|
+
debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
|
|
645
|
+
} else if (preserveId) {
|
|
646
|
+
const missedCount = priorMessages.length - previousCursor;
|
|
647
|
+
debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
|
|
648
|
+
} else {
|
|
649
|
+
debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.messages.length} records`);
|
|
650
|
+
}
|
|
651
|
+
debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
|
|
652
|
+
debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
|
|
653
|
+
return { sessionId: session.sessionId };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// @internal
|
|
657
|
+
export const __test = {
|
|
658
|
+
resetSharedSession() {
|
|
659
|
+
sharedSession = null;
|
|
660
|
+
},
|
|
661
|
+
setSharedSession(state: SessionState | null) {
|
|
662
|
+
sharedSession = state;
|
|
663
|
+
},
|
|
664
|
+
getSharedSession() {
|
|
665
|
+
return sharedSession;
|
|
666
|
+
},
|
|
667
|
+
syncSharedSession,
|
|
668
|
+
extractUserPromptBlocks,
|
|
669
|
+
consumeQuery,
|
|
670
|
+
finalizeCurrentStream,
|
|
671
|
+
resultErrorText,
|
|
672
|
+
deliverToolResults,
|
|
673
|
+
drainForAbort,
|
|
674
|
+
CC_CHILD_ENV,
|
|
675
|
+
buildMcpServers,
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
// --- Provider helpers: tool name mapping ---
|
|
679
|
+
|
|
680
|
+
// AskClaude path: CC runs its own tools, so builtin names are real.
|
|
681
|
+
function mapToolName(name: string): string {
|
|
682
|
+
const normalized = name.toLowerCase();
|
|
683
|
+
const builtin = SDK_TO_PI_TOOL_NAME[normalized];
|
|
684
|
+
if (builtin) return builtin;
|
|
685
|
+
if (normalized.startsWith(MCP_TOOL_PREFIX)) return name.slice(MCP_TOOL_PREFIX.length);
|
|
686
|
+
return name;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Provider path: the query runs with `tools: []`, so the only tools CC can
|
|
690
|
+
// legitimately call are the pi tools we serve over MCP. Any other name is the
|
|
691
|
+
// model hallucinating a builtin (`bash`, `Bash`, `Edit`, an MCP server we don't
|
|
692
|
+
// serve). CC answers those itself with "No such tool available" and retries
|
|
693
|
+
// inside the same query, never dispatching them to our MCP server — so a tool
|
|
694
|
+
// call under such a name must not reach pi. Forwarding one ran a tool CC never
|
|
695
|
+
// dispatched (real side effects) and, because the retry carries a fresh
|
|
696
|
+
// tool_use id, left the handler for the retry with no result to release it:
|
|
697
|
+
// pi's result arrived keyed to the dead id, and both sides deadlocked.
|
|
698
|
+
function piToolNameFor(name: string, customToolNameToPi: Map<string, string>): string | undefined {
|
|
699
|
+
return customToolNameToPi.get(name) ?? customToolNameToPi.get(name.toLowerCase());
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Renames for Claude Code SDK param names that differ from pi's native names.
|
|
703
|
+
// Keys not listed here pass through unchanged, so new pi params work automatically.
|
|
704
|
+
const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
|
|
705
|
+
read: { file_path: "path" },
|
|
706
|
+
write: { file_path: "path" },
|
|
707
|
+
edit: { file_path: "path", old_string: "oldText", new_string: "newText", old_text: "oldText", new_text: "newText" },
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// Maps SDK tool args to pi tool args via key renaming + pass-through.
|
|
711
|
+
// Pi's own prepareArguments hooks handle any structural transforms (e.g. edit oldText/newText → edits[]).
|
|
712
|
+
function mapToolArgs(
|
|
713
|
+
toolName: string, args: Record<string, unknown> | undefined,
|
|
714
|
+
): Record<string, unknown> {
|
|
715
|
+
const input = args ?? {};
|
|
716
|
+
const renames = SDK_KEY_RENAMES[toolName.toLowerCase()];
|
|
717
|
+
const result: Record<string, unknown> = {};
|
|
718
|
+
for (const [key, value] of Object.entries(input)) {
|
|
719
|
+
const piKey = renames?.[key] ?? key;
|
|
720
|
+
if (!(piKey in result)) result[piKey] = value; // first alias wins
|
|
721
|
+
}
|
|
722
|
+
// Pi bash has no default timeout; add a safety default
|
|
723
|
+
if (toolName.toLowerCase() === "bash" && result.timeout == null) {
|
|
724
|
+
result.timeout = 120;
|
|
725
|
+
}
|
|
726
|
+
return result;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// --- Query state ---
|
|
730
|
+
// QueryContext lives in query-state.js so tests can import it without
|
|
731
|
+
// activating the extension.
|
|
732
|
+
|
|
733
|
+
// Global (not query state):
|
|
734
|
+
let piUI: ExtensionUIContext | null = null;
|
|
735
|
+
let piMode: ExtensionContext["mode"] | null = null;
|
|
736
|
+
const activeQueryContexts = new Set<QueryContext>();
|
|
737
|
+
|
|
738
|
+
// `plan` is the one setting whose default silently costs the user something (no
|
|
739
|
+
// Opus 1M on Max), so announce it once. Deferred to the first bridge query
|
|
740
|
+
// rather than session_start: the notice persists a flag to the global config,
|
|
741
|
+
// and firing it on startup would write that file for every pi session that
|
|
742
|
+
// merely has this extension installed.
|
|
743
|
+
let planNoticePending = false;
|
|
744
|
+
|
|
745
|
+
function showPlanNoticeOnce(): void {
|
|
746
|
+
// `hasUI` is true in RPC mode too — it means dialogs are possible, not that a
|
|
747
|
+
// human is watching. Only a terminal user can act on this.
|
|
748
|
+
if (!planNoticePending || piMode !== "tui") return;
|
|
749
|
+
planNoticePending = false;
|
|
750
|
+
const path = markStartupNoticeShown();
|
|
751
|
+
piUI?.notify(
|
|
752
|
+
`Claude bridge: assuming a Pro plan. On Max (or Team Premium/Enterprise), set provider.plan to "max" in ${path} to unlock Opus at 1M context.`,
|
|
753
|
+
"info",
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// The user's own system prompt customisation (`--system-prompt`,
|
|
758
|
+
// `--append-system-prompt`), captured from before_agent_start. pi's assembled
|
|
759
|
+
// `context.systemPrompt` can't be forwarded wholesale — it describes pi's tools
|
|
760
|
+
// and harness and would fight Claude Code's own preset — but the user's text is
|
|
761
|
+
// theirs and has to reach the model, so it is kept separately.
|
|
762
|
+
let userSystemPrompt: { custom?: string; append?: string } = {};
|
|
763
|
+
|
|
764
|
+
function contextForToolResults(results: McpResult[]): QueryContext | undefined {
|
|
765
|
+
for (const result of results) {
|
|
766
|
+
const id = result.toolCallId;
|
|
767
|
+
if (!id) continue;
|
|
768
|
+
for (const queryCtx of activeQueryContexts) {
|
|
769
|
+
if (queryCtx.pendingToolCalls.has(id) || queryCtx.pendingResults.has(id) || queryCtx.turnToolCallIds.includes(id)) {
|
|
770
|
+
return queryCtx;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
778
|
+
mcpTools: Tool[];
|
|
779
|
+
customToolNameToSdk: Map<string, string>;
|
|
780
|
+
customToolNameToPi: Map<string, string>;
|
|
781
|
+
} {
|
|
782
|
+
const mcpTools: Tool[] = [];
|
|
783
|
+
const customToolNameToSdk = new Map<string, string>();
|
|
784
|
+
const customToolNameToPi = new Map<string, string>();
|
|
785
|
+
|
|
786
|
+
if (!context.tools) return { mcpTools, customToolNameToSdk, customToolNameToPi };
|
|
787
|
+
|
|
788
|
+
for (const tool of context.tools) {
|
|
789
|
+
if (tool.name === excludeToolName) continue;
|
|
790
|
+
const sdkName = `${MCP_TOOL_PREFIX}${tool.name}`;
|
|
791
|
+
mcpTools.push(tool);
|
|
792
|
+
customToolNameToSdk.set(tool.name, sdkName);
|
|
793
|
+
customToolNameToSdk.set(tool.name.toLowerCase(), sdkName);
|
|
794
|
+
customToolNameToPi.set(sdkName, tool.name);
|
|
795
|
+
customToolNameToPi.set(sdkName.toLowerCase(), tool.name);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return { mcpTools, customToolNameToSdk, customToolNameToPi };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Creates an MCP server that bridges pi tools to the SDK. Each tool handler
|
|
802
|
+
// blocks on a Promise until pi delivers the tool result via streamSimple.
|
|
803
|
+
// Handlers receive their toolCallId from Claude's tools/call _meta, so results
|
|
804
|
+
// are matched by ID end to end.
|
|
805
|
+
//
|
|
806
|
+
// The handler and pi's result can arrive in either order, hence the two maps:
|
|
807
|
+
// a result that lands first waits in `pendingResults` for the handler to claim
|
|
808
|
+
// it, and a handler that runs first parks its resolver in `pendingToolCalls`.
|
|
809
|
+
// Handlers close over the captured `queryCtx`, ensuring they operate on the
|
|
810
|
+
// correct query's state while multiple queries run concurrently.
|
|
811
|
+
function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createToolServer>> | undefined {
|
|
812
|
+
if (!tools.length) return undefined;
|
|
813
|
+
const mcpTools = tools.map((tool) => ({
|
|
814
|
+
name: tool.name,
|
|
815
|
+
description: tool.description,
|
|
816
|
+
inputSchema: tool.parameters,
|
|
817
|
+
handler: async (toolCallId: string) => {
|
|
818
|
+
if (queryCtx.pendingResults.has(toolCallId)) {
|
|
819
|
+
const result = queryCtx.pendingResults.get(toolCallId)!;
|
|
820
|
+
queryCtx.pendingResults.delete(toolCallId);
|
|
821
|
+
debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
|
|
822
|
+
return result;
|
|
823
|
+
}
|
|
824
|
+
debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
|
|
825
|
+
return new Promise<McpResult>((resolve) => {
|
|
826
|
+
queryCtx.pendingToolCalls.set(toolCallId, { toolName: tool.name, resolve });
|
|
827
|
+
});
|
|
828
|
+
},
|
|
829
|
+
}));
|
|
830
|
+
return { [MCP_SERVER_NAME]: createToolServer(MCP_SERVER_NAME, mcpTools) };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// --- Usage helpers ---
|
|
834
|
+
|
|
835
|
+
function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
|
|
836
|
+
if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
|
|
837
|
+
if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
|
|
838
|
+
if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
|
|
839
|
+
if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
|
|
840
|
+
// Claude Code may report reasoning/thinking tokens separately, while pi's Usage type does not model that field.
|
|
841
|
+
const reasoning = usage.reasoning_tokens ?? usage.thinking_tokens;
|
|
842
|
+
if (reasoning != null) (output.usage as typeof output.usage & { reasoning?: number }).reasoning = reasoning;
|
|
843
|
+
output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
844
|
+
calculateCost(model, output.usage);
|
|
845
|
+
const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
|
|
846
|
+
const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
|
|
847
|
+
const reasoningText = reasoning != null ? ` reasoning=${reasoning}` : "";
|
|
848
|
+
debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens}${reasoningText} cachePct=${cachePct}% model=${model.id}`);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Log the *served* context window reported by an SDK result message
|
|
852
|
+
// (modelUsage[id].contextWindow), which can differ from the window pi
|
|
853
|
+
// registered (model.contextWindow) when the runtime entitlement doesn't
|
|
854
|
+
// match the docs — e.g. bare Opus served 200K on Pro, or [1m] not honored.
|
|
855
|
+
// The result message's modelUsage is otherwise discarded; this makes the
|
|
856
|
+
// gap observable. See issue #18.
|
|
857
|
+
function logServedContextWindow(label: string, message: SDKMessage, model: Model<any>): void {
|
|
858
|
+
const modelUsage = (message as any).modelUsage as Record<string, { contextWindow?: number; maxOutputTokens?: number }> | undefined;
|
|
859
|
+
if (!modelUsage) return;
|
|
860
|
+
for (const [k, v] of Object.entries(modelUsage)) {
|
|
861
|
+
debug(`${label}: served contextWindow=${v.contextWindow ?? "?"} maxOutputTokens=${v.maxOutputTokens ?? "?"} servedModel=${k} registered=${model.contextWindow}`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// --- Effort level mapping ---
|
|
866
|
+
// Pi reasoning levels → CC SDK effort levels
|
|
867
|
+
|
|
868
|
+
const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
|
|
869
|
+
minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
// --- Provider helpers: misc ---
|
|
873
|
+
|
|
874
|
+
function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
|
|
875
|
+
switch (reason) {
|
|
876
|
+
case "tool_use": return "toolUse";
|
|
877
|
+
case "max_tokens": return "length";
|
|
878
|
+
case "end_turn": default: return "stop";
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
|
|
883
|
+
if (!input) return fallback;
|
|
884
|
+
try { return JSON.parse(input); } catch { return fallback; }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
// --- Provider: streaming function ---
|
|
889
|
+
//
|
|
890
|
+
// Push-based streaming with MCP tool bridge:
|
|
891
|
+
// 1. streamSimple starts a query() and kicks off consumeQuery() in background
|
|
892
|
+
// 2. consumeQuery() iterates the SDK generator, pushing events to currentPiStream
|
|
893
|
+
// 3. On tool_use: ends the current pi stream, nulls it out. The MCP handler
|
|
894
|
+
// blocks the generator naturally — no events arrive until resolved.
|
|
895
|
+
// 4. Pi executes the tool, calls streamSimple again. We swap in the new stream,
|
|
896
|
+
// resolve the MCP handler, and the generator unblocks — events flow to new stream.
|
|
897
|
+
//
|
|
898
|
+
// Note: resetTurnState clears turnSawStreamEvent while the generator may still
|
|
899
|
+
// have queued messages from the previous turn. This is safe because step 3 nulls
|
|
900
|
+
// currentPiStream, so any leftover messages hit the `!ctx().currentPiStream` guard
|
|
901
|
+
// in consumeQuery and are skipped before resetTurnState runs.
|
|
902
|
+
|
|
903
|
+
const completedStreams = new WeakSet<object>();
|
|
904
|
+
|
|
905
|
+
function markStreamComplete(stream: AssistantMessageEventStream | null): void {
|
|
906
|
+
if (stream) completedStreams.add(stream as object);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function claimCurrentPiStream(stream: AssistantMessageEventStream, label: string, c: QueryContext): void {
|
|
910
|
+
if (c.currentPiStream && !completedStreams.has(c.currentPiStream as object)) {
|
|
911
|
+
debug(`WARNING: currentPiStream overwritten before terminal event (${label}); activeQuery=${Boolean(c.activeQuery)} pendingHandlers=${c.pendingToolCalls.size}`);
|
|
912
|
+
}
|
|
913
|
+
c.currentPiStream = stream;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function ensureTurnStarted(c: QueryContext): void {
|
|
917
|
+
if (!c.turnStarted && c.currentPiStream && c.turnOutput) {
|
|
918
|
+
c.currentPiStream!.push({ type: "start", partial: c.turnOutput });
|
|
919
|
+
c.turnStarted = true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function finalizeCurrentStream(c: QueryContext, stopReason?: string): void {
|
|
924
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
925
|
+
debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: c.turnOutput!.stopReason, error: c.turnOutput!.errorMessage})}`);
|
|
926
|
+
if (!c.turnStarted) ensureTurnStarted(c);
|
|
927
|
+
const stream = c.currentPiStream;
|
|
928
|
+
if (c.turnOutput.stopReason === "error") {
|
|
929
|
+
stream!.push({ type: "error", reason: "error", error: c.turnOutput });
|
|
930
|
+
} else {
|
|
931
|
+
const reason = stopReason === "length" ? "length" : "stop";
|
|
932
|
+
stream!.push({ type: "done", reason, message: c.turnOutput });
|
|
933
|
+
}
|
|
934
|
+
markStreamComplete(stream);
|
|
935
|
+
stream!.end();
|
|
936
|
+
c.currentPiStream = null;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
940
|
+
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
941
|
+
function processStreamEvent(
|
|
942
|
+
message: SDKMessage,
|
|
943
|
+
customToolNameToPi: Map<string, string>,
|
|
944
|
+
model: Model<any>,
|
|
945
|
+
c: QueryContext,
|
|
946
|
+
): void {
|
|
947
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
948
|
+
c.turnSawStreamEvent = true;
|
|
949
|
+
const event = (message as SDKMessage & { event: any }).event;
|
|
950
|
+
|
|
951
|
+
if (event?.type === "message_start") {
|
|
952
|
+
c.turnToolCallIds = [];
|
|
953
|
+
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (event?.type === "content_block_start") {
|
|
958
|
+
ensureTurnStarted(c);
|
|
959
|
+
if (event.content_block?.type === "text") {
|
|
960
|
+
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
961
|
+
c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
962
|
+
} else if (event.content_block?.type === "thinking") {
|
|
963
|
+
c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
|
|
964
|
+
c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
965
|
+
} else if (event.content_block?.type === "tool_use") {
|
|
966
|
+
const piName = piToolNameFor(event.content_block.name, customToolNameToPi);
|
|
967
|
+
if (!piName) {
|
|
968
|
+
debug(`processStreamEvent: skipping tool_use for unserved tool ${event.content_block.name} [${event.content_block.id}] — CC rejects it and retries`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
c.turnSawToolCall = true;
|
|
972
|
+
c.turnToolCallIds.push(event.content_block.id);
|
|
973
|
+
c.turnBlocks.push({
|
|
974
|
+
type: "toolCall", id: event.content_block.id,
|
|
975
|
+
name: piName,
|
|
976
|
+
arguments: (event.content_block.input as Record<string, unknown>) ?? {},
|
|
977
|
+
partialJson: "", index: event.index,
|
|
978
|
+
});
|
|
979
|
+
c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
980
|
+
} else {
|
|
981
|
+
debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (event?.type === "content_block_delta") {
|
|
987
|
+
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
988
|
+
const block = c.turnBlocks[index];
|
|
989
|
+
if (!block) return;
|
|
990
|
+
if (event.delta?.type === "text_delta" && block.type === "text") {
|
|
991
|
+
block.text += event.delta.text;
|
|
992
|
+
c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
|
|
993
|
+
} else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
|
|
994
|
+
block.thinking += event.delta.thinking;
|
|
995
|
+
c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
|
|
996
|
+
} else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
|
|
997
|
+
block.partialJson += event.delta.partial_json;
|
|
998
|
+
block.arguments = parsePartialJson(block.partialJson, block.arguments);
|
|
999
|
+
c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
|
|
1000
|
+
} else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
|
|
1001
|
+
block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
|
|
1002
|
+
} else {
|
|
1003
|
+
debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
|
|
1004
|
+
}
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (event?.type === "content_block_stop") {
|
|
1009
|
+
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
1010
|
+
const block = c.turnBlocks[index];
|
|
1011
|
+
if (!block) return;
|
|
1012
|
+
delete block.index;
|
|
1013
|
+
if (block.type === "text") {
|
|
1014
|
+
c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
|
|
1015
|
+
} else if (block.type === "thinking") {
|
|
1016
|
+
c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
|
|
1017
|
+
} else if (block.type === "toolCall") {
|
|
1018
|
+
c.turnSawToolCall = true;
|
|
1019
|
+
block.arguments = mapToolArgs(
|
|
1020
|
+
block.name, parsePartialJson(block.partialJson, block.arguments),
|
|
1021
|
+
);
|
|
1022
|
+
delete block.partialJson;
|
|
1023
|
+
c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
|
|
1024
|
+
}
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (event?.type === "message_delta") {
|
|
1029
|
+
c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
|
|
1030
|
+
if (event.usage) updateUsage(c.turnOutput, event.usage, model);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (event?.type === "message_stop" && c.turnSawToolCall) {
|
|
1035
|
+
// Tool call complete — end this pi stream. The SDK will still yield an
|
|
1036
|
+
// assistant message for this turn, but currentPiStream=null causes
|
|
1037
|
+
// consumeQuery to skip it. The MCP handler blocks the generator until
|
|
1038
|
+
// pi delivers the tool result via the next streamSimple call.
|
|
1039
|
+
c.turnOutput.stopReason = "toolUse";
|
|
1040
|
+
const stream = c.currentPiStream;
|
|
1041
|
+
stream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
1042
|
+
markStreamComplete(stream);
|
|
1043
|
+
stream!.end();
|
|
1044
|
+
c.currentPiStream = null;
|
|
1045
|
+
|
|
1046
|
+
// Cursor is updated by the next streamSimple call (tool result delivery path)
|
|
1047
|
+
// which sets cursor = context.messages.length with the post-tool-result context.
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (event?.type !== "message_stop" && event?.type !== "ping") {
|
|
1052
|
+
debug("processStreamEvent: unhandled event type", event?.type);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// The SDK always yields `assistant` messages (completed content blocks) after streaming.
|
|
1057
|
+
// When stream_events already delivered the content, this is a no-op. But after
|
|
1058
|
+
// resetTurnState (e.g. tool result delivery), if the next turn's assistant message
|
|
1059
|
+
// arrives before any stream_events, this is the primary content path. Must maintain
|
|
1060
|
+
// the same stream lifecycle as processStreamEvent — including ending the stream on
|
|
1061
|
+
// tool_use to prevent deadlock with the MCP handler.
|
|
1062
|
+
function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext): void {
|
|
1063
|
+
if (c.turnSawStreamEvent) return;
|
|
1064
|
+
const assistantMsg = (message as any).message;
|
|
1065
|
+
if (!assistantMsg?.content) return;
|
|
1066
|
+
c.turnToolCallIds = [];
|
|
1067
|
+
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
|
|
1068
|
+
for (const block of assistantMsg.content) {
|
|
1069
|
+
if (block.type === "text" && block.text) {
|
|
1070
|
+
ensureTurnStarted(c);
|
|
1071
|
+
c.turnBlocks.push({ type: "text", text: block.text });
|
|
1072
|
+
const idx = c.turnBlocks.length - 1;
|
|
1073
|
+
c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
|
|
1074
|
+
c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
|
|
1075
|
+
c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
|
|
1076
|
+
} else if (block.type === "thinking") {
|
|
1077
|
+
ensureTurnStarted(c);
|
|
1078
|
+
c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
|
|
1079
|
+
const idx = c.turnBlocks.length - 1;
|
|
1080
|
+
c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
|
|
1081
|
+
if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
|
|
1082
|
+
c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
|
|
1083
|
+
} else if (block.type === "tool_use") {
|
|
1084
|
+
const piName = piToolNameFor(block.name, customToolNameToPi);
|
|
1085
|
+
if (!piName) {
|
|
1086
|
+
debug(`processAssistantMessage: skipping tool_use for unserved tool ${block.name} [${block.id}] — CC rejects it and retries`);
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
ensureTurnStarted(c);
|
|
1090
|
+
c.turnSawToolCall = true;
|
|
1091
|
+
c.turnToolCallIds.push(block.id);
|
|
1092
|
+
c.turnBlocks.push({
|
|
1093
|
+
type: "toolCall", id: block.id,
|
|
1094
|
+
name: piName,
|
|
1095
|
+
arguments: mapToolArgs(piName, block.input),
|
|
1096
|
+
});
|
|
1097
|
+
const idx = c.turnBlocks.length - 1;
|
|
1098
|
+
const toolBlock = c.turnBlocks[idx];
|
|
1099
|
+
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
1100
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
1101
|
+
} else {
|
|
1102
|
+
debug("processAssistantMessage: unhandled block type", block.type);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
1106
|
+
|
|
1107
|
+
// End the stream on tool_use, same as processStreamEvent's message_stop handler.
|
|
1108
|
+
if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
|
|
1109
|
+
c.turnOutput.stopReason = "toolUse";
|
|
1110
|
+
const stream = c.currentPiStream;
|
|
1111
|
+
stream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
1112
|
+
markStreamComplete(stream);
|
|
1113
|
+
stream.end();
|
|
1114
|
+
c.currentPiStream = null;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
|
|
1119
|
+
* Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
|
|
1120
|
+
* an assistant message (completed blocks). On tool_use, the stream is ended by
|
|
1121
|
+
* whichever path handles it first (processStreamEvent or processAssistantMessage),
|
|
1122
|
+
* and the MCP handler blocks the generator until pi delivers the tool result. */
|
|
1123
|
+
async function consumeQuery(
|
|
1124
|
+
sdkQuery: ReturnType<typeof query>,
|
|
1125
|
+
customToolNameToPi: Map<string, string>,
|
|
1126
|
+
model: Model<any>,
|
|
1127
|
+
wasAborted: () => boolean,
|
|
1128
|
+
queryCtx: QueryContext,
|
|
1129
|
+
): Promise<{ capturedSessionId?: string }> {
|
|
1130
|
+
let capturedSessionId: string | undefined;
|
|
1131
|
+
|
|
1132
|
+
for await (const message of sdkQuery) {
|
|
1133
|
+
if (RECORD_STREAM_PATH) appendFileSync(RECORD_STREAM_PATH, `${JSON.stringify(message)}\n`);
|
|
1134
|
+
if (wasAborted()) break;
|
|
1135
|
+
// Everything below the currentPiStream guard is content, which there is
|
|
1136
|
+
// nowhere to put once a turn has ended on a tool call. These three are not
|
|
1137
|
+
// content and must not share that gate:
|
|
1138
|
+
//
|
|
1139
|
+
// - stdin: nothing else closes the CLI's stdin now that the prompt is a
|
|
1140
|
+
// streamed generator (isSingleUserTurn=false), so missing this hangs the query.
|
|
1141
|
+
// - the failure a `result` carries: it is the only record that the turn
|
|
1142
|
+
// failed at all. Behind the guard, a 429 arriving at a tool boundary set
|
|
1143
|
+
// no stopReason, no errorMessage, and logged nothing — the turn simply
|
|
1144
|
+
// ended empty.
|
|
1145
|
+
// - rate-limit events: notifications to the user, which are most likely to
|
|
1146
|
+
// fire during exactly the long tool-using turns the guard was skipping.
|
|
1147
|
+
let resultError: string | undefined;
|
|
1148
|
+
if (message.type === "result") {
|
|
1149
|
+
queryCtx.promptStream?.end();
|
|
1150
|
+
logServedContextWindow("result", message, model);
|
|
1151
|
+
resultError = resultErrorText(message);
|
|
1152
|
+
if (resultError !== undefined) {
|
|
1153
|
+
debug(`consumeQuery: error result, subtype=${message.subtype}, error=${resultError}`);
|
|
1154
|
+
if (queryCtx.turnOutput) {
|
|
1155
|
+
queryCtx.turnOutput.stopReason = "error";
|
|
1156
|
+
queryCtx.turnOutput.errorMessage = resultError;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (message.type === "rate_limit_event") {
|
|
1161
|
+
const info = (message as any).rate_limit_info;
|
|
1162
|
+
debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
1163
|
+
if (info?.status === "rejected") {
|
|
1164
|
+
const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
|
|
1165
|
+
piUI?.notify(`Claude rate limited (${info.rateLimitType ?? "unknown"}) — resets at ${resetsAt}`, "warning");
|
|
1166
|
+
} else if (info?.status === "allowed_warning") {
|
|
1167
|
+
piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
|
|
1168
|
+
}
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (!queryCtx.currentPiStream || !queryCtx.turnOutput) continue;
|
|
1172
|
+
|
|
1173
|
+
switch (message.type) {
|
|
1174
|
+
case "stream_event":
|
|
1175
|
+
processStreamEvent(message, customToolNameToPi, model, queryCtx);
|
|
1176
|
+
break;
|
|
1177
|
+
case "assistant":
|
|
1178
|
+
processAssistantMessage(message, model, customToolNameToPi, queryCtx);
|
|
1179
|
+
break;
|
|
1180
|
+
case "result": {
|
|
1181
|
+
// The failure itself was recorded above the guard, along with the served
|
|
1182
|
+
// context window. What is left here is the success path: push the result
|
|
1183
|
+
// text when no assistant message already delivered it.
|
|
1184
|
+
if (resultError === undefined && !queryCtx.turnSawStreamEvent && message.subtype === "success") {
|
|
1185
|
+
ensureTurnStarted(queryCtx);
|
|
1186
|
+
const text = message.result || "";
|
|
1187
|
+
queryCtx.turnBlocks.push({ type: "text", text });
|
|
1188
|
+
const idx = queryCtx.turnBlocks.length - 1;
|
|
1189
|
+
queryCtx.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: queryCtx.turnOutput });
|
|
1190
|
+
queryCtx.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: queryCtx.turnOutput });
|
|
1191
|
+
queryCtx.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: queryCtx.turnOutput });
|
|
1192
|
+
}
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
case "system":
|
|
1196
|
+
if ((message as any).subtype === "init" && (message as any).session_id) {
|
|
1197
|
+
capturedSessionId = (message as any).session_id;
|
|
1198
|
+
}
|
|
1199
|
+
break;
|
|
1200
|
+
case "user":
|
|
1201
|
+
// SDK echo of the user prompt — no stream events to emit. Note it
|
|
1202
|
+
// carries only prompts and tool results: a steer CC drained at a
|
|
1203
|
+
// tool boundary is recorded in its session transcript as a
|
|
1204
|
+
// `queued_command` attachment and never reaches this stream, which
|
|
1205
|
+
// is why the mid-turn steering tripwire has to live in the
|
|
1206
|
+
// integration test.
|
|
1207
|
+
break;
|
|
1208
|
+
default:
|
|
1209
|
+
debug("consumeQuery: unhandled SDK message type", message.type);
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// DEBUG: trace when consumeQuery exits
|
|
1215
|
+
debug(`consumeQuery: for-await loop exited, wasAborted=${wasAborted()}, capturedSessionId=${capturedSessionId?.slice(0, 8) ?? "none"}`);
|
|
1216
|
+
|
|
1217
|
+
return { capturedSessionId };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** The trailing user turn as content blocks, or null if there isn't one.
|
|
1221
|
+
* Blocks rather than text so image steers keep their images. */
|
|
1222
|
+
function steerBlocks(messages: Context["messages"]): ContentBlockParam[] | null {
|
|
1223
|
+
const blocks = extractUserPromptBlocks(messages);
|
|
1224
|
+
if (blocks) return blocks;
|
|
1225
|
+
const text = extractUserPrompt(messages);
|
|
1226
|
+
return text ? [{ type: "text", text }] : null;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** A steer that never made it into CC's session. The cursor has already counted
|
|
1230
|
+
* it, so count-based sync would skip it forever — rebuild instead, which
|
|
1231
|
+
* re-imports the message from pi's context. */
|
|
1232
|
+
function steerMissedSession(text: string): void {
|
|
1233
|
+
if (!sharedSession) return;
|
|
1234
|
+
sharedSession = { ...sharedSession, needsRebuild: true };
|
|
1235
|
+
debug(`provider: steer never reached CC, marked session for rebuild: ${text.slice(0, 60)}`);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Releases this turn's tool results to their MCP handlers, after first pushing
|
|
1239
|
+
* any steer to CC.
|
|
1240
|
+
*
|
|
1241
|
+
* The ordering is mandatory, not an optimization. The steer and the MCP tool
|
|
1242
|
+
* result travel back to CC over the same stdin FIFO. Awaiting the push ack
|
|
1243
|
+
* (which resolves only once the SDK's write to stdin completed) before
|
|
1244
|
+
* resolving any handler guarantees CC enqueues the steer *before* it reads the
|
|
1245
|
+
* tool result, so its post-tool-call drain sees it and acts on it this turn.
|
|
1246
|
+
* Resolve first and the steer misses the drain, silently degrading to
|
|
1247
|
+
* follow-up semantics.
|
|
1248
|
+
*
|
|
1249
|
+
* Both the post-tool-call drain and the FIFO ordering are CC CLI internals,
|
|
1250
|
+
* not SDK contract — tests/int-tool-message.mjs is the tripwire if they move. */
|
|
1251
|
+
async function deliverToolResults(
|
|
1252
|
+
c: QueryContext,
|
|
1253
|
+
results: McpResult[],
|
|
1254
|
+
steer: ContentBlockParam[] | null,
|
|
1255
|
+
contextLength: number,
|
|
1256
|
+
): Promise<void> {
|
|
1257
|
+
if (steer) {
|
|
1258
|
+
const text = steer.map((b) => (b.type === "text" ? b.text : "[image]")).join("\n");
|
|
1259
|
+
if (!c.promptStream) {
|
|
1260
|
+
debug(`WARNING: steer with no prompt stream, dropping: ${text.slice(0, 60)}`);
|
|
1261
|
+
steerMissedSession(text);
|
|
1262
|
+
} else {
|
|
1263
|
+
try {
|
|
1264
|
+
await c.promptStream.push(userMessage(steer, "next"));
|
|
1265
|
+
debug(`provider: steer written to CC stdin before tool result: ${text.slice(0, 60)}`);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
// The query is ending — pushing further input would wedge tool-result
|
|
1268
|
+
// delivery, so the steer doesn't reach this query. It is still in
|
|
1269
|
+
// pi's context, and the caller has already advanced the session
|
|
1270
|
+
// cursor past it, so force a rebuild or CC would never see it.
|
|
1271
|
+
debug(`provider: steer push rejected, delivering tool result anyway:`, error);
|
|
1272
|
+
steerMissedSession(text);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
debug(`provider: tool results, ${results.length} results, ${c.pendingToolCalls.size} waiting handlers, ctx.msgs=${contextLength}`);
|
|
1278
|
+
for (const result of results) {
|
|
1279
|
+
const id = result.toolCallId;
|
|
1280
|
+
if (id && c.pendingToolCalls.has(id)) {
|
|
1281
|
+
const pending = c.pendingToolCalls.get(id)!;
|
|
1282
|
+
c.pendingToolCalls.delete(id);
|
|
1283
|
+
debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
|
|
1284
|
+
pending.resolve(result);
|
|
1285
|
+
} else if (id) {
|
|
1286
|
+
c.pendingResults.set(id, result);
|
|
1287
|
+
debug(`provider: queued result [${id}] (${c.pendingResults.size} pending)`);
|
|
1288
|
+
} else {
|
|
1289
|
+
debug(`WARNING: tool result without toolCallId, cannot match`);
|
|
1290
|
+
}
|
|
1291
|
+
if (c.pendingToolCalls.size > 0 && c.pendingResults.size > 0) {
|
|
1292
|
+
debug(`BUG: both maps non-empty! handlers=${c.pendingToolCalls.size} results=${c.pendingResults.size}`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (c.pendingToolCalls.size > 0) {
|
|
1296
|
+
debug(`WARNING: ${c.pendingToolCalls.size} MCP handlers still waiting after delivering ${results.length} results`);
|
|
1297
|
+
piUI?.notify(`Claude bridge: ${c.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** Abort teardown for one query: settle everything that would otherwise be left
|
|
1302
|
+
* awaiting a subprocess we are about to kill. The pump abandons iteration on
|
|
1303
|
+
* abort, so an in-flight prompt-stream push would hang forever and take
|
|
1304
|
+
* tool-result delivery with it. */
|
|
1305
|
+
function drainForAbort(c: QueryContext, promptStream: PromptStream): void {
|
|
1306
|
+
promptStream.fail(new Error("Operation aborted"));
|
|
1307
|
+
c.releasePendingToolCalls("Operation aborted");
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Provider entry point. Pi calls this for each new prompt and each tool result.
|
|
1311
|
+
* Two cases: tool result delivery (active query) or fresh query. */
|
|
1312
|
+
function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
|
|
1313
|
+
showPlanNoticeOnce();
|
|
1314
|
+
const stream = newAssistantMessageEventStream();
|
|
1315
|
+
|
|
1316
|
+
// DEBUG: trace followUp message triggering
|
|
1317
|
+
const lastMsgRole = context.messages[context.messages.length - 1]?.role;
|
|
1318
|
+
debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
|
|
1319
|
+
|
|
1320
|
+
const activeQuery = ctx().activeQuery !== null;
|
|
1321
|
+
const allResults = activeQueryContexts.size > 0 ? extractAllToolResults(context) : [];
|
|
1322
|
+
const resultCtx = allResults.length > 0 ? contextForToolResults(allResults) : undefined;
|
|
1323
|
+
const isReentrantUserQuery = activeQuery && lastMsgRole === "user" && allResults.length === 0;
|
|
1324
|
+
if (isReentrantUserQuery) {
|
|
1325
|
+
debug(`provider: active query user-only call treated as reentrant fresh query, waitingHandlers=${ctx().pendingToolCalls.size}, ctx.msgs=${context.messages.length}`);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// --- Tool result delivery ---
|
|
1329
|
+
// Pi appends tool results to context and calls back. Extract this turn's results
|
|
1330
|
+
// (everything after the last assistant message) and match against waiting MCP
|
|
1331
|
+
// handlers. Results that arrive before their handler get queued in pendingResults.
|
|
1332
|
+
if (resultCtx) {
|
|
1333
|
+
claimCurrentPiStream(stream, "tool-result", resultCtx);
|
|
1334
|
+
resultCtx.resetTurnState(model);
|
|
1335
|
+
// User messages (steer/followUp) pi injected into context during the
|
|
1336
|
+
// active query: a steer sent while a tool was executing, drained by pi at
|
|
1337
|
+
// the turn boundary and appended alongside the tool result.
|
|
1338
|
+
const steer = lastMsgRole === "user" ? steerBlocks(context.messages) : null;
|
|
1339
|
+
// Delivery is async because the steer must reach CC's stdin *before* the
|
|
1340
|
+
// tool result does — see deliverToolResults. Detached so the provider
|
|
1341
|
+
// still returns its stream synchronously.
|
|
1342
|
+
void deliverToolResults(resultCtx, allResults, steer, context.messages.length);
|
|
1343
|
+
// The shared cursor tracks the top-level conversation. A reentrant subagent
|
|
1344
|
+
// delivering its own results would drag it to that subagent's message count
|
|
1345
|
+
// — observed pulling a parent from 5 back to 3, which cost the parent's next
|
|
1346
|
+
// turn a full rebuild and a flushed prompt cache.
|
|
1347
|
+
if (sharedSession && resultCtx === ctx()) sharedSession.cursor = context.messages.length;
|
|
1348
|
+
resultCtx.latestCursor = Math.max(resultCtx.latestCursor, context.messages.length);
|
|
1349
|
+
return stream;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// --- Orphaned tool result (e.g. user aborted a tool call) ---
|
|
1353
|
+
// The query is gone but pi still delivered the result. Nothing to do — just
|
|
1354
|
+
// emit end_turn so pi waits for the next real user message.
|
|
1355
|
+
const lastMsg = context.messages[context.messages.length - 1];
|
|
1356
|
+
if (lastMsg?.role === "toolResult") {
|
|
1357
|
+
debug(`provider: orphaned tool result after abort, emitting end_turn`);
|
|
1358
|
+
if (sharedSession && activeQueryContexts.size === 0) sharedSession.cursor = context.messages.length;
|
|
1359
|
+
// No query owns this result, so there is no context to reset: resetTurnState
|
|
1360
|
+
// on the top-level ctx() would replace a live parent's turnOutput mid-stream,
|
|
1361
|
+
// stranding the blocks it had already emitted. A throwaway context just
|
|
1362
|
+
// supplies the empty message this turn ends with.
|
|
1363
|
+
const c = new QueryContext();
|
|
1364
|
+
c.resetTurnState(model);
|
|
1365
|
+
queueMicrotask(() => {
|
|
1366
|
+
stream.push({ type: "done", reason: "stop", message: c.turnOutput });
|
|
1367
|
+
markStreamComplete(stream);
|
|
1368
|
+
stream.end();
|
|
1369
|
+
});
|
|
1370
|
+
return stream;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// --- Fresh query ---
|
|
1374
|
+
|
|
1375
|
+
// 1. Determine reentrancy. Reentrant queries get their own QueryContext so
|
|
1376
|
+
// background subagents can run concurrently with the parent query.
|
|
1377
|
+
const isReentrant = activeQuery;
|
|
1378
|
+
const queryCtx = isReentrant ? new QueryContext() : ctx();
|
|
1379
|
+
debug(`provider: fresh query setup, isReentrant=${isReentrant}, activeContexts=${activeQueryContexts.size}`);
|
|
1380
|
+
|
|
1381
|
+
// 2. Fresh child context — constructor already gave us clean Maps and empty
|
|
1382
|
+
// arrays. For a reused top-level context, clear explicitly.
|
|
1383
|
+
claimCurrentPiStream(stream, "fresh-query", queryCtx);
|
|
1384
|
+
queryCtx.pendingToolCalls.clear();
|
|
1385
|
+
queryCtx.pendingResults.clear();
|
|
1386
|
+
// Stale ids would let a late result from the previous query route here via
|
|
1387
|
+
// contextForToolResults — which now means pushing its steer into this
|
|
1388
|
+
// query's stdin, not just mismatching a map.
|
|
1389
|
+
queryCtx.turnToolCallIds = [];
|
|
1390
|
+
queryCtx.resetTurnState(model);
|
|
1391
|
+
queryCtx.latestCursor = 0;
|
|
1392
|
+
|
|
1393
|
+
const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context, askClaudeToolName);
|
|
1394
|
+
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
1395
|
+
// cliModel is the actual id sent to Claude Code (may carry [1m]); model.id is the
|
|
1396
|
+
// pi-registered id. Log cliModel so debug lines reflect what CC actually received.
|
|
1397
|
+
const cliModel = claudeCodeModelId(model, longContextSettings);
|
|
1398
|
+
const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, cliModel);
|
|
1399
|
+
const { sessionId: resumeSessionId } = syncResult;
|
|
1400
|
+
const promptBlocks = extractUserPromptBlocks(context.messages);
|
|
1401
|
+
let promptText = extractUserPrompt(context.messages) ?? "";
|
|
1402
|
+
|
|
1403
|
+
// Guard: empty prompt means the last context message isn't a user message.
|
|
1404
|
+
// This should never happen with per-query state — dump diagnostics if it does.
|
|
1405
|
+
if (!promptText && !promptBlocks) {
|
|
1406
|
+
diagDump("empty_prompt", {
|
|
1407
|
+
contextLength: context.messages.length,
|
|
1408
|
+
lastMsgRole: lastMsg?.role,
|
|
1409
|
+
isReentrant,
|
|
1410
|
+
activeQueryContexts: activeQueryContexts.size,
|
|
1411
|
+
activeQueryExists: queryCtx.activeQuery !== null,
|
|
1412
|
+
sharedSession: sharedSession ? { sessionId: sharedSession.sessionId.slice(0, 8), cursor: sharedSession.cursor } : null,
|
|
1413
|
+
messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
|
|
1414
|
+
});
|
|
1415
|
+
// Recover: use a continuation prompt so the SDK doesn't send an empty text block
|
|
1416
|
+
promptText = "[continue]";
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Always stream the prompt rather than passing a string: a parked input
|
|
1420
|
+
// generator is what lets us write steers to CC's stdin mid-turn. The cost is
|
|
1421
|
+
// that `isSingleUserTurn` is false, so the SDK no longer closes stdin on the
|
|
1422
|
+
// first result — consumeQuery ends the stream explicitly instead, or the
|
|
1423
|
+
// query would never terminate.
|
|
1424
|
+
const promptStream = makePromptStream();
|
|
1425
|
+
void promptStream.push(userMessage(promptBlocks ?? [{ type: "text", text: promptText }]))
|
|
1426
|
+
.catch((error) => debug(`provider: initial prompt push rejected:`, error));
|
|
1427
|
+
queryCtx.promptStream = promptStream;
|
|
1428
|
+
const mcpServers = buildMcpServers(mcpTools, queryCtx);
|
|
1429
|
+
const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
|
|
1430
|
+
const agentsAppend = appendSystemPrompt ? extractAgentsAppend(cwd) : undefined;
|
|
1431
|
+
const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
|
|
1432
|
+
// Last, so the user's own instructions win over anything the bridge adds, and
|
|
1433
|
+
// ungated by appendSystemPrompt: that setting suppresses context the bridge
|
|
1434
|
+
// injects on its own, not what the user explicitly asked for.
|
|
1435
|
+
const appendParts = [agentsAppend, skillsAppend, userSystemPrompt.custom, userSystemPrompt.append]
|
|
1436
|
+
.filter((part): part is string => Boolean(part));
|
|
1437
|
+
const systemPromptAppend = appendParts.length > 0 ? appendParts.join("\n\n") : undefined;
|
|
1438
|
+
|
|
1439
|
+
// MCP auto-loading suppression: CC reads MCP servers from ~/.claude.json (top-level
|
|
1440
|
+
// + per-project) and .mcp.json. Since pi executes tools (not CC), those are pure
|
|
1441
|
+
// token overhead. --strict-mcp-config tells the binary to use ONLY mcpServers passed
|
|
1442
|
+
// programmatically and ignore filesystem MCP entries — applied unconditionally because
|
|
1443
|
+
// settingSources=undefined does NOT give isolation (the CC default loads all sources).
|
|
1444
|
+
const settingSources: SettingSource[] | undefined = appendSystemPrompt
|
|
1445
|
+
? undefined
|
|
1446
|
+
: providerSettings.settingSources ?? ["user", "project"];
|
|
1447
|
+
const strictMcpConfigEnabled = providerSettings.strictMcpConfig !== false;
|
|
1448
|
+
const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
|
|
1449
|
+
|
|
1450
|
+
// Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
|
|
1451
|
+
// per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
|
|
1452
|
+
// Fall back to our generic table for older pi-ai or unmapped levels.
|
|
1453
|
+
const effort = options?.reasoning
|
|
1454
|
+
? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
|
|
1455
|
+
?? REASONING_TO_EFFORT[options.reasoning]
|
|
1456
|
+
: undefined;
|
|
1457
|
+
|
|
1458
|
+
const extraArgs: Record<string, string | null> = { model: cliModel };
|
|
1459
|
+
if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
|
|
1460
|
+
// Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
|
|
1461
|
+
// Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
|
|
1462
|
+
if (effort) extraArgs["thinking-display"] = "summarized";
|
|
1463
|
+
|
|
1464
|
+
// Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
|
|
1465
|
+
// when the user is logged into Anthropic). These are a separate code path from
|
|
1466
|
+
// filesystem MCP and are NOT blocked by --strict-mcp-config or settingSources=undefined.
|
|
1467
|
+
// The native CC binary gates them on env var ENABLE_CLAUDEAI_MCP_SERVERS: setting it
|
|
1468
|
+
// to "0"/"false"/"no"/"off" makes the loader return early before any cloud fetch.
|
|
1469
|
+
// DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
|
|
1470
|
+
// /compact via session_compact (see handler in default export). Letting CC
|
|
1471
|
+
// also autocompact would double-flush the prompt cache and races pi's
|
|
1472
|
+
// threshold with CC's, including CC's anti-thrashing guard (issue #8).
|
|
1473
|
+
// Manual /compact in CC still works (we never invoke it).
|
|
1474
|
+
const childEnv = { ...process.env, ...CC_CHILD_ENV };
|
|
1475
|
+
const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
|
|
1476
|
+
cwd,
|
|
1477
|
+
env: childEnv,
|
|
1478
|
+
tools: [],
|
|
1479
|
+
permissionMode: "bypassPermissions",
|
|
1480
|
+
includePartialMessages: true,
|
|
1481
|
+
settings: claudeCodeSettings(providerSettings),
|
|
1482
|
+
systemPrompt: {
|
|
1483
|
+
type: "preset", preset: "claude_code",
|
|
1484
|
+
append: systemPromptAppend ? systemPromptAppend : undefined,
|
|
1485
|
+
},
|
|
1486
|
+
extraArgs,
|
|
1487
|
+
...(effort ? { effort } : {}),
|
|
1488
|
+
...(settingSources ? { settingSources } : {}),
|
|
1489
|
+
...(mcpServers ? { mcpServers } : {}),
|
|
1490
|
+
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
1491
|
+
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
1492
|
+
...makeCliDebugOptions("provider"),
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
debug("provider: fresh query",
|
|
1496
|
+
`model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
|
|
1497
|
+
`resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
|
|
1498
|
+
`appendSys=${appendSystemPrompt} strictMcp=${strictMcpConfigEnabled}`,
|
|
1499
|
+
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
|
|
1500
|
+
|
|
1501
|
+
// 3. Start SDK query and claim it for this context
|
|
1502
|
+
let wasAborted = false;
|
|
1503
|
+
const sdkQuery = query({ prompt: promptStream.stream, options: queryOptions });
|
|
1504
|
+
queryCtx.activeQuery = sdkQuery;
|
|
1505
|
+
activeQueryContexts.add(queryCtx);
|
|
1506
|
+
|
|
1507
|
+
// 4. Capture context for abort handling
|
|
1508
|
+
const abortCtx = queryCtx;
|
|
1509
|
+
|
|
1510
|
+
const requestAbort = () => {
|
|
1511
|
+
// interrupt() asks the CLI to stop gracefully; close() kills it immediately.
|
|
1512
|
+
// Both are needed — interrupt alone lets the current API call finish.
|
|
1513
|
+
void sdkQuery.interrupt().catch(() => {});
|
|
1514
|
+
try { sdkQuery.close(); } catch {}
|
|
1515
|
+
};
|
|
1516
|
+
const onAbort = () => {
|
|
1517
|
+
wasAborted = true;
|
|
1518
|
+
drainForAbort(abortCtx, promptStream);
|
|
1519
|
+
requestAbort();
|
|
1520
|
+
};
|
|
1521
|
+
if (options?.signal) {
|
|
1522
|
+
if (options.signal.aborted) onAbort();
|
|
1523
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// Background consumer — runs until query ends
|
|
1527
|
+
consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted, queryCtx)
|
|
1528
|
+
.then(async ({ capturedSessionId }) => {
|
|
1529
|
+
debug(`provider: consumeQuery completed, stopReason=${queryCtx.turnOutput?.stopReason}, error=${queryCtx.turnOutput?.errorMessage}, aborted=${wasAborted}`);
|
|
1530
|
+
|
|
1531
|
+
// --- Abort detection in normal completion path ---
|
|
1532
|
+
if (wasAborted || options?.signal?.aborted) {
|
|
1533
|
+
if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
1534
|
+
debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
|
|
1535
|
+
if (queryCtx.turnOutput) {
|
|
1536
|
+
queryCtx.turnOutput.stopReason = "aborted";
|
|
1537
|
+
queryCtx.turnOutput.errorMessage = "Operation aborted";
|
|
1538
|
+
}
|
|
1539
|
+
const stream = queryCtx.currentPiStream;
|
|
1540
|
+
stream?.push({ type: "error", reason: "aborted", error: queryCtx.turnOutput! });
|
|
1541
|
+
markStreamComplete(stream);
|
|
1542
|
+
stream?.end();
|
|
1543
|
+
queryCtx.currentPiStream = null;
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// --- Capture session ID ---
|
|
1548
|
+
const sessionId = capturedSessionId ?? sharedSession?.sessionId;
|
|
1549
|
+
if (syncResult.preserveSharedSession) {
|
|
1550
|
+
if (capturedSessionId && capturedSessionId !== sharedSession?.sessionId) {
|
|
1551
|
+
deleteSession(capturedSessionId, cwd, process.env.CLAUDE_CONFIG_DIR);
|
|
1552
|
+
debug(`provider: query done, deleted ephemeral session ${capturedSessionId.slice(0, 8)} to preserve shared session`);
|
|
1553
|
+
}
|
|
1554
|
+
debug(`provider: query done, ignoring captured session ${capturedSessionId?.slice(0, 8) ?? "none"} to preserve shared session`);
|
|
1555
|
+
} else if (sessionId) {
|
|
1556
|
+
const cursor = Math.max(context.messages.length, queryCtx.latestCursor, sharedSession?.cursor ?? 0);
|
|
1557
|
+
debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
|
|
1558
|
+
sharedSession = { sessionId, cursor, cwd };
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
|
|
1562
|
+
debug("provider: clearing activeQuery before final stream completion");
|
|
1563
|
+
queryCtx.activeQuery = null;
|
|
1564
|
+
}
|
|
1565
|
+
finalizeCurrentStream(queryCtx, queryCtx.turnOutput?.stopReason);
|
|
1566
|
+
})
|
|
1567
|
+
.catch((error) => {
|
|
1568
|
+
debug(`provider: query error, model=${cliModel}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
|
|
1569
|
+
if ((wasAborted || options?.signal?.aborted) && sharedSession) {
|
|
1570
|
+
sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
1571
|
+
} else {
|
|
1572
|
+
sharedSession = null;
|
|
1573
|
+
}
|
|
1574
|
+
promptStream.fail(error instanceof Error ? error : new Error(String(error)));
|
|
1575
|
+
if (queryCtx.turnOutput) {
|
|
1576
|
+
queryCtx.turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
1577
|
+
// The SDK drops its copy of the result text if any message follows the error
|
|
1578
|
+
// result, so prefer the cause consumeQuery recorded off the result itself.
|
|
1579
|
+
queryCtx.turnOutput.errorMessage ??= error instanceof Error ? error.message : String(error);
|
|
1580
|
+
}
|
|
1581
|
+
if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
|
|
1582
|
+
queryCtx.releasePendingToolCalls("Query ended");
|
|
1583
|
+
debug("provider: clearing activeQuery before error stream completion");
|
|
1584
|
+
queryCtx.activeQuery = null;
|
|
1585
|
+
}
|
|
1586
|
+
const stream = queryCtx.currentPiStream;
|
|
1587
|
+
stream?.push({ type: "error", reason: (queryCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: queryCtx.turnOutput! });
|
|
1588
|
+
markStreamComplete(stream);
|
|
1589
|
+
stream?.end();
|
|
1590
|
+
queryCtx.currentPiStream = null;
|
|
1591
|
+
})
|
|
1592
|
+
.finally(() => {
|
|
1593
|
+
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
|
1594
|
+
// Settle any ack still parked in the generator — the CLI is gone, so
|
|
1595
|
+
// nothing will resume it. Clear the handle only if a later query
|
|
1596
|
+
// hasn't already claimed the shared context.
|
|
1597
|
+
promptStream.fail(new Error("query ended"));
|
|
1598
|
+
if (queryCtx.promptStream === promptStream) queryCtx.promptStream = null;
|
|
1599
|
+
if (queryCtx.activeQuery === sdkQuery) {
|
|
1600
|
+
queryCtx.releasePendingToolCalls("Query ended");
|
|
1601
|
+
queryCtx.activeQuery = null;
|
|
1602
|
+
// Guarded like the two cleanups above: if a later query has already
|
|
1603
|
+
// claimed this context, removing it from the routing set would send
|
|
1604
|
+
// that query's tool results down the orphan path and strand its handler.
|
|
1605
|
+
activeQueryContexts.delete(queryCtx);
|
|
1606
|
+
}
|
|
1607
|
+
sdkQuery.close();
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
return stream;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// --- AskClaude: prompt and wait ---
|
|
1614
|
+
|
|
1615
|
+
async function promptAndWait(
|
|
1616
|
+
prompt: string,
|
|
1617
|
+
mode: "full" | "read" | "none",
|
|
1618
|
+
toolCalls: Map<string, ToolCallState>,
|
|
1619
|
+
signal?: AbortSignal,
|
|
1620
|
+
options?: {
|
|
1621
|
+
systemPrompt?: string;
|
|
1622
|
+
appendSkills?: boolean;
|
|
1623
|
+
onStreamUpdate?: (responseText: string) => void;
|
|
1624
|
+
model?: string;
|
|
1625
|
+
thinking?: string;
|
|
1626
|
+
isolated?: boolean;
|
|
1627
|
+
context?: Context["messages"];
|
|
1628
|
+
},
|
|
1629
|
+
): Promise<{ responseText: string; stopReason: string }> {
|
|
1630
|
+
const cwd = process.cwd();
|
|
1631
|
+
const requestedModel = options?.model ?? "opus";
|
|
1632
|
+
const model = resolveModel(requestedModel);
|
|
1633
|
+
const modelId = model?.id ?? requestedModel;
|
|
1634
|
+
const cliModel = model ? claudeCodeModelId(model, longContextSettings) : modelId;
|
|
1635
|
+
|
|
1636
|
+
// Session resume for shared mode — reuse provider's session if it exists,
|
|
1637
|
+
// otherwise create one from pi's context.
|
|
1638
|
+
// Note: doesn't update sharedSession.cursor after completion, so the next
|
|
1639
|
+
// provider call will see missed messages and trigger a Case 4 rebuild.
|
|
1640
|
+
let resumeSessionId: string | null = null;
|
|
1641
|
+
if (!options?.isolated && options?.context?.length) {
|
|
1642
|
+
if (sharedSession) {
|
|
1643
|
+
// Provider already has a session — just resume from it
|
|
1644
|
+
// Any missed messages from other providers were already handled by the provider's Case 4
|
|
1645
|
+
resumeSessionId = sharedSession.sessionId;
|
|
1646
|
+
} else {
|
|
1647
|
+
// No provider session yet — create one from pi's context
|
|
1648
|
+
const contextWithPrompt = [...options.context, { role: "user" as const, content: prompt, timestamp: Date.now() }];
|
|
1649
|
+
const sync = syncSharedSession(contextWithPrompt as Context["messages"], cwd, undefined, cliModel);
|
|
1650
|
+
resumeSessionId = sync.sessionId;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Mode → disallowed tools
|
|
1655
|
+
const disallowedTools = MODE_DISALLOWED_TOOLS[mode] ?? [];
|
|
1656
|
+
|
|
1657
|
+
// Skills append
|
|
1658
|
+
const skillsBlock = options?.appendSkills !== false && options?.systemPrompt
|
|
1659
|
+
? extractSkillsBlock(options.systemPrompt) : undefined;
|
|
1660
|
+
|
|
1661
|
+
// Effort
|
|
1662
|
+
const effort = options?.thinking && options.thinking !== "off"
|
|
1663
|
+
? REASONING_TO_EFFORT[options.thinking] : undefined;
|
|
1664
|
+
|
|
1665
|
+
const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
|
|
1666
|
+
|
|
1667
|
+
const extraArgs: Record<string, string | null> = {
|
|
1668
|
+
"strict-mcp-config": null,
|
|
1669
|
+
model: cliModel,
|
|
1670
|
+
};
|
|
1671
|
+
if (effort) extraArgs["thinking-display"] = "summarized";
|
|
1672
|
+
|
|
1673
|
+
debug("askClaude:",
|
|
1674
|
+
`mode=${mode} model=${modelId} cliModel=${cliModel} effort=${effort ?? "default"}`,
|
|
1675
|
+
`isolated=${options?.isolated ?? false} resume=${resumeSessionId?.slice(0, 8) ?? "none"}`,
|
|
1676
|
+
`skills=${Boolean(skillsBlock)} promptLen=${prompt.length}`);
|
|
1677
|
+
|
|
1678
|
+
const sdkQuery = query({
|
|
1679
|
+
prompt,
|
|
1680
|
+
options: {
|
|
1681
|
+
cwd,
|
|
1682
|
+
env: { ...process.env, ...CC_CHILD_ENV },
|
|
1683
|
+
permissionMode: "bypassPermissions",
|
|
1684
|
+
settings: claudeCodeSettings(providerSettings),
|
|
1685
|
+
...(disallowedTools.length ? { disallowedTools } : {}),
|
|
1686
|
+
...(effort ? { effort } : {}),
|
|
1687
|
+
systemPrompt: skillsBlock
|
|
1688
|
+
? { type: "preset", preset: "claude_code", append: skillsBlock }
|
|
1689
|
+
: undefined,
|
|
1690
|
+
settingSources: ["user", "project"] as SettingSource[],
|
|
1691
|
+
extraArgs,
|
|
1692
|
+
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
1693
|
+
...(options?.isolated ? { persistSession: false } : {}),
|
|
1694
|
+
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
1695
|
+
...makeCliDebugOptions("askclaude"),
|
|
1696
|
+
},
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
// Abort handling
|
|
1700
|
+
let wasAborted = false;
|
|
1701
|
+
const onAbort = () => {
|
|
1702
|
+
wasAborted = true;
|
|
1703
|
+
sdkQuery.interrupt().catch(() => { try { sdkQuery.close(); } catch {} });
|
|
1704
|
+
};
|
|
1705
|
+
if (signal?.aborted) { onAbort(); throw new Error("Aborted"); }
|
|
1706
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1707
|
+
|
|
1708
|
+
let responseText = "";
|
|
1709
|
+
let sdkMessageCount = 0;
|
|
1710
|
+
let textDeltaCount = 0;
|
|
1711
|
+
let resultSubtype: string | undefined;
|
|
1712
|
+
|
|
1713
|
+
try {
|
|
1714
|
+
for await (const message of sdkQuery) {
|
|
1715
|
+
if (wasAborted) break;
|
|
1716
|
+
sdkMessageCount++;
|
|
1717
|
+
|
|
1718
|
+
switch (message.type) {
|
|
1719
|
+
case "stream_event": {
|
|
1720
|
+
const event = (message as SDKMessage & { event: any }).event;
|
|
1721
|
+
// Text deltas → accumulate and stream
|
|
1722
|
+
if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
1723
|
+
responseText += event.delta.text;
|
|
1724
|
+
textDeltaCount++;
|
|
1725
|
+
options?.onStreamUpdate?.(responseText);
|
|
1726
|
+
}
|
|
1727
|
+
// Tool call start → track for action summary progress
|
|
1728
|
+
if (event?.type === "content_block_start" && event.content_block?.type === "tool_use") {
|
|
1729
|
+
debug(`askClaude: tool_use start: ${event.content_block.name}`);
|
|
1730
|
+
toolCalls.set(event.content_block.id, {
|
|
1731
|
+
name: mapToolName(event.content_block.name),
|
|
1732
|
+
status: "running",
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
break;
|
|
1736
|
+
}
|
|
1737
|
+
case "assistant": {
|
|
1738
|
+
// Update tool calls with full input for action summary
|
|
1739
|
+
for (const block of (message as any).message?.content ?? []) {
|
|
1740
|
+
if (block.type === "tool_use") {
|
|
1741
|
+
toolCalls.set(block.id, {
|
|
1742
|
+
name: mapToolName(block.name),
|
|
1743
|
+
status: "complete",
|
|
1744
|
+
rawInput: block.input,
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
break;
|
|
1749
|
+
}
|
|
1750
|
+
case "result": {
|
|
1751
|
+
resultSubtype = message.subtype;
|
|
1752
|
+
const r = message as any;
|
|
1753
|
+
if (r.usage) {
|
|
1754
|
+
debug(`askClaude: result usage: in=${r.usage.input_tokens} out=${r.usage.output_tokens} cacheRead=${r.usage.cache_read_input_tokens ?? 0} cacheWrite=${r.usage.cache_creation_input_tokens ?? 0} turns=${r.num_turns ?? "?"}`);
|
|
1755
|
+
}
|
|
1756
|
+
if (!responseText && message.subtype === "success" && message.result) {
|
|
1757
|
+
responseText = message.result;
|
|
1758
|
+
}
|
|
1759
|
+
break;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const stopReason = wasAborted ? "cancelled" : "stop";
|
|
1765
|
+
debug(`askClaude: done`,
|
|
1766
|
+
`stopReason=${stopReason} resultSubtype=${resultSubtype ?? "none"}`,
|
|
1767
|
+
`sdkMessages=${sdkMessageCount} textDeltas=${textDeltaCount} responseLen=${responseText.length}`,
|
|
1768
|
+
`toolCalls=${toolCalls.size}`);
|
|
1769
|
+
return { responseText, stopReason };
|
|
1770
|
+
} finally {
|
|
1771
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1772
|
+
sdkQuery.close();
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// --- Extension registration ---
|
|
1777
|
+
|
|
1778
|
+
const DEFAULT_TOOL_DESCRIPTION_FULL = "Delegate to Claude Code for a second opinion or analysis (code review, architecture questions, debugging theories), or to autonomously handle a task. Defaults to read-only mode — use full mode when the user wants to delegate a task that requires changes. Prefer to handle straightforward tasks yourself.";
|
|
1779
|
+
const DEFAULT_TOOL_DESCRIPTION = "Delegate to Claude Code for a second opinion or analysis (code review, architecture questions, debugging theories). Read-only — Claude Code can explore the codebase but not make changes. Prefer to handle straightforward tasks yourself.";
|
|
1780
|
+
|
|
1781
|
+
const PREVIEW_MAX_CHARS = 1000;
|
|
1782
|
+
const PREVIEW_MAX_LINES = 6;
|
|
1783
|
+
|
|
1784
|
+
let askClaudeToolName = "AskClaude";
|
|
1785
|
+
|
|
1786
|
+
export default function (pi: ExtensionAPI) {
|
|
1787
|
+
// Disable non-essential Claude Code traffic (update checks, MCP registry, telemetry)
|
|
1788
|
+
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
1789
|
+
|
|
1790
|
+
const config = loadConfig(process.cwd());
|
|
1791
|
+
debug("loadConfig:", JSON.stringify(config));
|
|
1792
|
+
providerSettings = config.provider ?? {};
|
|
1793
|
+
// We need these settings to know if we're eligible for 1M context on certain models
|
|
1794
|
+
longContextSettings = {
|
|
1795
|
+
plan: providerSettings.plan ?? "pro",
|
|
1796
|
+
longContextExtraUsage: providerSettings.longContextExtraUsage ?? false,
|
|
1797
|
+
};
|
|
1798
|
+
const registeredModels = applyLongContext(MODELS, longContextSettings);
|
|
1799
|
+
|
|
1800
|
+
planNoticePending = config.provider?.plan === undefined && !config.startupNoticeShown;
|
|
1801
|
+
|
|
1802
|
+
// Reset shared session on pi session lifecycle events
|
|
1803
|
+
const clearSession = (event: string) => {
|
|
1804
|
+
debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
|
|
1805
|
+
sharedSession = null;
|
|
1806
|
+
|
|
1807
|
+
// Clear the global streamSimple if this instance registered it.
|
|
1808
|
+
// This allows /reload to work — the old instance clears the flag so
|
|
1809
|
+
// the new instance can register fresh without wrapping stale state.
|
|
1810
|
+
const g = globalThis as Record<symbol, any>;
|
|
1811
|
+
if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
|
|
1812
|
+
debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
|
|
1813
|
+
g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
1816
|
+
pi.on("session_start", (event, ctx) => {
|
|
1817
|
+
piUI = ctx.ui;
|
|
1818
|
+
piMode = ctx.mode;
|
|
1819
|
+
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
|
|
1820
|
+
clearSession(`session_start:${event.reason}`);
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
// `--system-prompt` replaces pi's default rather than adding to it, but Claude
|
|
1824
|
+
// Code's preset carries its own tool and permission guidance that the bridge
|
|
1825
|
+
// still depends on, so both flags are forwarded as an append.
|
|
1826
|
+
pi.on("before_agent_start", (event) => {
|
|
1827
|
+
const options = event.systemPromptOptions;
|
|
1828
|
+
userSystemPrompt = { custom: options?.customPrompt, append: options?.appendSystemPrompt };
|
|
1829
|
+
});
|
|
1830
|
+
pi.on("session_shutdown", () => clearSession("session_shutdown"));
|
|
1831
|
+
|
|
1832
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
1833
|
+
if (ctx.model?.baseUrl !== "claude-bridge") return undefined;
|
|
1834
|
+
debug(
|
|
1835
|
+
`session_before_compact: takeover reason=${event.reason} willRetry=${event.willRetry} ` +
|
|
1836
|
+
`isSplitTurn=${event.preparation.isSplitTurn} messages=${event.preparation.messagesToSummarize.length} ` +
|
|
1837
|
+
`turnPrefix=${event.preparation.turnPrefixMessages.length}`,
|
|
1838
|
+
);
|
|
1839
|
+
try {
|
|
1840
|
+
reinjectPriorCompactionFileOps(event.branchEntries, event.preparation);
|
|
1841
|
+
const compaction = await compact(
|
|
1842
|
+
event.preparation,
|
|
1843
|
+
ctx.model,
|
|
1844
|
+
undefined,
|
|
1845
|
+
undefined,
|
|
1846
|
+
event.customInstructions,
|
|
1847
|
+
event.signal,
|
|
1848
|
+
undefined,
|
|
1849
|
+
isolatedStreamFn,
|
|
1850
|
+
undefined,
|
|
1851
|
+
);
|
|
1852
|
+
debug(`session_before_compact: takeover complete summaryLen=${compaction.summary.length}`);
|
|
1853
|
+
return { compaction };
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
const msg = errorMessage(err);
|
|
1856
|
+
debug("session_before_compact: takeover failed; cancelling to avoid native compact fallback", err);
|
|
1857
|
+
ctx.ui?.notify?.(
|
|
1858
|
+
`Claude bridge compact failed (${msg}); cancelled to avoid known hang. Retry, switch model, or reduce context.`,
|
|
1859
|
+
"error",
|
|
1860
|
+
);
|
|
1861
|
+
return { cancel: true };
|
|
1862
|
+
}
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
// pi /compact and session-tree navigation (rewind / fork-at-point /
|
|
1866
|
+
// branch switch) both mutate pi's messages array out from under the
|
|
1867
|
+
// bridge. syncSharedSession's REUSE check would otherwise see
|
|
1868
|
+
// slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
|
|
1869
|
+
// session that no longer matches pi's history. /compact in particular
|
|
1870
|
+
// triggers CC's autocompact-thrashing guard (issue #8). Force the next
|
|
1871
|
+
// call down the REBUILD path so CC sees the current history.
|
|
1872
|
+
const markRebuild = (event: string) => {
|
|
1873
|
+
if (sharedSession) {
|
|
1874
|
+
debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
|
|
1875
|
+
sharedSession = { ...sharedSession, needsRebuild: true };
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
pi.on("session_compact", (event) => markRebuild(`session_compact:${event.reason}:willRetry=${event.willRetry}`));
|
|
1879
|
+
pi.on("session_tree", () => markRebuild("session_tree"));
|
|
1880
|
+
|
|
1881
|
+
// --- Provider ---
|
|
1882
|
+
//
|
|
1883
|
+
// Guard against re-registration when the module is loaded multiple times
|
|
1884
|
+
// (e.g., when spawning subagents). The shared ModelRegistry would otherwise
|
|
1885
|
+
// overwrite the parent's streamSimple, breaking tool result delivery.
|
|
1886
|
+
// See ACTIVE_STREAM_SIMPLE_KEY for the full mechanism.
|
|
1887
|
+
|
|
1888
|
+
const g = globalThis as Record<symbol, any>;
|
|
1889
|
+
if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
|
|
1890
|
+
// First instance: store our streamSimple and register.
|
|
1891
|
+
g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
|
|
1892
|
+
pi.registerProvider(PROVIDER_ID, {
|
|
1893
|
+
baseUrl: "claude-bridge",
|
|
1894
|
+
apiKey: "not-used",
|
|
1895
|
+
api: "claude-bridge",
|
|
1896
|
+
models: registeredModels,
|
|
1897
|
+
// Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
|
|
1898
|
+
streamSimple: streamClaudeAgentSdk as any,
|
|
1899
|
+
});
|
|
1900
|
+
} else {
|
|
1901
|
+
// Subsequent instance (subagent session): skip registration entirely.
|
|
1902
|
+
// The subagent already has access to claude-bridge models via the shared
|
|
1903
|
+
// ModelRegistry from the parent's registration. Calls to those models
|
|
1904
|
+
// route through the parent's streamSimple via reentrant QueryContexts.
|
|
1905
|
+
debug(`provider: skipping re-registration, parent instance active (module=${moduleInstanceId})`);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// --- AskClaude tool ---
|
|
1909
|
+
|
|
1910
|
+
const askConf = config.askClaude;
|
|
1911
|
+
const allowFull = askConf?.allowFullMode !== false;
|
|
1912
|
+
const defaultMode = askConf?.defaultMode ?? "read";
|
|
1913
|
+
const defaultIsolated = askConf?.defaultIsolated ?? false;
|
|
1914
|
+
askClaudeToolName = askConf?.name ?? "AskClaude";
|
|
1915
|
+
|
|
1916
|
+
const modeValues = allowFull ? ["read", "full", "none"] as const : ["read", "none"] as const;
|
|
1917
|
+
let modeDesc = `"read" (default): questions about the codebase — review, analysis, explain. "none": general knowledge only (no file access).`;
|
|
1918
|
+
if (allowFull) modeDesc += ` "full": allows writing and bash execution (careful: runs without feedback to pi).`;
|
|
1919
|
+
|
|
1920
|
+
if (askConf?.enabled !== false) {
|
|
1921
|
+
const askClaudeParams = Type.Object({
|
|
1922
|
+
prompt: Type.String({ description: "The question or task for Claude Code. By default Claude sees the full conversation history. Don't research up front, let Claude explore." }),
|
|
1923
|
+
mode: Type.Optional(StringEnum(modeValues, { description: modeDesc })),
|
|
1924
|
+
model: Type.Optional(Type.String({ description: 'Claude model (e.g. "opus", "sonnet", "haiku", or full ID). Defaults to "opus".' })),
|
|
1925
|
+
thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, { description: "Thinking effort level. Omit to use Claude Code's default." })),
|
|
1926
|
+
isolated: Type.Optional(Type.Boolean({ description: "When true, Claude sees only this prompt (clean session). When false (default), Claude sees the full conversation history." })),
|
|
1927
|
+
});
|
|
1928
|
+
pi.registerTool<typeof askClaudeParams>({
|
|
1929
|
+
name: askConf?.name ?? "AskClaude",
|
|
1930
|
+
label: askConf?.label ?? "Ask Claude Code",
|
|
1931
|
+
description: askConf?.description ?? (allowFull ? DEFAULT_TOOL_DESCRIPTION_FULL : DEFAULT_TOOL_DESCRIPTION),
|
|
1932
|
+
parameters: askClaudeParams,
|
|
1933
|
+
renderCall(args, theme) {
|
|
1934
|
+
let text = theme.fg("mdLink", theme.bold("AskClaude "));
|
|
1935
|
+
const mode = args.mode ?? defaultMode;
|
|
1936
|
+
const tags: string[] = [];
|
|
1937
|
+
if (mode !== defaultMode) tags.push(`mode=${mode}`);
|
|
1938
|
+
if (args.model) tags.push(`model=${args.model}`);
|
|
1939
|
+
if (args.thinking) tags.push(`thinking=${args.thinking}`);
|
|
1940
|
+
if (args.isolated) tags.push("isolated");
|
|
1941
|
+
if (tags.length) text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
|
|
1942
|
+
const truncated = args.prompt.length > PREVIEW_MAX_CHARS ? args.prompt.substring(0, PREVIEW_MAX_CHARS) : args.prompt;
|
|
1943
|
+
const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
|
|
1944
|
+
text += theme.fg("muted", `"${lines.join("\n")}"`);
|
|
1945
|
+
if (args.prompt.length > PREVIEW_MAX_CHARS || args.prompt.split("\n").length > PREVIEW_MAX_LINES) text += theme.fg("dim", " …");
|
|
1946
|
+
return new Text(text, 0, 0);
|
|
1947
|
+
},
|
|
1948
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
1949
|
+
if (isPartial) {
|
|
1950
|
+
const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
|
|
1951
|
+
return new Text(theme.fg("mdLink", "◉ Claude Code ") + theme.fg("muted", status), 0, 0);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
const details = result.details as { prompt?: string; executionTime?: number; actions?: string; error?: boolean } | undefined;
|
|
1955
|
+
const body = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
1956
|
+
|
|
1957
|
+
let text = details?.error
|
|
1958
|
+
? theme.fg("error", "✗ Claude Code error")
|
|
1959
|
+
: theme.fg("mdLink", "✓ Claude Code");
|
|
1960
|
+
|
|
1961
|
+
if (details?.executionTime) text += ` ${theme.fg("dim", `${(details.executionTime / 1000).toFixed(1)}s`)}`;
|
|
1962
|
+
if (details?.actions) text += ` ${theme.fg("muted", details.actions)}`;
|
|
1963
|
+
|
|
1964
|
+
if (expanded) {
|
|
1965
|
+
if (details?.prompt) text += `\n${theme.fg("dim", `Prompt: ${details.prompt}`)}`;
|
|
1966
|
+
if (details?.prompt && body) text += `\n${theme.fg("dim", "─".repeat(40))}`;
|
|
1967
|
+
if (body) text += `\n${theme.fg("toolOutput", body)}`;
|
|
1968
|
+
} else {
|
|
1969
|
+
const truncated = body.length > PREVIEW_MAX_CHARS ? body.substring(0, PREVIEW_MAX_CHARS) : body;
|
|
1970
|
+
const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
|
|
1971
|
+
if (lines.length) text += `\n${theme.fg("toolOutput", lines.join("\n"))}`;
|
|
1972
|
+
if (body.length > PREVIEW_MAX_CHARS || body.split("\n").length > PREVIEW_MAX_LINES) text += `\n${theme.fg("dim", `… (${keyHint("app.tools.expand", "to expand")})`)}`;
|
|
1973
|
+
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
return new Text(text, 0, 0);
|
|
1977
|
+
},
|
|
1978
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1979
|
+
// Guard: circular delegation
|
|
1980
|
+
if (ctx.model?.baseUrl === "claude-bridge") {
|
|
1981
|
+
debug("askClaude: blocked circular delegation (active provider is claude-bridge)");
|
|
1982
|
+
return {
|
|
1983
|
+
content: [{ type: "text" as const, text: "Error: AskClaude cannot be used when the active provider is claude-bridge — you're already running through Claude Code." }],
|
|
1984
|
+
details: { error: true },
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
const mode = (params.mode ?? defaultMode) as "full" | "read" | "none";
|
|
1989
|
+
const isolated = params.isolated ?? defaultIsolated;
|
|
1990
|
+
const toolCalls = new Map<string, ToolCallState>();
|
|
1991
|
+
const start = Date.now();
|
|
1992
|
+
|
|
1993
|
+
const progressInterval = setInterval(() => {
|
|
1994
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
1995
|
+
const summary = buildActionSummary(toolCalls);
|
|
1996
|
+
const status = summary ? `${elapsed}s — ${summary}` : `${elapsed}s — working...`;
|
|
1997
|
+
onUpdate?.({
|
|
1998
|
+
content: [{ type: "text", text: status }],
|
|
1999
|
+
details: { prompt: params.prompt, executionTime: Date.now() - start },
|
|
2000
|
+
});
|
|
2001
|
+
}, 1000);
|
|
2002
|
+
|
|
2003
|
+
try {
|
|
2004
|
+
const result = await promptAndWait(params.prompt, mode, toolCalls, signal, {
|
|
2005
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
2006
|
+
appendSkills: askConf?.appendSkills,
|
|
2007
|
+
model: params.model,
|
|
2008
|
+
thinking: params.thinking,
|
|
2009
|
+
isolated,
|
|
2010
|
+
context: isolated ? undefined : buildSessionContext(ctx.sessionManager.getBranch()).messages as Context["messages"],
|
|
2011
|
+
});
|
|
2012
|
+
clearInterval(progressInterval);
|
|
2013
|
+
onUpdate?.({ content: [{ type: "text", text: "" }], details: {} });
|
|
2014
|
+
const executionTime = Date.now() - start;
|
|
2015
|
+
const actions = buildActionSummary(toolCalls);
|
|
2016
|
+
|
|
2017
|
+
const text = actions
|
|
2018
|
+
? `${result.responseText}\n\n[Claude Code actions: ${actions}]`
|
|
2019
|
+
: result.responseText;
|
|
2020
|
+
return {
|
|
2021
|
+
content: [{ type: "text" as const, text }],
|
|
2022
|
+
details: { prompt: params.prompt, executionTime, actions },
|
|
2023
|
+
};
|
|
2024
|
+
} catch (err) {
|
|
2025
|
+
clearInterval(progressInterval);
|
|
2026
|
+
debug(`askClaude error: mode=${mode}, model=${params.model ?? "default"}, isolated=${isolated}, elapsed=${((Date.now() - start) / 1000).toFixed(1)}s, error=`, err);
|
|
2027
|
+
const msg = errorMessage(err);
|
|
2028
|
+
return {
|
|
2029
|
+
content: [{ type: "text" as const, text: `Error: ${msg}` }],
|
|
2030
|
+
details: { prompt: params.prompt, executionTime: Date.now() - start, error: true },
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
},
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
}
|