pi-claude-agent-sdk 0.8.6 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -9
- package/package.json +8 -8
- package/src/child-env.ts +40 -1
- package/src/config.ts +3 -0
- package/src/index.ts +633 -224
- package/src/log-paths.ts +11 -0
- package/src/models.ts +116 -86
- package/src/prompt-capture.ts +104 -41
- package/src/query-state.ts +27 -0
- package/src/transcript.ts +74 -0
- package/src/usage.ts +53 -0
package/src/index.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import * as piAi from "@earendil-works/pi-ai";
|
|
1
|
+
import { createAssistantMessageEventStream, type AssistantMessage, type AssistantMessageEventStream, type Context, type ImageContent, type Model, type SimpleStreamOptions, type TextContent, type Tool, type UserMessage } from "@earendil-works/pi-ai";
|
|
3
2
|
import { getModels } from "@earendil-works/pi-ai/compat";
|
|
4
3
|
import { type ExtensionAPI, type ExtensionContext, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import { query, type EffortLevel, type SDKMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
6
5
|
import type { Base64ImageSource, ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
|
7
6
|
import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
|
|
8
7
|
import { appendFileSync, mkdirSync, realpathSync, statSync } from "fs";
|
|
9
|
-
import { homedir } from "os";
|
|
10
8
|
import { dirname, join } from "path";
|
|
11
9
|
import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
|
|
10
|
+
import { DEBUG_LOG_PATH, DIAG_LOG_PATH } from "./log-paths.js";
|
|
12
11
|
import { adaptiveThinkingAlwaysOn, applyLongContext, buildModels, claudeCodeModelId, thinkingBoundToPrefix, type LongContextSettings } from "./models.js";
|
|
13
12
|
import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
|
|
14
13
|
import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
|
|
@@ -17,28 +16,22 @@ import { QueryContext, ctx } from "./query-state.js";
|
|
|
17
16
|
import { makePromptStream, userMessage, type PromptStream } from "./prompt-stream.js";
|
|
18
17
|
import { claudeCodeSettings, loadConfig, markStartupNoticeShown, type Config } from "./config.js";
|
|
19
18
|
import {
|
|
20
|
-
getSharedPromptCaptures,
|
|
21
19
|
projectPromptCapture,
|
|
22
|
-
|
|
20
|
+
sharedPromptCaptures,
|
|
21
|
+
type PromptCapture,
|
|
23
22
|
} from "./prompt-capture.js";
|
|
24
23
|
import { collectCarriedAttachments, placeCarriedAttachments, type CarriedAttachment } from "./attachments.js";
|
|
25
24
|
import { createToolServer } from "./mcp-server.js";
|
|
26
25
|
import { CC_CHILD_ENV, resolveClaudeChildEnv, type AnthropicAuthRegistry } from "./child-env.js";
|
|
27
26
|
import { resolveClaudeCodeExecutable } from "./claude-executable.js";
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const _piAi = piAi as any;
|
|
31
|
-
const newAssistantMessageEventStream: () => AssistantMessageEventStream =
|
|
32
|
-
typeof _piAi.createAssistantMessageEventStream === "function"
|
|
33
|
-
? _piAi.createAssistantMessageEventStream
|
|
34
|
-
: () => new _piAi.AssistantMessageEventStream();
|
|
27
|
+
import { nonSystemMessages, toBridgeContext } from "./transcript.js";
|
|
28
|
+
import { updateUsage, type SdkUsage } from "./usage.js";
|
|
35
29
|
|
|
36
30
|
// --- Debug logging ---
|
|
37
|
-
// CLAUDE_BRIDGE_DEBUG=1 enables debug logging to
|
|
31
|
+
// CLAUDE_BRIDGE_DEBUG=1 enables debug logging to the bridge log in pi's agent
|
|
32
|
+
// dir (log-paths.ts), not a fixed ~/.pi/agent.
|
|
38
33
|
|
|
39
34
|
const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
|
|
40
|
-
const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
|
|
41
|
-
const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
|
|
42
35
|
|
|
43
36
|
// CLAUDE_BRIDGE_RECORD_STREAM=<path> appends every SDK message consumeQuery sees,
|
|
44
37
|
// one JSON object per line. Used by tests/lib/record-sdk-streams.mjs to capture
|
|
@@ -46,7 +39,6 @@ const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
|
|
|
46
39
|
// emitted rather than ones we imagined.
|
|
47
40
|
const RECORD_STREAM_PATH = process.env.CLAUDE_BRIDGE_RECORD_STREAM;
|
|
48
41
|
|
|
49
|
-
|
|
50
42
|
// Pi owns context files on the provider path, so Claude Code must not load its
|
|
51
43
|
// own on top: otherwise a project CLAUDE.md arrives twice, and the user's
|
|
52
44
|
// ~/.claude/CLAUDE.md — a persona written for a harness that is not the one
|
|
@@ -60,11 +52,10 @@ const RECORD_STREAM_PATH = process.env.CLAUDE_BRIDGE_RECORD_STREAM;
|
|
|
60
52
|
// while rules need their own. Managed/policy memory is not excludable by design.
|
|
61
53
|
const CLAUDE_MD_EXCLUDES = ["**/CLAUDE.md", "**/.claude/rules/**"];
|
|
62
54
|
|
|
63
|
-
// Ensure log
|
|
55
|
+
// Ensure the debug log directory exists when debug is enabled
|
|
64
56
|
if (DEBUG) {
|
|
65
57
|
try {
|
|
66
58
|
mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
|
|
67
|
-
mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
|
|
68
59
|
} catch {
|
|
69
60
|
// If directory creation fails, debug functions will throw on first use
|
|
70
61
|
}
|
|
@@ -88,7 +79,7 @@ function debug(...args: unknown[]) {
|
|
|
88
79
|
// Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
|
|
89
80
|
// CLI subprocess to write its own debug log to a file we choose, and also
|
|
90
81
|
// forward its stderr into our debug stream. Drops straight into the real SDK's
|
|
91
|
-
// Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:
|
|
82
|
+
// Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
|
|
92
83
|
// stderr). Without this, CC's internal view of the world is invisible to us
|
|
93
84
|
// and "No conversation found" / empty-error reports are unactionable.
|
|
94
85
|
let nextCliDebugSeq = 1;
|
|
@@ -111,34 +102,21 @@ function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string
|
|
|
111
102
|
};
|
|
112
103
|
}
|
|
113
104
|
|
|
114
|
-
/** Unconditional diagnostic dump — for "should never happen" paths
|
|
105
|
+
/** Unconditional diagnostic dump — for "should never happen" paths. Creates pi's agent
|
|
106
|
+
* dir itself: callers run inside streamSimple, where a missing dir must not throw. */
|
|
115
107
|
function diagDump(label: string, data: Record<string, unknown>) {
|
|
116
108
|
const ts = new Date().toISOString();
|
|
117
109
|
const entry = { ts, moduleInstanceId, label, ...data };
|
|
110
|
+
mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
|
|
118
111
|
appendFileSync(DIAG_LOG_PATH, JSON.stringify(entry) + "\n");
|
|
119
112
|
debug(`DIAG: ${label} (see ${DIAG_LOG_PATH})`);
|
|
120
113
|
}
|
|
121
114
|
|
|
122
115
|
// --- Constants ---
|
|
123
116
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// again. Without this guard, the subagent's call to registerProvider() would
|
|
128
|
-
// overwrite the parent's `streamSimple` function reference in the shared
|
|
129
|
-
// ModelRegistry. When the parent later delivers a tool result, it would call
|
|
130
|
-
// the subagent's `streamSimple` (which has empty state) instead of its own.
|
|
131
|
-
//
|
|
132
|
-
// By storing the active streamSimple in a Symbol.for() global (shared across all
|
|
133
|
-
// module instances), we ensure only the FIRST instance to register takes effect.
|
|
134
|
-
// Subsequent instances wrap the stored function instead of overwriting it.
|
|
135
|
-
//
|
|
136
|
-
// On session_shutdown (including /reload), clearSession() resets this so a fresh
|
|
137
|
-
// registration can occur for the next session.
|
|
138
|
-
//
|
|
139
|
-
// The prompt-capture table is shared the same way (PROMPT_CAPTURES_KEY): skipping
|
|
140
|
-
// re-registration is not enough when the first copy's before_agent_start handler
|
|
141
|
-
// is dropped and a second copy records into a different Map.
|
|
117
|
+
// Marks which bridge module instance owns the registered provider's stream fn.
|
|
118
|
+
// Full registration policy (first vs later instances, shared vs own registry):
|
|
119
|
+
// see the "--- Provider ---" block in activate() below.
|
|
142
120
|
const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
|
|
143
121
|
|
|
144
122
|
// MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
|
|
@@ -165,19 +143,25 @@ interface SessionState {
|
|
|
165
143
|
sessionId: string;
|
|
166
144
|
cursor: number;
|
|
167
145
|
cwd: string;
|
|
146
|
+
// The pi session this CC conversation serves, from the provider call's
|
|
147
|
+
// options.sessionId. Attribution for history rewrites: a subagent's
|
|
148
|
+
// session_compact must not force a rebuild of a conversation that belongs
|
|
149
|
+
// to a different pi session. Null until a provider call recorded it.
|
|
150
|
+
piSessionId?: string;
|
|
168
151
|
// Force the next syncSharedSession call down the REBUILD path. Set when
|
|
169
152
|
// pi has mutated its messages array out from under us (compact, tree
|
|
170
153
|
// navigation) or after an abort left the JSONL in an indeterminate state.
|
|
171
154
|
// REBUILD wipes and rewrites the file to match pi's current history.
|
|
172
155
|
needsRebuild?: boolean;
|
|
173
|
-
// Set ONLY
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// in
|
|
156
|
+
// Set ONLY where we have just killed a CC subprocess: an abort, or a query
|
|
157
|
+
// discarded because pi rewrote the history under it. The killed subprocess
|
|
158
|
+
// may still be flushing a late "[Request interrupted by user]" record to the
|
|
159
|
+
// session JSONL. Reusing the same sessionId/path would race that orphan write
|
|
160
|
+
// into our fresh file and break CC's parent-uuid chain on the next resume.
|
|
161
|
+
// When this flag is set, REBUILD takes a fresh UUID and skips deleteSession
|
|
162
|
+
// so the orphan writes land on a dead inode. A compact or tree navigation
|
|
163
|
+
// with no query in flight does NOT set this — there's no concurrent CC writer
|
|
164
|
+
// then, so in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
|
|
181
165
|
forceRotate?: boolean;
|
|
182
166
|
}
|
|
183
167
|
|
|
@@ -202,7 +186,105 @@ function readCarriedAttachments(sessionId: string, cwd: string): CarriedAttachme
|
|
|
202
186
|
}
|
|
203
187
|
}
|
|
204
188
|
|
|
205
|
-
|
|
189
|
+
/** The session key a pi session's provider calls address. Unattributed calls
|
|
190
|
+
* (no options.sessionId — a host that omits it)
|
|
191
|
+
* share the "(none)" bucket: they cannot be told apart, so they share the
|
|
192
|
+
* pre-existing single-slot semantics. */
|
|
193
|
+
function sessionKey(piSessionId: string | null | undefined): string {
|
|
194
|
+
return piSessionId ?? "(none)";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Mirror of the CC conversation one pi session's turns are running on. One
|
|
198
|
+
* entry per pi session: a bridge process serves several sessions at once
|
|
199
|
+
* (pi-subagents children run their own AgentSessions), and a single shared
|
|
200
|
+
* slot forced them to fight over it — a length-matching foreign sync could
|
|
201
|
+
* REUSE or rebuild another session's CC file, and the completion capture was
|
|
202
|
+
* last-writer-wins (a foreground child in the parent's first turn permanently
|
|
203
|
+
* reassigned the parent's conversation). Keyed lookup removes the fight: each
|
|
204
|
+
* session's reads, writes and teardown marks touch only its own mirror. */
|
|
205
|
+
const sharedSessions = new Map<string, SessionState>();
|
|
206
|
+
|
|
207
|
+
/** The mirror for `piSessionId`, or null when this session has none yet. */
|
|
208
|
+
function sessionStateFor(piSessionId: string | null | undefined): SessionState | null {
|
|
209
|
+
return sharedSessions.get(sessionKey(piSessionId)) ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Replace (or plant) the mirror for `piSessionId`. */
|
|
213
|
+
function setSessionStateFor(piSessionId: string | null | undefined, state: SessionState | null): void {
|
|
214
|
+
if (state === null) sharedSessions.delete(sessionKey(piSessionId));
|
|
215
|
+
else sharedSessions.set(sessionKey(piSessionId), state);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// pi replaced one of its sessions' history (compact, tree) rather than appending
|
|
219
|
+
// to it. Read on the tool-result path — the one provider call that never reaches
|
|
220
|
+
// syncSharedSession — so a query parked at a tool boundary is discarded rather
|
|
221
|
+
// than resumed (issue #101). Keyed by pi session id, not process-global: a bridge
|
|
222
|
+
// process serves several pi sessions at once (subagents run their own
|
|
223
|
+
// AgentSessions and can compact mid-run while the parent is parked), and
|
|
224
|
+
// marking across that boundary kills healthy queries (or, worse, rebuilds the
|
|
225
|
+
// parent around a compaction that never touched it).
|
|
226
|
+
//
|
|
227
|
+
// Holds real pi session ids only, never the "(none)" key: no production caller
|
|
228
|
+
// marks with null (the rewrite events attribute via ctx.sessionManager), and
|
|
229
|
+
// every reader guards on a non-null piSessionId — so a "(none)" entry could
|
|
230
|
+
// never be matched or consumed, only leaked.
|
|
231
|
+
const historyRewrittenBySession = new Set<string>();
|
|
232
|
+
|
|
233
|
+
/** Handlers that arm rewrite staleness, one per module instance. Worktree-
|
|
234
|
+
* spawned subagents can load this module fresh (pi's loader cache is keyed on
|
|
235
|
+
* cwd, and a worktree cwd clears it), while the serving instance — whose
|
|
236
|
+
* streamSimple the pi sessions actually call — is whoever registered first.
|
|
237
|
+
* A fresh instance must forward its session's rewrites to the serving one.
|
|
238
|
+
* Symbol.for: one registry per process, like SHARED_CAPTURES_KEY below. */
|
|
239
|
+
const MARK_REBUILD_HOOKS_KEY = Symbol.for("claude-bridge:markRebuildHooks");
|
|
240
|
+
type MarkRebuildHook = (piSession: string | null, event: string) => void;
|
|
241
|
+
const markRebuildHooks: Set<MarkRebuildHook> =
|
|
242
|
+
((globalThis as Record<symbol, unknown>)[MARK_REBUILD_HOOKS_KEY] as Set<MarkRebuildHook> | undefined) ?? new Set();
|
|
243
|
+
(globalThis as Record<symbol, unknown>)[MARK_REBUILD_HOOKS_KEY] = markRebuildHooks;
|
|
244
|
+
|
|
245
|
+
/** pi mutated its messages array out from under us: force the next
|
|
246
|
+
* syncSharedSession down REBUILD, and arm the discard above. `piSession`
|
|
247
|
+
* is never null from an event handler (each pi session has its own runner).
|
|
248
|
+
* The "(none)" key still covers the mirror for a hypothetical direct caller
|
|
249
|
+
* with no session id, so such a rewrite forces the REBUILD side; the discard
|
|
250
|
+
* set holds real ids only — see the comment on historyRewrittenBySession. */
|
|
251
|
+
function markRebuildForSession(piSession: string | null, event: string): void {
|
|
252
|
+
// The rewriting session's own mirror: the rewrite changed the history it was
|
|
253
|
+
// built from, so its next sync must REBUILD rather than REUSE. Every other
|
|
254
|
+
// session's mirror stays untouched — its conversation was never rewritten.
|
|
255
|
+
const key = sessionKey(piSession);
|
|
256
|
+
const state = sharedSessions.get(key);
|
|
257
|
+
if (!state) {
|
|
258
|
+
debug(`${event}: history rewritten, no session to mark yet`);
|
|
259
|
+
} else {
|
|
260
|
+
sharedSessions.set(key, { ...state, needsRebuild: true });
|
|
261
|
+
debug(`${event}: marking needsRebuild on session ${state.sessionId.slice(0, 8)}`);
|
|
262
|
+
}
|
|
263
|
+
// Arming parked contexts cannot wait for delivery: the entry checks
|
|
264
|
+
// `resultCtx.historyStale`, and a rewrite usually lands *while* the query is
|
|
265
|
+
// parked (compaction runs inside pi's turn loop, not between provider calls).
|
|
266
|
+
if (piSession) historyRewrittenBySession.add(piSession);
|
|
267
|
+
armStaleContexts();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Copy each armed session's mark onto the parked queries built from it. */
|
|
271
|
+
function armStaleContexts(): void {
|
|
272
|
+
for (const c of activeQueryContexts) {
|
|
273
|
+
if (c.piSessionId && historyRewrittenBySession.has(c.piSessionId)) c.historyStale = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// This instance enlists. Session ids reaching any hook equal options.sessionId
|
|
278
|
+
// on the serving instance's provider calls, so forwarding is safe: only the
|
|
279
|
+
// owning instance's contexts and SessionState match the key.
|
|
280
|
+
markRebuildHooks.add(markRebuildForSession);
|
|
281
|
+
|
|
282
|
+
/** Event handlers call this: it fans the rewrite out to every module instance
|
|
283
|
+
* in the process, of which exactly one is serving provider traffic for any
|
|
284
|
+
* given pi session. */
|
|
285
|
+
function sponsorMarkRebuildForSession(piSession: string | null, event: string): void {
|
|
286
|
+
for (const hook of markRebuildHooks) hook(piSession, event);
|
|
287
|
+
}
|
|
206
288
|
|
|
207
289
|
// Convert pi messages to Anthropic API format for session import.
|
|
208
290
|
// Lossy: only text, thinking and toolCall blocks survive, and thinking only when
|
|
@@ -408,7 +490,7 @@ function resultErrorText(message: SDKMessage): string | undefined {
|
|
|
408
490
|
*
|
|
409
491
|
* pi has no typed rate-limit error — `stopReason` is only ever `"error"` and the sole carrier
|
|
410
492
|
* is `errorMessage` — so everything that reacts to a rate limit pattern-matches that string:
|
|
411
|
-
* pi-subagents gates `fallbackModels` on
|
|
493
|
+
* pi-subagents gates `fallbackModels` on its own pattern list, and key-rotating extensions use
|
|
412
494
|
* their own. Claude Code words a subscription limit as "You're out of extra usage · resets
|
|
413
495
|
* 6:30pm", which matches none of them, so an exhausted quota reads as a fatal error and the
|
|
414
496
|
* fallback chain never runs (issue #58).
|
|
@@ -418,12 +500,12 @@ function resultErrorText(message: SDKMessage): string | undefined {
|
|
|
418
500
|
* failure and refuses to retry. */
|
|
419
501
|
function describeRateLimitFailure(rejection: { rateLimitType?: string; resetsAt?: number }, failure: string): string {
|
|
420
502
|
const kind = rejection.rateLimitType ? ` (${rejection.rateLimitType})` : "";
|
|
421
|
-
const resets = rejection.resetsAt ? ` — resets ${new Date(rejection.resetsAt * 1000).toLocaleTimeString()}` : "";
|
|
503
|
+
const resets = rejection.resetsAt ? ` — resets ${new Date(rejection.resetsAt * 1000).toLocaleTimeString()}` : ""; // resetsAt: Unix seconds (unit undocumented in the SDK; observed)
|
|
422
504
|
return `Claude rate limit${kind}${resets}: ${failure}`;
|
|
423
505
|
}
|
|
424
506
|
|
|
425
507
|
function standaloneStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
|
|
426
|
-
const stream =
|
|
508
|
+
const stream = createAssistantMessageEventStream();
|
|
427
509
|
void runStandaloneRequest(model, context, options, stream);
|
|
428
510
|
return stream;
|
|
429
511
|
}
|
|
@@ -450,9 +532,11 @@ async function runStandaloneRequest(
|
|
|
450
532
|
if (claudeExecutableResolution.error) throw new Error(claudeExecutableResolution.error);
|
|
451
533
|
const claudeExecutable = claudeExecutableResolution.path;
|
|
452
534
|
const cliModel = claudeCodeModelId(model, longContextSettings);
|
|
535
|
+
const mapped = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] : undefined;
|
|
453
536
|
const effort = options?.reasoning
|
|
454
|
-
?
|
|
455
|
-
|
|
537
|
+
? mapped === undefined
|
|
538
|
+
? REASONING_TO_EFFORT[options.reasoning]
|
|
539
|
+
: VALID_EFFORTS.has(mapped as EffortLevel) ? mapped as EffortLevel : undefined
|
|
456
540
|
: undefined;
|
|
457
541
|
const extraArgs: Record<string, string | null> = {};
|
|
458
542
|
if (effort || adaptiveThinkingAlwaysOn(model.id)) extraArgs["thinking-display"] = "summarized";
|
|
@@ -488,7 +572,7 @@ async function runStandaloneRequest(
|
|
|
488
572
|
let assistantText = "";
|
|
489
573
|
let finalText = "";
|
|
490
574
|
let errorText: string | undefined;
|
|
491
|
-
let resultUsage:
|
|
575
|
+
let resultUsage: SdkUsage | undefined;
|
|
492
576
|
let firstEventLogged = false;
|
|
493
577
|
|
|
494
578
|
for await (const message of sdkQuery) {
|
|
@@ -505,7 +589,7 @@ async function runStandaloneRequest(
|
|
|
505
589
|
} else if (message.type === "result") {
|
|
506
590
|
logServedContextWindow("standalone", message, model);
|
|
507
591
|
errorText = resultErrorText(message);
|
|
508
|
-
resultUsage = (message as SDKMessage & { usage?:
|
|
592
|
+
resultUsage = (message as SDKMessage & { usage?: SdkUsage }).usage;
|
|
509
593
|
if (!errorText && message.subtype === "success") finalText = message.result || assistantText;
|
|
510
594
|
}
|
|
511
595
|
}
|
|
@@ -528,7 +612,7 @@ async function runStandaloneRequest(
|
|
|
528
612
|
}
|
|
529
613
|
|
|
530
614
|
const output = newAssistantOutput(model, text, "stop");
|
|
531
|
-
if (resultUsage)
|
|
615
|
+
if (resultUsage) recordUsage(output, resultUsage, model);
|
|
532
616
|
debug(`standalone: done textLen=${text.length}`);
|
|
533
617
|
stream.push({ type: "done", reason: "stop", message: output });
|
|
534
618
|
stream.end();
|
|
@@ -624,13 +708,23 @@ function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void
|
|
|
624
708
|
//
|
|
625
709
|
// Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
|
|
626
710
|
// int-session-resume.mjs) keep grepping the same anchors.
|
|
627
|
-
function syncSharedSession(
|
|
711
|
+
function syncSharedSession(
|
|
628
712
|
messages: Context["messages"],
|
|
629
713
|
cwd: string,
|
|
630
714
|
customToolNameToSdk?: Map<string, string>,
|
|
631
715
|
modelId?: string,
|
|
716
|
+
piSessionId?: string | null,
|
|
632
717
|
): SyncResult {
|
|
633
|
-
|
|
718
|
+
// System messages are pi's transcript representation of prompt and tool state, not
|
|
719
|
+
// conversation history — they are never imported into a CC session, so exclude them from
|
|
720
|
+
// the history space (priorMessages, cursor, missed) everywhere below (issue #106).
|
|
721
|
+
// The mirror this sync coordinates belongs to the syncing pi session alone:
|
|
722
|
+
// every read and write below addresses sessionStateFor(piSessionId), so a
|
|
723
|
+
// foreign session's shape-matching context can never REUSE or rebuild another
|
|
724
|
+
// session's CC file.
|
|
725
|
+
const sharedSession = sessionStateFor(piSessionId);
|
|
726
|
+
const history = nonSystemMessages(messages);
|
|
727
|
+
const priorMessages = history.slice(0, turnStart(history)); // everything before the current user turn
|
|
634
728
|
|
|
635
729
|
// REUSE path
|
|
636
730
|
//
|
|
@@ -644,23 +738,27 @@ function syncSharedSession(
|
|
|
644
738
|
const trailingAssistantOnly =
|
|
645
739
|
missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
|
|
646
740
|
if (missed.length === 0 || trailingAssistantOnly) {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
debug(`
|
|
652
|
-
|
|
741
|
+
if (trailingAssistantOnly) {
|
|
742
|
+
setSessionStateFor(piSessionId, { ...sharedSession, cursor: priorMessages.length, cwd });
|
|
743
|
+
debug(`Case 3: advanced cursor past trailing assistant, resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${priorMessages.length}`);
|
|
744
|
+
} else {
|
|
745
|
+
debug(`Case 3: resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
|
|
746
|
+
}
|
|
747
|
+
debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession?.cursor}`);
|
|
748
|
+
return { sessionId: sharedSession.sessionId };
|
|
653
749
|
}
|
|
654
750
|
}
|
|
655
|
-
// This is what keeps a
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
// the
|
|
660
|
-
//
|
|
751
|
+
// This is what keeps a caller with a pruned or short context from resuming
|
|
752
|
+
// — then overwriting — the bucket's session: shorter-than-cursor means the
|
|
753
|
+
// incoming history cannot be a continuation, so start clean and preserve.
|
|
754
|
+
// Historically this also caught reentrant subagents (a subagent's priors are
|
|
755
|
+
// shorter than the parent's cursor); with per-session mirrors it now catches
|
|
756
|
+
// the pruned-context shapes on a session's own bucket, and the non-isolated
|
|
757
|
+
// callers without a session id on the "(none)" bucket. The captured ephemeral session is
|
|
758
|
+
// deleted once its query completes (see preserveSharedSession in the
|
|
759
|
+
// completion handler).
|
|
661
760
|
//
|
|
662
|
-
//
|
|
663
|
-
// syncSharedSession at all.
|
|
761
|
+
// Standalone completions never call syncSharedSession.
|
|
664
762
|
//
|
|
665
763
|
// Only reachable when needsRebuild is false — user-facing history rewrites
|
|
666
764
|
// (/compact, session_tree, /new, fork) always set needsRebuild or clear
|
|
@@ -673,7 +771,7 @@ function syncSharedSession(
|
|
|
673
771
|
|
|
674
772
|
// REBUILD path
|
|
675
773
|
if (priorMessages.length === 0) {
|
|
676
|
-
debug(`Case 1: clean start, ${
|
|
774
|
+
debug(`Case 1: clean start, ${history.length} total messages`);
|
|
677
775
|
debug(`syncResult: path=clean-start`);
|
|
678
776
|
return { sessionId: null };
|
|
679
777
|
}
|
|
@@ -681,8 +779,8 @@ function syncSharedSession(
|
|
|
681
779
|
const previousCursor = sharedSession?.cursor ?? 0;
|
|
682
780
|
// preserveId: rebuild in place (deleteSession + createSession with the
|
|
683
781
|
// existing UUID), so prompt-cache UUIDs stay stable for log correlation
|
|
684
|
-
// and for any tools that key off them. Skipped
|
|
685
|
-
//
|
|
782
|
+
// and for any tools that key off them. Skipped when there's a concurrent
|
|
783
|
+
// writer we shouldn't race (forceRotate).
|
|
686
784
|
const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
|
|
687
785
|
// Before deleteSession — it wipes the file these live in.
|
|
688
786
|
const carried = previousSessionId !== undefined ? readCarriedAttachments(previousSessionId, cwd) : [];
|
|
@@ -701,7 +799,7 @@ function syncSharedSession(
|
|
|
701
799
|
// records, not messages: `messages` filters out the attachment records that
|
|
702
800
|
// carrying an `@file` expansion across a rebuild writes into the same file.
|
|
703
801
|
verifyWrittenSession(session.jsonlPath, session.sessionId, session.records.length, cwd);
|
|
704
|
-
|
|
802
|
+
setSessionStateFor(piSessionId, { sessionId: session.sessionId, cursor: priorMessages.length, cwd, piSessionId: piSessionId ?? undefined });
|
|
705
803
|
if (previousSessionId === undefined) {
|
|
706
804
|
debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.records.length} records`);
|
|
707
805
|
} else if (preserveId) {
|
|
@@ -715,20 +813,45 @@ function syncSharedSession(
|
|
|
715
813
|
return { sessionId: session.sessionId };
|
|
716
814
|
}
|
|
717
815
|
|
|
816
|
+
// The SDK's query(), or a test double (see setQuery). The compact/summary
|
|
817
|
+
// path calls the real query() directly — its subprocess must never be swapped
|
|
818
|
+
// out from under a real compaction.
|
|
819
|
+
let queryImpl: typeof query = query;
|
|
820
|
+
|
|
718
821
|
// @internal
|
|
719
822
|
export const __test = {
|
|
720
|
-
|
|
721
|
-
|
|
823
|
+
setQuery(fn: typeof query | null) {
|
|
824
|
+
queryImpl = fn ?? query;
|
|
722
825
|
},
|
|
723
|
-
|
|
724
|
-
|
|
826
|
+
resetSharedSession(piSessionId?: string | null) {
|
|
827
|
+
// No id: full reset (the pre-map semantics — tests start from a blank slate).
|
|
828
|
+
if (piSessionId === undefined) sharedSessions.clear();
|
|
829
|
+
else setSessionStateFor(piSessionId, null);
|
|
830
|
+
historyRewrittenBySession.clear();
|
|
725
831
|
},
|
|
726
|
-
|
|
727
|
-
|
|
832
|
+
markRebuildForSession,
|
|
833
|
+
getHistoryRewritten: () => historyRewrittenBySession.size > 0,
|
|
834
|
+
historyRewrittenBySession,
|
|
835
|
+
armStaleContexts,
|
|
836
|
+
discardRewrittenQuery,
|
|
837
|
+
contextForToolResults,
|
|
838
|
+
isQueryAbandoned: (q: object) => abandonedQueries.has(q),
|
|
839
|
+
get activeQueryContexts() {
|
|
840
|
+
return activeQueryContexts;
|
|
841
|
+
},
|
|
842
|
+
setSharedSession(piSessionId: string | null, state: SessionState | null) {
|
|
843
|
+
setSessionStateFor(piSessionId, state);
|
|
844
|
+
},
|
|
845
|
+
getSharedSession(piSessionId: string | null = null) {
|
|
846
|
+
return sessionStateFor(piSessionId);
|
|
847
|
+
},
|
|
848
|
+
setPiModelRegistry(registry: AnthropicAuthRegistry | null) {
|
|
849
|
+
piModelRegistry = registry;
|
|
728
850
|
},
|
|
729
851
|
setPiUI(ui: ExtensionUIContext | null) {
|
|
730
852
|
piUI = ui;
|
|
731
853
|
},
|
|
854
|
+
toBridgeContext,
|
|
732
855
|
syncSharedSession,
|
|
733
856
|
extractUserPromptBlocks,
|
|
734
857
|
consumeQuery,
|
|
@@ -740,6 +863,9 @@ export const __test = {
|
|
|
740
863
|
buildMcpServers,
|
|
741
864
|
isStandaloneRequest,
|
|
742
865
|
extractStandalonePrompt,
|
|
866
|
+
get promptCaptures() {
|
|
867
|
+
return promptCaptures;
|
|
868
|
+
},
|
|
743
869
|
};
|
|
744
870
|
|
|
745
871
|
// --- Provider helpers: tool name mapping ---
|
|
@@ -794,11 +920,11 @@ let piMode: ExtensionContext["mode"] | null = null;
|
|
|
794
920
|
let piModelRegistry: AnthropicAuthRegistry | null = null;
|
|
795
921
|
const activeQueryContexts = new Set<QueryContext>();
|
|
796
922
|
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
923
|
+
// The assumed Max plan is announced once. Deferred to the first bridge query rather
|
|
924
|
+
// than session_start: the notice persists a flag to the global config, and
|
|
925
|
+
// firing it on startup would write that file for every pi session that merely
|
|
926
|
+
// has this extension installed. One message, because consecutive info notifies
|
|
927
|
+
// overwrite each other in the TUI.
|
|
802
928
|
let pendingNotices: string[] = [];
|
|
803
929
|
|
|
804
930
|
function showStartupNoticeOnce(): void {
|
|
@@ -816,10 +942,10 @@ function showStartupNoticeOnce(): void {
|
|
|
816
942
|
}
|
|
817
943
|
|
|
818
944
|
// Captures of what pi assembled per agent; see src/prompt-capture.ts for why this
|
|
819
|
-
// is keyed rather than held in a single slot.
|
|
820
|
-
//
|
|
821
|
-
// the
|
|
822
|
-
const promptCaptures =
|
|
945
|
+
// is keyed rather than held in a single slot. One process-wide instance, shared
|
|
946
|
+
// across every extension module instance: isolated subagents re-evaluate this
|
|
947
|
+
// module, and the pinned stream they all route through resolves against it.
|
|
948
|
+
const promptCaptures = sharedPromptCaptures((diagnostic) => {
|
|
823
949
|
const first = diagnostic.matches[0];
|
|
824
950
|
debug(
|
|
825
951
|
`prompt-capture: no match for ${diagnostic.systemPrompt.length}-char system prompt. `
|
|
@@ -829,7 +955,7 @@ const promptCaptures = getSharedPromptCaptures(() => new PromptCaptures(256, (di
|
|
|
829
955
|
: "no known captures to compare against."
|
|
830
956
|
) + ` known keys=${diagnostic.matches.length}`,
|
|
831
957
|
);
|
|
832
|
-
})
|
|
958
|
+
});
|
|
833
959
|
|
|
834
960
|
/** Whatever a settled session left behind, named in one greppable line.
|
|
835
961
|
*
|
|
@@ -920,19 +1046,11 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
|
|
|
920
1046
|
|
|
921
1047
|
// --- Usage helpers ---
|
|
922
1048
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
// Claude Code may report reasoning/thinking tokens separately, while pi's Usage type does not model that field.
|
|
929
|
-
const reasoning = usage.reasoning_tokens ?? usage.thinking_tokens;
|
|
930
|
-
if (reasoning != null) (output.usage as typeof output.usage & { reasoning?: number }).reasoning = reasoning;
|
|
931
|
-
output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
932
|
-
calculateCost(model, output.usage);
|
|
933
|
-
const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
|
|
934
|
-
const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
|
|
935
|
-
const reasoningText = reasoning != null ? ` reasoning=${reasoning}` : "";
|
|
1049
|
+
// The counter mapping lives in usage.ts; the debug line is this side's job, so every
|
|
1050
|
+
// call site logs the same way rather than three times over.
|
|
1051
|
+
function recordUsage(output: AssistantMessage, usage: SdkUsage, model: Model<any>): void {
|
|
1052
|
+
const { cachePct, reasoning } = updateUsage(output, usage, model);
|
|
1053
|
+
const reasoningText = reasoning == null ? "" : ` reasoning=${reasoning}`;
|
|
936
1054
|
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}`);
|
|
937
1055
|
}
|
|
938
1056
|
|
|
@@ -957,6 +1075,8 @@ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
|
|
|
957
1075
|
minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
|
|
958
1076
|
};
|
|
959
1077
|
|
|
1078
|
+
const VALID_EFFORTS = new Set<string>(["low", "medium", "high", "xhigh", "max"]);
|
|
1079
|
+
|
|
960
1080
|
// --- Provider helpers: misc ---
|
|
961
1081
|
|
|
962
1082
|
function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
|
|
@@ -1037,8 +1157,14 @@ function processStreamEvent(
|
|
|
1037
1157
|
const event = (message as SDKMessage & { event: any }).event;
|
|
1038
1158
|
|
|
1039
1159
|
if (event?.type === "message_start") {
|
|
1160
|
+
// Still open from an earlier message_start: Claude Code gave up on that
|
|
1161
|
+
// stream and is retrying it. Its blocks were never completed.
|
|
1162
|
+
if (c.turnStreamOpen) dropAbandonedStreamBlocks(c, `restreamed as ${event.message?.id}`);
|
|
1040
1163
|
c.turnToolCallIds = [];
|
|
1041
|
-
|
|
1164
|
+
c.turnStreamMessageId = event.message?.id;
|
|
1165
|
+
c.turnStreamOpen = true;
|
|
1166
|
+
c.turnStreamBlockStart = c.turnBlocks.length;
|
|
1167
|
+
if (event.message?.usage) recordUsage(c.turnOutput, event.message.usage, model);
|
|
1042
1168
|
return;
|
|
1043
1169
|
}
|
|
1044
1170
|
|
|
@@ -1115,10 +1241,12 @@ function processStreamEvent(
|
|
|
1115
1241
|
|
|
1116
1242
|
if (event?.type === "message_delta") {
|
|
1117
1243
|
c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
|
|
1118
|
-
if (event.usage)
|
|
1244
|
+
if (event.usage) recordUsage(c.turnOutput, event.usage, model);
|
|
1119
1245
|
return;
|
|
1120
1246
|
}
|
|
1121
1247
|
|
|
1248
|
+
if (event?.type === "message_stop") c.turnStreamOpen = false;
|
|
1249
|
+
|
|
1122
1250
|
if (event?.type === "message_stop" && c.turnSawToolCall) {
|
|
1123
1251
|
// Tool call complete — end this pi stream. The SDK will still yield an
|
|
1124
1252
|
// assistant message for this turn, but currentPiStream=null causes
|
|
@@ -1141,16 +1269,46 @@ function processStreamEvent(
|
|
|
1141
1269
|
}
|
|
1142
1270
|
}
|
|
1143
1271
|
|
|
1272
|
+
/** Remove the blocks a stream Claude Code abandoned mid-message. They never got a
|
|
1273
|
+
* message_stop, so a thinking block has no signature and a tool call is one CC will
|
|
1274
|
+
* never dispatch; left in, pi would run the tool and the turn would wait on a
|
|
1275
|
+
* handler that never comes, or the next request would replay a broken block.
|
|
1276
|
+
* The fallback then restarts those indices. pi's normal provider path tolerates that;
|
|
1277
|
+
* pi-agent-core's experimental harness frame encoder keys blocks by contentIndex and
|
|
1278
|
+
* rejects a repeated start, so it would need a change there to drive this provider. */
|
|
1279
|
+
function dropAbandonedStreamBlocks(c: QueryContext, why: string): void {
|
|
1280
|
+
const dropped = c.turnBlocks.splice(c.turnStreamBlockStart);
|
|
1281
|
+
debug(`dropAbandonedStreamBlocks: ${why}; dropped ${dropped.length} blocks from ${c.turnStreamMessageId} types=${dropped.map((b: any) => b.type).join(",")}`);
|
|
1282
|
+
c.turnToolCallIds = [];
|
|
1283
|
+
c.turnSawToolCall = c.turnBlocks.some((b: any) => b.type === "toolCall");
|
|
1284
|
+
c.turnStreamOpen = false;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1144
1287
|
// The SDK always yields `assistant` messages (completed content blocks) after streaming.
|
|
1145
1288
|
// When stream_events already delivered the content, this is a no-op. But after
|
|
1146
1289
|
// resetTurnState (e.g. tool result delivery), if the next turn's assistant message
|
|
1147
1290
|
// arrives before any stream_events, this is the primary content path. Must maintain
|
|
1148
1291
|
// the same stream lifecycle as processStreamEvent — including ending the stream on
|
|
1149
1292
|
// tool_use to prevent deadlock with the MCP handler.
|
|
1293
|
+
//
|
|
1294
|
+
// It is also the content path when a stream stalls: Claude Code drops it and asks
|
|
1295
|
+
// again without streaming ("Error streaming, falling back to non-streaming mode"),
|
|
1296
|
+
// and the answer arrives as one assistant message, under a new message id, with no
|
|
1297
|
+
// stream_events of its own. turnSawStreamEvent is already set by the dead stream,
|
|
1298
|
+
// so gating on it alone dropped that message: its tool calls never reached pi, CC
|
|
1299
|
+
// sat in the MCP handler waiting for their results, and the turn hung on "Working"
|
|
1300
|
+
// until the user aborted it.
|
|
1150
1301
|
function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext): void {
|
|
1151
|
-
if (c.turnSawStreamEvent) return;
|
|
1152
1302
|
const assistantMsg = (message as any).message;
|
|
1153
1303
|
if (!assistantMsg?.content) return;
|
|
1304
|
+
if (c.turnSawStreamEvent) {
|
|
1305
|
+
// Same id was already delivered; a new id is CC's non-streaming fallback.
|
|
1306
|
+
// Drop the stalled stream's partial blocks if it never stopped. Deliberately
|
|
1307
|
+
// deliver even if it stopped, at the risk of duplication if CC renumbers it.
|
|
1308
|
+
const id = assistantMsg.id;
|
|
1309
|
+
if (!id || !c.turnStreamMessageId || id === c.turnStreamMessageId) return;
|
|
1310
|
+
if (c.turnStreamOpen) dropAbandonedStreamBlocks(c, `non-streaming fallback ${id}`);
|
|
1311
|
+
}
|
|
1154
1312
|
c.turnToolCallIds = [];
|
|
1155
1313
|
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
|
|
1156
1314
|
for (const block of assistantMsg.content) {
|
|
@@ -1190,7 +1348,7 @@ function processAssistantMessage(message: SDKMessage, model: Model<any>, customT
|
|
|
1190
1348
|
debug("processAssistantMessage: unhandled block type", block.type);
|
|
1191
1349
|
}
|
|
1192
1350
|
}
|
|
1193
|
-
if (assistantMsg.usage && c.turnOutput)
|
|
1351
|
+
if (assistantMsg.usage && c.turnOutput) recordUsage(c.turnOutput, assistantMsg.usage, model);
|
|
1194
1352
|
|
|
1195
1353
|
// End the stream on tool_use, same as processStreamEvent's message_stop handler.
|
|
1196
1354
|
if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
|
|
@@ -1344,10 +1502,11 @@ function steerBlocks(messages: Context["messages"]): ContentBlockParam[] | null
|
|
|
1344
1502
|
/** A steer that never made it into CC's session. The cursor has already counted
|
|
1345
1503
|
* it, so count-based sync would skip it forever — rebuild instead, which
|
|
1346
1504
|
* re-imports the message from pi's context. */
|
|
1347
|
-
function steerMissedSession(text: string): void {
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1505
|
+
function steerMissedSession(c: QueryContext, text: string): void {
|
|
1506
|
+
c.missedSteer = true;
|
|
1507
|
+
const state = sessionStateFor(c.piSessionId);
|
|
1508
|
+
if (state) setSessionStateFor(c.piSessionId, { ...state, needsRebuild: true });
|
|
1509
|
+
debug(`provider: steer never reached CC, marked query for rebuild: ${text.slice(0, 60)}`);
|
|
1351
1510
|
}
|
|
1352
1511
|
|
|
1353
1512
|
/** Releases this turn's tool results to their MCP handlers, after first pushing
|
|
@@ -1373,7 +1532,7 @@ async function deliverToolResults(
|
|
|
1373
1532
|
const text = steer.map((b) => (b.type === "text" ? b.text : "[image]")).join("\n");
|
|
1374
1533
|
if (!c.promptStream) {
|
|
1375
1534
|
debug(`WARNING: steer with no prompt stream, dropping: ${text.slice(0, 60)}`);
|
|
1376
|
-
steerMissedSession(text);
|
|
1535
|
+
steerMissedSession(c, text);
|
|
1377
1536
|
} else {
|
|
1378
1537
|
try {
|
|
1379
1538
|
await c.promptStream.push(userMessage(steer, "next"));
|
|
@@ -1384,7 +1543,7 @@ async function deliverToolResults(
|
|
|
1384
1543
|
// pi's context, and the caller has already advanced the session
|
|
1385
1544
|
// cursor past it, so force a rebuild or CC would never see it.
|
|
1386
1545
|
debug(`provider: steer push rejected, delivering tool result anyway:`, error);
|
|
1387
|
-
steerMissedSession(text);
|
|
1546
|
+
steerMissedSession(c, text);
|
|
1388
1547
|
}
|
|
1389
1548
|
}
|
|
1390
1549
|
}
|
|
@@ -1422,25 +1581,100 @@ function drainForAbort(c: QueryContext, promptStream: PromptStream): void {
|
|
|
1422
1581
|
c.releasePendingToolCalls("Operation aborted");
|
|
1423
1582
|
}
|
|
1424
1583
|
|
|
1425
|
-
/**
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1584
|
+
/** Queries pi's history moved out from under. Their completion must not touch
|
|
1585
|
+
* `sharedSession` or the pi stream: the query that took over the turn has
|
|
1586
|
+
* already rebuilt both from the new history, and this one's session id names the
|
|
1587
|
+
* conversation pi just discarded. */
|
|
1588
|
+
const abandonedQueries = new WeakSet<object>();
|
|
1589
|
+
|
|
1590
|
+
/** The prompt a continuation query is opened with: the pi turn goes on, but its
|
|
1591
|
+
* last message is a tool result rather than a prompt, and query() cannot resume
|
|
1592
|
+
* a session without one. */
|
|
1593
|
+
const CONTINUE_AFTER_REWRITE_PROMPT =
|
|
1594
|
+
"[Your context was compacted. What precedes this is a summary plus the most recent messages, "
|
|
1595
|
+
+ "ending with the tool result you were waiting for. Continue the task from there.]";
|
|
1596
|
+
|
|
1597
|
+
/** Drop a Claude Code query parked at a tool boundary whose conversation pi has
|
|
1598
|
+
* since rewritten (/compact, tree navigation).
|
|
1599
|
+
*
|
|
1600
|
+
* Delivering the turn's tool result into that query hands Claude Code the
|
|
1601
|
+
* context pi just shrank: one pi turn is one CC query, and the query keeps its
|
|
1602
|
+
* own context inside the CLI whatever pi does to its transcript. It answers off
|
|
1603
|
+
* the pre-compaction conversation, reports the pre-compaction usage back, and pi
|
|
1604
|
+
* crosses the same threshold at the next boundary — measured as one compaction
|
|
1605
|
+
* per tool call with usage never dropping (issue #101). `needsRebuild` does not
|
|
1606
|
+
* prevent it: only syncSharedSession reads that flag, and tool-result delivery
|
|
1607
|
+
* is the one call that never syncs.
|
|
1608
|
+
*
|
|
1609
|
+
* The caller then takes the fresh-query path, where REBUILD imports pi's
|
|
1610
|
+
* rewritten history — this tool result included, since it is already in that
|
|
1611
|
+
* history — so the turn continues instead of ending here. Nothing is lost by
|
|
1612
|
+
* killing the subprocess: pi owns the only copy of the conversation that counts. */
|
|
1613
|
+
function discardRewrittenQuery(c: QueryContext): void {
|
|
1614
|
+
const discarded = c.activeQuery as { interrupt?: () => Promise<unknown>; close?: () => void } | null;
|
|
1615
|
+
if (discarded) abandonedQueries.add(discarded);
|
|
1616
|
+
c.activeQuery = null;
|
|
1617
|
+
// Leaving the routing set is what stops this result coming straight back here:
|
|
1618
|
+
// contextForToolResults only matches ids against contexts still in it.
|
|
1619
|
+
activeQueryContexts.delete(c);
|
|
1620
|
+
c.turnToolCallIds = [];
|
|
1621
|
+
c.promptStream?.fail(new Error("conversation rewritten"));
|
|
1622
|
+
c.promptStream = null;
|
|
1623
|
+
// Settle the parked handlers before killing the CLI, for drainForAbort's
|
|
1624
|
+
// reason: one left awaiting a dead subprocess never settles.
|
|
1625
|
+
c.releasePendingToolCalls("Context was compacted; this query was discarded.");
|
|
1626
|
+
void discarded?.interrupt?.().catch(() => {});
|
|
1627
|
+
try { discarded?.close?.(); } catch {}
|
|
1628
|
+
// The CLI we just killed may still flush a record into the session JSONL, and
|
|
1629
|
+
// the rebuild is the next thing that happens — so rotate rather than race it,
|
|
1630
|
+
// exactly as after an abort. Only this session's mirror: the discarding query
|
|
1631
|
+
// proves its own conversation is the one being rebuilt around.
|
|
1632
|
+
const state = sessionStateFor(c.piSessionId);
|
|
1633
|
+
if (state) setSessionStateFor(c.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
|
|
1634
|
+
if (c.piSessionId) historyRewrittenBySession.delete(c.piSessionId);
|
|
1635
|
+
debug("provider: history rewritten under a parked query — discarded it, rebuilding from current history");
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/** Provider entry point. Pi calls this for each new prompt and each tool result.
|
|
1639
|
+
* Two cases: tool result delivery (active query) or fresh query. */
|
|
1428
1640
|
function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
|
|
1641
|
+
// pi hands providers a transcript (prompt/tools folded into system messages) — fold it
|
|
1642
|
+
// back out to the prompt/tools fields every cursor write, syncSharedSession call and
|
|
1643
|
+
// prompt-capture lookup below assumes (issue #106).
|
|
1644
|
+
context = toBridgeContext(context);
|
|
1645
|
+
|
|
1646
|
+
// Normalize transcript-shaped summaries before recognizing the tool-free no-cache
|
|
1647
|
+
// call. Keep nested extension completions and summaries out of live session state.
|
|
1429
1648
|
if (isStandaloneRequest(context, options)) {
|
|
1430
|
-
debug(
|
|
1649
|
+
debug("provider: routing standalone cacheRetention=none request to isolated subprocess");
|
|
1431
1650
|
return standaloneStreamFn(model, context, options);
|
|
1432
1651
|
}
|
|
1433
1652
|
|
|
1434
1653
|
showStartupNoticeOnce();
|
|
1435
|
-
const stream =
|
|
1654
|
+
const stream = createAssistantMessageEventStream();
|
|
1436
1655
|
|
|
1437
1656
|
// DEBUG: trace followUp message triggering
|
|
1438
1657
|
const lastMsgRole = context.messages[context.messages.length - 1]?.role;
|
|
1439
1658
|
debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
|
|
1440
1659
|
|
|
1441
|
-
|
|
1660
|
+
let activeQuery = ctx().activeQuery !== null;
|
|
1442
1661
|
const allResults = activeQueryContexts.size > 0 ? extractAllToolResults(context) : [];
|
|
1443
|
-
|
|
1662
|
+
let resultCtx = allResults.length > 0 ? contextForToolResults(allResults) : undefined;
|
|
1663
|
+
|
|
1664
|
+
// pi rewrote its history while this query sat parked at a tool boundary, so the
|
|
1665
|
+
// query answers about a conversation that no longer exists. Discard it and let
|
|
1666
|
+
// this tool result carry the turn into a fresh query over the rewritten history.
|
|
1667
|
+
// The staleness mark is per pi session: a subagent's compaction (its own
|
|
1668
|
+
// AgentSession, sharing this process) must not discard the parent's parked
|
|
1669
|
+
// query, and vice versa.
|
|
1670
|
+
const rewrittenUnderQuery = Boolean(resultCtx?.historyStale);
|
|
1671
|
+
if (resultCtx && rewrittenUnderQuery) {
|
|
1672
|
+
discardRewrittenQuery(resultCtx);
|
|
1673
|
+
resultCtx = undefined;
|
|
1674
|
+
// Recomputed, not cleared: a reentrant subagent may still hold a query of its own.
|
|
1675
|
+
activeQuery = ctx().activeQuery !== null;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1444
1678
|
const isReentrantUserQuery = activeQuery && lastMsgRole === "user" && allResults.length === 0;
|
|
1445
1679
|
if (isReentrantUserQuery) {
|
|
1446
1680
|
debug(`provider: active query user-only call treated as reentrant fresh query, waitingHandlers=${ctx().pendingToolCalls.size}, ctx.msgs=${context.messages.length}`);
|
|
@@ -1453,6 +1687,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1453
1687
|
if (resultCtx) {
|
|
1454
1688
|
claimCurrentPiStream(stream, "tool-result", resultCtx);
|
|
1455
1689
|
resultCtx.resetTurnState(model);
|
|
1690
|
+
// A rewrite that armed the mark after this query parked gets copied here,
|
|
1691
|
+
// though markRebuildForSession usually reaches it directly.
|
|
1692
|
+
if (!resultCtx.historyStale && resultCtx.piSessionId && historyRewrittenBySession.has(resultCtx.piSessionId)) {
|
|
1693
|
+
resultCtx.historyStale = true;
|
|
1694
|
+
}
|
|
1456
1695
|
// User messages (steer/followUp) pi injected into context during the
|
|
1457
1696
|
// active query: a steer sent while a tool was executing, drained by pi at
|
|
1458
1697
|
// the turn boundary and appended alongside the tool result.
|
|
@@ -1465,18 +1704,26 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1465
1704
|
// delivering its own results would drag it to that subagent's message count
|
|
1466
1705
|
// — observed pulling a parent from 5 back to 3, which cost the parent's next
|
|
1467
1706
|
// turn a full rebuild and a flushed prompt cache.
|
|
1468
|
-
|
|
1707
|
+
const state = sessionStateFor(resultCtx.piSessionId);
|
|
1708
|
+
if (state) state.cursor = context.messages.length;
|
|
1469
1709
|
resultCtx.latestCursor = Math.max(resultCtx.latestCursor, context.messages.length);
|
|
1470
1710
|
return stream;
|
|
1471
1711
|
}
|
|
1472
1712
|
|
|
1473
1713
|
// --- Orphaned tool result (e.g. user aborted a tool call) ---
|
|
1474
1714
|
// The query is gone but pi still delivered the result. Nothing to do — just
|
|
1475
|
-
// emit end_turn so pi waits for the next real user message.
|
|
1715
|
+
// emit end_turn so pi waits for the next real user message. The discard
|
|
1716
|
+
// branch above already siphoned off the stale-query case, which goes on to a
|
|
1717
|
+
// rebuild instead — that one has somewhere to deliver the result to.
|
|
1476
1718
|
const lastMsg = context.messages[context.messages.length - 1];
|
|
1477
|
-
if (lastMsg?.role === "toolResult") {
|
|
1719
|
+
if (lastMsg?.role === "toolResult" && !rewrittenUnderQuery) {
|
|
1478
1720
|
debug(`provider: orphaned tool result after abort, emitting end_turn`);
|
|
1479
|
-
|
|
1721
|
+
// With no query in flight anywhere, the top-level session this result
|
|
1722
|
+
// belongs to is the one whose turn just ended: its cursor advances to
|
|
1723
|
+
// count the result (options.sessionId is that session — pi emits the
|
|
1724
|
+
// result event through the same session's streamSimple call).
|
|
1725
|
+
const orphanState = sessionStateFor(options?.sessionId ?? null);
|
|
1726
|
+
if (orphanState && activeQueryContexts.size === 0) orphanState.cursor = context.messages.length;
|
|
1480
1727
|
// No query owns this result, so there is no context to reset: resetTurnState
|
|
1481
1728
|
// on the top-level ctx() would replace a live parent's turnOutput mid-stream,
|
|
1482
1729
|
// stranding the blocks it had already emitted. A throwaway context just
|
|
@@ -1499,33 +1746,53 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1499
1746
|
const queryCtx = isReentrant ? new QueryContext() : ctx();
|
|
1500
1747
|
debug(`provider: fresh query setup, isReentrant=${isReentrant}, activeContexts=${activeQueryContexts.size}`);
|
|
1501
1748
|
|
|
1502
|
-
// Fail before claiming a stream if this model needs a newer CLI than we have.
|
|
1503
1749
|
const claudeExecutableResolution = resolveClaudeCodeExecutable(model.id, providerSettings.pathToClaudeCodeExecutable);
|
|
1504
1750
|
if (claudeExecutableResolution.error) {
|
|
1505
|
-
debug(`provider: ${claudeExecutableResolution.error}`);
|
|
1506
1751
|
stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", claudeExecutableResolution.error) });
|
|
1507
1752
|
stream.end();
|
|
1508
1753
|
return stream;
|
|
1509
1754
|
}
|
|
1510
1755
|
const claudeExecutable = claudeExecutableResolution.path;
|
|
1511
|
-
if (claudeExecutableResolution.source === "path") {
|
|
1512
|
-
debug(`provider: using PATH claude ${claudeExecutable} (bundled CLI is too old for ${model.id})`);
|
|
1513
|
-
}
|
|
1514
1756
|
|
|
1515
|
-
// Resolved first: an unaccountable system prompt
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1757
|
+
// Resolved first: an unaccountable system prompt fails this query before anything
|
|
1758
|
+
// is claimed or reset, leaving no half-built query behind — in particular no stream
|
|
1759
|
+
// claimed on the shared context that nobody will ever end.
|
|
1518
1760
|
const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
|
|
1519
1761
|
// Build from what Pi loaded for this run, so `--no-context-files` and
|
|
1520
1762
|
// `--no-skills` reach Claude Code by leaving nothing to forward. A sub-agent's
|
|
1521
1763
|
// custom override embeds its parent's assembled Pi prompt; recursive projection
|
|
1522
1764
|
// replaces that exact inherited prompt with its already-safe portable parts.
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1765
|
+
// Derive the key from the transcript replay (toBridgeContext), NOT from the
|
|
1766
|
+
// recorded keys: under a forced prompt the transcript head is projected via
|
|
1767
|
+
// transformContext after turn_start, so ctx.getSystemPrompt() is not the head.
|
|
1768
|
+
let promptCapture: PromptCapture | undefined;
|
|
1769
|
+
let systemPromptAppend: string | undefined;
|
|
1770
|
+
try {
|
|
1771
|
+
promptCapture = promptCaptures.resolveOrDerive(context.systemPrompt);
|
|
1772
|
+
systemPromptAppend = promptCapture
|
|
1773
|
+
? projectPromptCapture(promptCapture, {
|
|
1774
|
+
skillReadTool: mcpTools.some((tool) => tool.name === "read") ? "mcp" : "none",
|
|
1775
|
+
})
|
|
1776
|
+
: undefined;
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
// resolveOrDerive and projectPromptCapture throw to stop a turn that would lose
|
|
1779
|
+
// its instructions or leak pi's harness text. Report it on the stream, as pi-ai's
|
|
1780
|
+
// provider contract expects, so any caller — not only pi's agent loop, which
|
|
1781
|
+
// catches a throw — sees a failed turn rather than a synchronous exception.
|
|
1782
|
+
const output = newAssistantOutput(model, "", "error", errorMessage(err));
|
|
1783
|
+
queueMicrotask(() => {
|
|
1784
|
+
stream.push({ type: "error", reason: "error", error: output });
|
|
1785
|
+
markStreamComplete(stream);
|
|
1786
|
+
stream.end();
|
|
1787
|
+
});
|
|
1788
|
+
diagDump("prompt_capture_unresolved", {
|
|
1789
|
+
promptChars: context.systemPrompt?.length ?? 0,
|
|
1790
|
+
knownKeys: promptCaptures.size,
|
|
1791
|
+
reentrantUserQuery: isReentrantUserQuery,
|
|
1792
|
+
error: errorMessage(err),
|
|
1793
|
+
});
|
|
1794
|
+
return stream;
|
|
1795
|
+
}
|
|
1529
1796
|
|
|
1530
1797
|
// 2. Fresh child context — constructor already gave us clean Maps and empty
|
|
1531
1798
|
// arrays. For a reused top-level context, clear explicitly.
|
|
@@ -1538,16 +1805,41 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1538
1805
|
queryCtx.turnToolCallIds = [];
|
|
1539
1806
|
queryCtx.resetTurnState(model);
|
|
1540
1807
|
queryCtx.latestCursor = 0;
|
|
1541
|
-
|
|
1542
|
-
|
|
1808
|
+
// The served pi session, for rewrite attribution on delivery (issue #101
|
|
1809
|
+
// follow-up) and on SessionState. A fresh instance of this module inside a
|
|
1810
|
+
// worktree-spawned subagent has its own contexts; each records its own.
|
|
1811
|
+
queryCtx.piSessionId = options?.sessionId ?? null;
|
|
1812
|
+
// A discarded query's replacement reuses this context; without the reset its
|
|
1813
|
+
// first tool result would sit on armed staleness again (the mark is consumed
|
|
1814
|
+
// from the set, not from here) and re-discard a healthy query.
|
|
1815
|
+
queryCtx.historyStale = false;
|
|
1816
|
+
queryCtx.missedSteer = false;
|
|
1817
|
+
|
|
1818
|
+
const cwd = process.cwd();
|
|
1543
1819
|
// cliModel is the actual id sent to Claude Code (may carry [1m]); model.id is the
|
|
1544
1820
|
// pi-registered id. Log cliModel so debug lines reflect what CC actually received.
|
|
1545
1821
|
const cliModel = claudeCodeModelId(model, longContextSettings);
|
|
1546
|
-
|
|
1822
|
+
// Which pi session this query serves — the attribution key for history
|
|
1823
|
+
// rewrites (session_compact / session_tree) and for SessionState above.
|
|
1824
|
+
const piSessionId = options?.sessionId ?? null;
|
|
1825
|
+
const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, cliModel, piSessionId);
|
|
1826
|
+
// This query starts from the history pi has now: consume this session's
|
|
1827
|
+
// armed rewrite — a sibling pi session's stays armed for its own queries.
|
|
1828
|
+
if (piSessionId) historyRewrittenBySession.delete(piSessionId);
|
|
1547
1829
|
const { sessionId: resumeSessionId } = syncResult;
|
|
1548
1830
|
const promptBlocks = extractUserPromptBlocks(context.messages);
|
|
1549
1831
|
let promptText = extractUserPrompt(context.messages) ?? "";
|
|
1550
1832
|
|
|
1833
|
+
// A turn continuing past a discarded query ends at its tool result, not at a
|
|
1834
|
+
// prompt, so say what happened rather than falling into the empty-prompt
|
|
1835
|
+
// recovery below — that one is for a shape we do not expect, and this is one
|
|
1836
|
+
// we do. The rebuilt session already ends with the tool result, placed after
|
|
1837
|
+
// the tool call it answers.
|
|
1838
|
+
if (rewrittenUnderQuery && !promptText && !promptBlocks) {
|
|
1839
|
+
promptText = CONTINUE_AFTER_REWRITE_PROMPT;
|
|
1840
|
+
debug(`provider: continuing the turn after a rewritten history, ${context.messages.length} msgs rebuilt`);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1551
1843
|
// Guard: empty prompt means the last context message isn't a user message.
|
|
1552
1844
|
// This should never happen with per-query state — dump diagnostics if it does.
|
|
1553
1845
|
if (!promptText && !promptBlocks) {
|
|
@@ -1557,7 +1849,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1557
1849
|
isReentrant,
|
|
1558
1850
|
activeQueryContexts: activeQueryContexts.size,
|
|
1559
1851
|
activeQueryExists: queryCtx.activeQuery !== null,
|
|
1560
|
-
sharedSession:
|
|
1852
|
+
sharedSession: sessionStateFor(piSessionId) ? { sessionId: sessionStateFor(piSessionId)!.sessionId.slice(0, 8), cursor: sessionStateFor(piSessionId)!.cursor } : (sessionStateFor(null) ? { sessionId: sessionStateFor(null)!.sessionId.slice(0, 8), cursor: sessionStateFor(null)!.cursor } : null),
|
|
1561
1853
|
messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
|
|
1562
1854
|
});
|
|
1563
1855
|
// Recover: use a continuation prompt so the SDK doesn't send an empty text block
|
|
@@ -1582,20 +1874,25 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1582
1874
|
// settingSources is left at CC's default, which loads all sources.
|
|
1583
1875
|
const strictMcpConfigEnabled = providerSettings.strictMcpConfig !== false;
|
|
1584
1876
|
|
|
1585
|
-
// Prefer the model's own thinkingLevelMap
|
|
1586
|
-
//
|
|
1587
|
-
//
|
|
1877
|
+
// Prefer the model's own thinkingLevelMap (per-model overrides — e.g. a map can
|
|
1878
|
+
// route xhigh→xhigh where the generic table maps xhigh→max). pi-ai's catalog
|
|
1879
|
+
// ships a map for most Claude models; the table below covers models without
|
|
1880
|
+
// one, and the levels a map leaves unnamed. A null entry means the level is
|
|
1881
|
+
// unsupported on that model: no effort argument is sent, so Claude Code's own
|
|
1882
|
+
// default applies rather than the generic table's value. Map values are
|
|
1883
|
+
// provider-generic strings, so a map value is trusted only when it names a
|
|
1884
|
+
// level CC accepts.
|
|
1885
|
+
const mapped = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] : undefined;
|
|
1588
1886
|
const effort = options?.reasoning
|
|
1589
|
-
?
|
|
1590
|
-
|
|
1887
|
+
? mapped === undefined
|
|
1888
|
+
? REASONING_TO_EFFORT[options.reasoning]
|
|
1889
|
+
: VALID_EFFORTS.has(mapped as EffortLevel) ? mapped as EffortLevel : undefined
|
|
1591
1890
|
: undefined;
|
|
1592
1891
|
|
|
1593
1892
|
const extraArgs: Record<string, string | null> = { model: cliModel };
|
|
1594
1893
|
if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
|
|
1595
1894
|
// Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
|
|
1596
1895
|
// Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
|
|
1597
|
-
// Fable 5 / 5.1 always think and also default to omitted, even when we pass no effort
|
|
1598
|
-
// (CC then uses the model default, high).
|
|
1599
1896
|
if (effort || adaptiveThinkingAlwaysOn(model.id)) extraArgs["thinking-display"] = "summarized";
|
|
1600
1897
|
|
|
1601
1898
|
// Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
|
|
@@ -1621,7 +1918,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1621
1918
|
// hit on every transition. Cost here is nil: the setting also strips
|
|
1622
1919
|
// CC's git-workflow guidance from its Bash tool prompt, but the provider
|
|
1623
1920
|
// path runs CC with `tools: []`, so those definitions never ship.
|
|
1624
|
-
// AskClaude keeps CC's native tools and its guidance — unaffected.
|
|
1625
1921
|
settings: {
|
|
1626
1922
|
...claudeCodeSettings(providerSettings),
|
|
1627
1923
|
claudeMdExcludes: CLAUDE_MD_EXCLUDES,
|
|
@@ -1645,15 +1941,19 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1645
1941
|
`ctxFiles=${promptCapture?.contextFiles.length ?? 0} strictMcp=${strictMcpConfigEnabled}`,
|
|
1646
1942
|
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
|
|
1647
1943
|
|
|
1648
|
-
// Resolve Pi's Anthropic
|
|
1649
|
-
//
|
|
1650
|
-
// so query startup runs in the background and forwards into the claimed stream.
|
|
1944
|
+
// Resolve Pi's refreshed Anthropic auth asynchronously without changing the
|
|
1945
|
+
// synchronous provider API. Claim a pending handle so nested calls stay reentrant.
|
|
1651
1946
|
let wasAborted = false;
|
|
1652
1947
|
let sdkQuery: ReturnType<typeof query> | null = null;
|
|
1653
1948
|
const authPending = { kind: "claude-auth-pending" };
|
|
1654
1949
|
queryCtx.activeQuery = authPending;
|
|
1950
|
+
|
|
1951
|
+
// Capture context for abort handling
|
|
1655
1952
|
const abortCtx = queryCtx;
|
|
1953
|
+
|
|
1656
1954
|
const requestAbort = () => {
|
|
1955
|
+
// interrupt() asks the CLI to stop gracefully; close() kills it immediately.
|
|
1956
|
+
// Both are needed — interrupt alone lets the current API call finish.
|
|
1657
1957
|
void sdkQuery?.interrupt().catch(() => {});
|
|
1658
1958
|
try { sdkQuery?.close(); } catch {}
|
|
1659
1959
|
};
|
|
@@ -1667,88 +1967,115 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1667
1967
|
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
1668
1968
|
}
|
|
1669
1969
|
|
|
1970
|
+
// Background consumer — runs until query ends.
|
|
1670
1971
|
void (async () => {
|
|
1671
1972
|
queryOptions.env = await resolveClaudeChildEnv(piModelRegistry);
|
|
1672
1973
|
if (wasAborted || options?.signal?.aborted) throw new Error("Operation aborted");
|
|
1673
|
-
|
|
1674
|
-
const startedQuery = query({ prompt: promptStream.stream, options: queryOptions });
|
|
1974
|
+
const startedQuery = queryImpl({ prompt: promptStream.stream, options: queryOptions });
|
|
1675
1975
|
sdkQuery = startedQuery;
|
|
1676
1976
|
queryCtx.activeQuery = startedQuery;
|
|
1677
1977
|
activeQueryContexts.add(queryCtx);
|
|
1678
|
-
// query() may synchronously trigger an abort before sdkQuery is assigned.
|
|
1679
|
-
// Re-check now so the just-created child cannot escape requestAbort().
|
|
1680
1978
|
if (wasAborted || options?.signal?.aborted) {
|
|
1681
1979
|
requestAbort();
|
|
1682
1980
|
throw new Error("Operation aborted");
|
|
1683
1981
|
}
|
|
1684
|
-
|
|
1685
1982
|
const { capturedSessionId } = await consumeQuery(startedQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
|
|
1686
1983
|
debug(`provider: consumeQuery completed, stopReason=${queryCtx.turnOutput?.stopReason}, error=${queryCtx.turnOutput?.errorMessage}, aborted=${wasAborted}`);
|
|
1687
1984
|
|
|
1985
|
+
// Discarded out from under: the query continuing the turn owns the context,
|
|
1986
|
+
// the session and the stream. Capturing this one's session id here would put
|
|
1987
|
+
// Claude Code back on the conversation it was discarded for.
|
|
1988
|
+
if (abandonedQueries.has(sdkQuery)) {
|
|
1989
|
+
debug("provider: discarded query completed, leaving session and stream to its replacement");
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// --- Abort detection in normal completion path ---
|
|
1688
1994
|
if (wasAborted || options?.signal?.aborted) {
|
|
1689
|
-
|
|
1995
|
+
// The killed subprocess may flush a late record into this session's
|
|
1996
|
+
// JSONL — its own mirror's next sync must rebuild and rotate.
|
|
1997
|
+
const state = sessionStateFor(queryCtx.piSessionId);
|
|
1998
|
+
if (state) setSessionStateFor(queryCtx.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
|
|
1690
1999
|
debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
|
|
1691
2000
|
if (queryCtx.turnOutput) {
|
|
1692
2001
|
queryCtx.turnOutput.stopReason = "aborted";
|
|
1693
2002
|
queryCtx.turnOutput.errorMessage = "Operation aborted";
|
|
1694
2003
|
}
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
markStreamComplete(
|
|
1698
|
-
|
|
2004
|
+
const stream = queryCtx.currentPiStream;
|
|
2005
|
+
stream?.push({ type: "error", reason: "aborted", error: queryCtx.turnOutput! });
|
|
2006
|
+
markStreamComplete(stream);
|
|
2007
|
+
stream?.end();
|
|
1699
2008
|
queryCtx.currentPiStream = null;
|
|
1700
2009
|
return;
|
|
1701
2010
|
}
|
|
1702
2011
|
|
|
1703
|
-
|
|
2012
|
+
// --- Capture session ID ---
|
|
2013
|
+
// This query's own mirror — a reentrant subagent completing does not
|
|
2014
|
+
// reassign the parent's conversation to the child's CC file.
|
|
1704
2015
|
if (syncResult.preserveSharedSession) {
|
|
1705
|
-
|
|
2016
|
+
const state = sessionStateFor(queryCtx.piSessionId);
|
|
2017
|
+
if (capturedSessionId && capturedSessionId !== state?.sessionId) {
|
|
1706
2018
|
deleteSession(capturedSessionId, cwd, process.env.CLAUDE_CONFIG_DIR);
|
|
1707
2019
|
debug(`provider: query done, deleted ephemeral session ${capturedSessionId.slice(0, 8)} to preserve shared session`);
|
|
1708
2020
|
}
|
|
1709
2021
|
debug(`provider: query done, ignoring captured session ${capturedSessionId?.slice(0, 8) ?? "none"} to preserve shared session`);
|
|
1710
|
-
} else
|
|
1711
|
-
const
|
|
1712
|
-
|
|
1713
|
-
|
|
2022
|
+
} else {
|
|
2023
|
+
const state = sessionStateFor(queryCtx.piSessionId);
|
|
2024
|
+
const sessionId = capturedSessionId ?? state?.sessionId;
|
|
2025
|
+
if (sessionId) {
|
|
2026
|
+
const cursor = Math.max(context.messages.length, queryCtx.latestCursor, state?.cursor ?? 0);
|
|
2027
|
+
debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
|
|
2028
|
+
// A missed steer may precede the first mirror or arrive while this
|
|
2029
|
+
// query is still able to complete. Preserve both rebuild signals.
|
|
2030
|
+
setSessionStateFor(queryCtx.piSessionId, { ...state, sessionId, cursor, cwd, piSessionId: queryCtx.piSessionId ?? undefined, needsRebuild: queryCtx.missedSteer || state?.needsRebuild });
|
|
2031
|
+
}
|
|
1714
2032
|
}
|
|
1715
2033
|
|
|
1716
|
-
if (queryCtx.activeQuery ===
|
|
1717
|
-
|
|
1718
|
-
if (!isReentrant) debug("provider: clearing activeQuery before final stream completion");
|
|
2034
|
+
if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
|
|
2035
|
+
debug("provider: clearing activeQuery before final stream completion");
|
|
1719
2036
|
queryCtx.activeQuery = null;
|
|
1720
2037
|
}
|
|
1721
2038
|
finalizeCurrentStream(queryCtx, queryCtx.turnOutput?.stopReason);
|
|
1722
2039
|
})()
|
|
1723
2040
|
.catch((error) => {
|
|
1724
|
-
debug(`provider: query error, model=${cliModel}, aborted=${Boolean(
|
|
1725
|
-
if (
|
|
1726
|
-
|
|
2041
|
+
debug(`provider: query error, model=${cliModel}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
|
|
2042
|
+
if (sdkQuery && abandonedQueries.has(sdkQuery)) {
|
|
2043
|
+
debug("provider: discarded query ended in error, leaving session and stream to its replacement");
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
if ((wasAborted || options?.signal?.aborted)) {
|
|
2047
|
+
const state = sessionStateFor(queryCtx.piSessionId);
|
|
2048
|
+
if (state) setSessionStateFor(queryCtx.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
|
|
1727
2049
|
} else if (sdkQuery) {
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
|
|
2050
|
+
// Auth failures precede the subprocess and must preserve a resumable mirror.
|
|
2051
|
+
// Drop this session's mirror: its conversation is in an unknown
|
|
2052
|
+
// state after the error. Other sessions' mirrors stay — one
|
|
2053
|
+
// session's failure says nothing about another's conversation.
|
|
2054
|
+
setSessionStateFor(queryCtx.piSessionId, null);
|
|
1732
2055
|
}
|
|
1733
2056
|
promptStream.fail(error instanceof Error ? error : new Error(String(error)));
|
|
1734
2057
|
if (queryCtx.turnOutput) {
|
|
1735
|
-
queryCtx.turnOutput.stopReason =
|
|
2058
|
+
queryCtx.turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
2059
|
+
// The SDK drops its copy of the result text if any message follows the error
|
|
2060
|
+
// result, so prefer the cause consumeQuery recorded off the result itself.
|
|
1736
2061
|
queryCtx.turnOutput.errorMessage ??= error instanceof Error ? error.message : String(error);
|
|
1737
2062
|
}
|
|
1738
|
-
if (
|
|
2063
|
+
if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
|
|
1739
2064
|
queryCtx.releasePendingToolCalls("Query ended");
|
|
1740
|
-
|
|
1741
|
-
if (!isReentrant) debug("provider: clearing activeQuery before error stream completion");
|
|
2065
|
+
debug("provider: clearing activeQuery before error stream completion");
|
|
1742
2066
|
queryCtx.activeQuery = null;
|
|
1743
2067
|
}
|
|
1744
|
-
const
|
|
1745
|
-
|
|
1746
|
-
markStreamComplete(
|
|
1747
|
-
|
|
2068
|
+
const stream = queryCtx.currentPiStream;
|
|
2069
|
+
stream?.push({ type: "error", reason: (queryCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: queryCtx.turnOutput! });
|
|
2070
|
+
markStreamComplete(stream);
|
|
2071
|
+
stream?.end();
|
|
1748
2072
|
queryCtx.currentPiStream = null;
|
|
1749
2073
|
})
|
|
1750
2074
|
.finally(() => {
|
|
1751
2075
|
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
|
2076
|
+
// Settle any ack still parked in the generator — the CLI is gone, so
|
|
2077
|
+
// nothing will resume it. Clear the handle only if a later query
|
|
2078
|
+
// hasn't already claimed the shared context.
|
|
1752
2079
|
promptStream.fail(new Error("query ended"));
|
|
1753
2080
|
if (queryCtx.promptStream === promptStream) queryCtx.promptStream = null;
|
|
1754
2081
|
// A later query claiming this context sets activeQuery to its own handle;
|
|
@@ -1757,8 +2084,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1757
2084
|
// path, leaving the top-level context in the routing set forever — where a
|
|
1758
2085
|
// later orphaned tool result matches its stale turnToolCallIds and takes
|
|
1759
2086
|
// the delivery branch, returning a stream nothing ends.
|
|
1760
|
-
// authPending covers the window before Claude Code starts, when sdkQuery
|
|
1761
|
-
// is still null.
|
|
1762
2087
|
if (queryCtx.activeQuery === authPending || queryCtx.activeQuery === sdkQuery || queryCtx.activeQuery === null) {
|
|
1763
2088
|
queryCtx.releasePendingToolCalls("Query ended");
|
|
1764
2089
|
queryCtx.activeQuery = null;
|
|
@@ -1770,10 +2095,8 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1770
2095
|
return stream;
|
|
1771
2096
|
}
|
|
1772
2097
|
|
|
1773
|
-
|
|
1774
2098
|
// --- Extension registration ---
|
|
1775
2099
|
|
|
1776
|
-
|
|
1777
2100
|
export default function (pi: ExtensionAPI) {
|
|
1778
2101
|
// Disable non-essential Claude Code traffic (update checks, MCP registry, telemetry)
|
|
1779
2102
|
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
@@ -1782,11 +2105,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1782
2105
|
debug("loadConfig:", JSON.stringify(config));
|
|
1783
2106
|
providerSettings = config.provider ?? {};
|
|
1784
2107
|
// We need these settings to know if we're eligible for 1M context on certain models
|
|
2108
|
+
// Validate at the boundary: a non-array here would throw inside every
|
|
2109
|
+
// claudeCodeModelId call and brick the extension at activation.
|
|
2110
|
+
const forceTwoHundredK = Array.isArray(providerSettings.forceTwoHundredK)
|
|
2111
|
+
? providerSettings.forceTwoHundredK.filter((id): id is string => typeof id === "string")
|
|
2112
|
+
: undefined;
|
|
1785
2113
|
longContextSettings = {
|
|
1786
2114
|
plan: providerSettings.plan ?? "max",
|
|
1787
2115
|
longContextExtraUsage: providerSettings.longContextExtraUsage ?? false,
|
|
2116
|
+
forceTwoHundredK,
|
|
1788
2117
|
};
|
|
1789
2118
|
const registeredModels = applyLongContext(MODELS, longContextSettings);
|
|
2119
|
+
if (registeredModels.length === 0) {
|
|
2120
|
+
console.error("claude-bridge: no models available from pi-ai's anthropic catalog — update @earendil-works/pi-ai (requires >=0.86.1)");
|
|
2121
|
+
}
|
|
1790
2122
|
|
|
1791
2123
|
if (!config.startupNoticeShown) {
|
|
1792
2124
|
if (config.provider?.plan === undefined) pendingNotices.push('Assuming a Max plan. On Pro, set provider.plan to "pro" so Opus 4.6 stays at 200K context.');
|
|
@@ -1794,8 +2126,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1794
2126
|
|
|
1795
2127
|
// Reset shared session on pi session lifecycle events
|
|
1796
2128
|
const clearSession = (event: string) => {
|
|
1797
|
-
debug(`${event}: clearing session
|
|
1798
|
-
|
|
2129
|
+
debug(`${event}: clearing ${sharedSessions.size} shared session${sharedSessions.size === 1 ? "" : "s"}`);
|
|
2130
|
+
// Whole map: children never emit session_shutdown (only runtime teardown
|
|
2131
|
+
// and /reload do), so there is no per-entry removal to do here — the
|
|
2132
|
+
// top-level transition takes every mirror with it.
|
|
2133
|
+
sharedSessions.clear();
|
|
2134
|
+
historyRewrittenBySession.clear();
|
|
1799
2135
|
|
|
1800
2136
|
// Clear the global streamSimple if this instance registered it.
|
|
1801
2137
|
// This allows /reload to work — the old instance clears the flag so
|
|
@@ -1817,15 +2153,57 @@ export default function (pi: ExtensionAPI) {
|
|
|
1817
2153
|
// `--system-prompt` replaces pi's default rather than adding to it, but Claude
|
|
1818
2154
|
// Code's preset carries its own tool and permission guidance that the bridge
|
|
1819
2155
|
// still depends on, so both flags are forwarded as an append.
|
|
1820
|
-
|
|
1821
|
-
|
|
2156
|
+
//
|
|
2157
|
+
// The options (custom/append/contextFiles/skills) are pi config, stable across a
|
|
2158
|
+
// turn; only the auto-generated tool list in the rendered prompt varies. Stash them
|
|
2159
|
+
// at before_agent_start so the agent_start recording below can reuse them.
|
|
2160
|
+
type RecordOptions = Parameters<typeof recordSystemPrompt>[2];
|
|
2161
|
+
let lastSystemPromptOptions: RecordOptions | undefined;
|
|
2162
|
+
function recordSystemPrompt(source: string, systemPrompt: string | undefined, options: {
|
|
2163
|
+
customPrompt?: string;
|
|
2164
|
+
appendSystemPrompt?: string;
|
|
2165
|
+
contextFiles?: { path: string; content: string }[];
|
|
2166
|
+
skills?: Parameters<typeof promptCaptures.record>[1]["skills"];
|
|
2167
|
+
selectedTools?: string[];
|
|
2168
|
+
} | undefined) {
|
|
2169
|
+
if (!systemPrompt) return;
|
|
1822
2170
|
const hasRead = !options?.selectedTools || options.selectedTools.includes("read");
|
|
1823
|
-
promptCaptures.record(
|
|
2171
|
+
promptCaptures.record(systemPrompt, {
|
|
1824
2172
|
custom: options?.customPrompt,
|
|
1825
2173
|
append: options?.appendSystemPrompt,
|
|
1826
2174
|
contextFiles: options?.contextFiles ?? [],
|
|
1827
2175
|
skills: hasRead ? options?.skills ?? [] : [],
|
|
1828
|
-
});
|
|
2176
|
+
}, source);
|
|
2177
|
+
}
|
|
2178
|
+
pi.on("before_agent_start", (event) => {
|
|
2179
|
+
lastSystemPromptOptions = event.systemPromptOptions;
|
|
2180
|
+
recordSystemPrompt("before_agent_start", event.systemPrompt, event.systemPromptOptions);
|
|
2181
|
+
});
|
|
2182
|
+
// The prompt the provider actually queries with is the fully-widened one: MCP tool
|
|
2183
|
+
// descriptions merge into the system prompt only after their servers connect, which
|
|
2184
|
+
// is after before_agent_start. ctx.getSystemPrompt() returns that widened prompt by
|
|
2185
|
+
// agent_start (verified: before_agent_start=10,988 chars vs agent_start/query=23,479).
|
|
2186
|
+
// A subagent embeds the widened parent prompt verbatim (pi-subagents reads
|
|
2187
|
+
// ctx.getSystemPrompt() at dispatch), so unless the widened prompt is a capture key
|
|
2188
|
+
// too, the child's turn resolves against nothing, falls to a verbatim side request,
|
|
2189
|
+
// and ships pi's harness — tripping the server's third-party plan-eligibility check
|
|
2190
|
+
// ("out of extra usage"). Recording it here, before the query, restores the match.
|
|
2191
|
+
//
|
|
2192
|
+
// agent_start also captures a handler-returned forceSystemPrompt, which
|
|
2193
|
+
// buildSystemPrompt renders verbatim.
|
|
2194
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
2195
|
+
recordSystemPrompt("agent_start", ctx.getSystemPrompt(), lastSystemPromptOptions);
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
// Mid-run re-renders: turn_start fires before every turn (first turn included)
|
|
2199
|
+
// after the turn's prompt is final: prepareNextTurnWithContext has
|
|
2200
|
+
// re-rendered the options (pi's section-based prompt) and any mid-run
|
|
2201
|
+
// setActiveToolsByName rebuild has already landed. Re-keying at each boundary
|
|
2202
|
+
// the prompt can change at keeps exact-match alive mid-run. The stashed options can
|
|
2203
|
+
// lag a mid-run tool-loadout change, which skews the hasRead skills filter until the
|
|
2204
|
+
// next before_agent_start — accepted: a stale skills list beats failing the turn.
|
|
2205
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
2206
|
+
recordSystemPrompt("turn_start", ctx.getSystemPrompt(), lastSystemPromptOptions);
|
|
1829
2207
|
});
|
|
1830
2208
|
pi.on("session_shutdown", () => {
|
|
1831
2209
|
reportLeaks("session_shutdown");
|
|
@@ -1838,40 +2216,71 @@ export default function (pi: ExtensionAPI) {
|
|
|
1838
2216
|
// slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
|
|
1839
2217
|
// session that no longer matches pi's history. /compact in particular
|
|
1840
2218
|
// triggers CC's autocompact-thrashing guard (issue #8). Force the next
|
|
1841
|
-
// call down the REBUILD path so CC sees the current history
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2219
|
+
// call down the REBUILD path so CC sees the current history — and, when a
|
|
2220
|
+
// query is parked at a tool boundary while this fires, discard that query
|
|
2221
|
+
// instead of resuming it (markRebuild, discardRewrittenQuery).
|
|
2222
|
+
//
|
|
2223
|
+
// Attributed to the compacting session (ctx.sessionManager belongs to the
|
|
2224
|
+
// session whose runner fired this), so a subagent compacting while its
|
|
2225
|
+
// parent sits parked on the Agent tool result discards nothing — the parent's
|
|
2226
|
+
// query is live and its conversation untouched. Registered by every instance
|
|
2227
|
+
// of this module; instances sponsoring stale marks forward them to the
|
|
2228
|
+
// serving instance via sponsorMarkRebuildForSession.
|
|
2229
|
+
pi.on("session_compact", (event, ctx) =>
|
|
2230
|
+
sponsorMarkRebuildForSession(ctx.sessionManager.getSessionId(), `session_compact:${event.reason}:willRetry=${event.willRetry}`));
|
|
2231
|
+
pi.on("session_tree", (_event, ctx) => sponsorMarkRebuildForSession(ctx.sessionManager.getSessionId(), "session_tree"));
|
|
1850
2232
|
|
|
1851
2233
|
// --- Provider ---
|
|
1852
2234
|
//
|
|
1853
|
-
//
|
|
1854
|
-
//
|
|
1855
|
-
//
|
|
1856
|
-
//
|
|
2235
|
+
// Registration policy across module instances (a subagent session can load
|
|
2236
|
+
// this module fresh): the FIRST instance registers unconditionally at load,
|
|
2237
|
+
// which is what puts claude-bridge models in the picker before any session
|
|
2238
|
+
// starts. Later instances decide at session_start, when ctx.modelRegistry
|
|
2239
|
+
// reveals who owns this session's registry:
|
|
2240
|
+
//
|
|
2241
|
+
// - Registry already has the provider (host passes the parent's registry down,
|
|
2242
|
+
// e.g. pi-subagents >=0.14.3): skip. Re-registering would overwrite the
|
|
2243
|
+
// parent's pinned streamSimple with this instance's fresh — empty-state —
|
|
2244
|
+
// stream fn, and the parent's next tool-result delivery would route into it.
|
|
2245
|
+
// - Registry lacks the provider (host gives the child its own, e.g. older
|
|
2246
|
+
// pi-subagents forks): register, or every claude-bridge/* dispatch in the
|
|
2247
|
+
// child fails with "Model not found" (#91). Even loading the bridge via the
|
|
2248
|
+
// agent's `extensions:` frontmatter didn't help there — the module loaded,
|
|
2249
|
+
// hit the old skip-guard, and the child's registry stayed empty.
|
|
2250
|
+
//
|
|
2251
|
+
// A per-instance stream fn registered into a per-instance registry is
|
|
2252
|
+
// self-consistent: that session's traffic flows through this module state,
|
|
2253
|
+
// which starts clean and serves only that session.
|
|
2254
|
+
//
|
|
2255
|
+
// On session_shutdown (including /reload), clearSession() resets
|
|
2256
|
+
// ACTIVE_STREAM_SIMPLE_KEY so a freshly loaded module can register as first
|
|
2257
|
+
// again.
|
|
1857
2258
|
|
|
1858
2259
|
const g = globalThis as Record<symbol, any>;
|
|
2260
|
+
const providerConfig = {
|
|
2261
|
+
baseUrl: "claude-bridge",
|
|
2262
|
+
apiKey: "not-used",
|
|
2263
|
+
api: "claude-bridge",
|
|
2264
|
+
models: registeredModels,
|
|
2265
|
+
// Cast: the Provider interface passes a TranscriptContext; the bridge takes plain
|
|
2266
|
+
// Context models (toBridgeContext normalizes at the stream entry points).
|
|
2267
|
+
streamSimple: streamClaudeAgentSdk as any,
|
|
2268
|
+
};
|
|
1859
2269
|
if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
|
|
1860
2270
|
// First instance: store our streamSimple and register.
|
|
1861
2271
|
g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
|
|
1862
|
-
pi.registerProvider(PROVIDER_ID,
|
|
1863
|
-
baseUrl: "claude-bridge",
|
|
1864
|
-
apiKey: "not-used",
|
|
1865
|
-
api: "claude-bridge",
|
|
1866
|
-
models: registeredModels,
|
|
1867
|
-
// Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
|
|
1868
|
-
streamSimple: streamClaudeAgentSdk as any,
|
|
1869
|
-
});
|
|
2272
|
+
pi.registerProvider(PROVIDER_ID, providerConfig);
|
|
1870
2273
|
} else {
|
|
1871
|
-
//
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
2274
|
+
// Later instance: register only if this session's registry lacks the provider.
|
|
2275
|
+
debug(`provider: deferring registration decision to session_start (module=${moduleInstanceId})`);
|
|
2276
|
+
pi.on("session_start", (_event, ctx) => {
|
|
2277
|
+
if (ctx.modelRegistry.getProvider(PROVIDER_ID)) {
|
|
2278
|
+
debug(`provider: registry already has ${PROVIDER_ID}, skipping registration (module=${moduleInstanceId})`);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
debug(`provider: registry lacks ${PROVIDER_ID}, registering (module=${moduleInstanceId})`);
|
|
2282
|
+
pi.registerProvider(PROVIDER_ID, providerConfig);
|
|
2283
|
+
});
|
|
1876
2284
|
}
|
|
2285
|
+
|
|
1877
2286
|
}
|