pi-claude-agent-sdk 0.8.4 → 0.8.6

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 CHANGED
@@ -21,7 +21,7 @@ pi install npm:pi-claude-agent-sdk
21
21
 
22
22
  ## Provider
23
23
 
24
- Use `/model` to select `claude-bridge/claude-fable-5-1`, `claude-bridge/claude-fable-5`, `claude-bridge/claude-opus-5`, `claude-bridge/claude-opus-4-8`, `claude-bridge/claude-opus-4-7`, `claude-bridge/claude-opus-4-6`, `claude-bridge/claude-sonnet-5`, `claude-bridge/claude-sonnet-4-6`, or `claude-bridge/claude-haiku-4-5`. The `fable` shortcut resolves to Fable 5.1. Fable 5.1 needs Claude Code **2.1.251 or newer**; if the SDK's bundled CLI is older, set `provider.pathToClaudeCodeExecutable` to a current `claude` binary.
24
+ Use `/model` to select `claude-bridge/claude-fable-5-1`, `claude-bridge/claude-fable-5`, `claude-bridge/claude-opus-5`, `claude-bridge/claude-opus-4-8`, `claude-bridge/claude-opus-4-7`, `claude-bridge/claude-opus-4-6`, `claude-bridge/claude-sonnet-5`, `claude-bridge/claude-sonnet-4-6`, or `claude-bridge/claude-haiku-4-5`. The `fable` shortcut resolves to Fable 5.1. Fable 5.1 needs Claude Code **2.1.251 or newer** (the SDK's bundled CLI is 2.1.257). If you point `provider.pathToClaudeCodeExecutable` at an older binary, or a later model outruns the bundle, the bridge uses a current `claude` on PATH when it finds one.
25
25
 
26
26
  Behind the scenes, pi's tools are bridged to Claude Code but it should all work like normal in pi. Bash commands get a 120-second default timeout (matching Claude Code's default) since pi's bash has no timeout by default. Skills in pi are copied over to Claude Code's system prompt so should work as they would with any other pi provider. Steering works mid-turn: a message sent while Claude is running a tool reaches it at that tool boundary, not after the whole turn finishes.
27
27
 
@@ -49,7 +49,7 @@ Config: `~/.pi/agent/claude-bridge.json` (global) or the project Pi config direc
49
49
  - `longContextExtraUsage` — set to `true` to enable 1M models that cost money through Extra Usage. It enables Sonnet 4.6 with 1M on every plan and Opus 4.6 with 1M on Pro. Not needed for Opus 4.7 or 4.8.
50
50
  - `strictMcpConfig` — block MCP servers from `~/.claude.json` / `.mcp.json` (default `true`). Cloud MCP (Gmail/Drive via claude.ai OAuth) is always blocked.
51
51
  - `autoMemoryEnabled` — enable Claude Code's auto-memory system (default `false`)
52
- - `pathToClaudeCodeExecutable` — path to the `claude` binary. Useful if your OS/filesystem has the SDK's bundled musl/glibc binaries in a place where they can't run. For example, with Nix you can set the binary to e.g. `"/home/you/.nix-profile/bin/claude"`.
52
+ - `pathToClaudeCodeExecutable` — path to the `claude` binary. Useful if your OS/filesystem has the SDK's bundled musl/glibc binaries in a place where they can't run, or to pin a specific CLI. For example, with Nix you can set the binary to e.g. `"/home/you/.nix-profile/bin/claude"`.
53
53
 
54
54
  **Extension providers and models.json:** pi's `modelOverrides` in `~/.pi/agent/models.json` do not currently apply to extension-registered providers (like claude-bridge). Overriding `contextWindow` or other fields requires editing `src/models.ts` directly.
55
55
 
@@ -66,7 +66,7 @@ Integration tests spawn real `pi` and Claude Code subprocesses, so they need wri
66
66
  Set `CLAUDE_BRIDGE_DEBUG=1` to enable debug output:
67
67
 
68
68
  - **Bridge log** at `~/.pi/agent/claude-bridge.log` — every provider call, session sync decision, tool result delivery, and CC's stderr. Override location with `CLAUDE_BRIDGE_DEBUG_PATH`.
69
- - **Per-query Claude Code CLI logs** at `~/.pi/agent/cc-cli-logs/<timestamp>-<tag>-<seq>.log` — the CC subprocess's own debug stream, one file per `query()` call. Tags are `provider` (main turn) or `compact-summary`. Useful when a resume fails or CC misbehaves internally — shows the CLI's own view of session loading, API requests, and tool calls.
69
+ - **Per-query Claude Code CLI logs** at `~/.pi/agent/cc-cli-logs/<timestamp>-<tag>-<seq>.log` — the CC subprocess's own debug stream, one file per `query()` call. Tags are `provider` (resumable main turn) or `standalone` (compaction, branch summary, and tool-free extension-owned no-cache completion). Useful when a resume fails or CC misbehaves internally — shows the CLI's own view of session loading, API requests, and tool calls.
70
70
 
71
71
  When filing a bug about a session-resume failure (e.g. "No conversation found"), the most useful attachments are the `syncResult:` lines from the bridge log plus the matching `cc-cli-logs/` file for the failing query.
72
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-agent-sdk",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "private": false,
5
5
  "description": "Pi extension that uses Claude Code (via Agent SDK) as a model provider.",
6
6
  "keywords": [
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "type": "module",
42
42
  "dependencies": {
43
- "@anthropic-ai/claude-agent-sdk": "^0.2.141",
43
+ "@anthropic-ai/claude-agent-sdk": "^0.3.257",
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "cc-session-io": "^0.4.0",
46
46
  "change-case": "^5.4.4"
@@ -50,7 +50,7 @@
50
50
  "@earendil-works/pi-coding-agent": ">=0.82.1"
51
51
  },
52
52
  "devDependencies": {
53
- "@anthropic-ai/sdk": "^0.73.0",
53
+ "@anthropic-ai/sdk": "^0.93.0",
54
54
  "@earendil-works/pi-ai": "^0.83.0",
55
55
  "@earendil-works/pi-coding-agent": "^0.83.0",
56
56
  "@types/node": "^24.13.2",
@@ -0,0 +1,149 @@
1
+ // Pick a Claude Code CLI that can actually serve the selected model.
2
+ // The Agent SDK bundles its own binary (`claudeCodeVersion` in its package.json).
3
+ // Some models (Fable 5.1) 400 against an older CLI; when the bundle is below
4
+ // the model's minimum we use a current `claude` on PATH, matching what the
5
+ // README already told people to set via pathToClaudeCodeExecutable.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { accessSync, constants, readFileSync } from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import { delimiter, dirname, join } from "node:path";
11
+ import { minClaudeCodeVersionForModel } from "./models.js";
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ export type ClaudeVersion = { major: number; minor: number; patch: number };
16
+
17
+ export type ClaudeExecutableSource = "configured" | "bundled" | "path";
18
+
19
+ export type ClaudeExecutableResolution = {
20
+ /** Absolute path for the SDK, or undefined to use the bundled CLI. */
21
+ path?: string;
22
+ source: ClaudeExecutableSource;
23
+ /** Set when no available CLI meets the model's minimum version. */
24
+ error?: string;
25
+ };
26
+
27
+ export type ClaudeExecutableDeps = {
28
+ bundledVersion?: () => ClaudeVersion | undefined;
29
+ findOnPath?: () => string | undefined;
30
+ readVersion?: (executable: string) => ClaudeVersion | undefined;
31
+ };
32
+
33
+ const versionCache = new Map<string, ClaudeVersion | undefined>();
34
+
35
+ export function parseClaudeVersion(text: string): ClaudeVersion | undefined {
36
+ const match = text.match(/(\d+)\.(\d+)\.(\d+)/);
37
+ if (!match) return undefined;
38
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
39
+ }
40
+
41
+ export function formatClaudeVersion(version: ClaudeVersion): string {
42
+ return `${version.major}.${version.minor}.${version.patch}`;
43
+ }
44
+
45
+ export function compareClaudeVersion(a: ClaudeVersion, b: ClaudeVersion): number {
46
+ return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
47
+ }
48
+
49
+ export function readBundledClaudeCodeVersion(): ClaudeVersion | undefined {
50
+ try {
51
+ // The SDK does not export ./package.json; read the file next to its entry.
52
+ const pkgPath = join(dirname(require.resolve("@anthropic-ai/claude-agent-sdk")), "package.json");
53
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { claudeCodeVersion?: string };
54
+ return parseClaudeVersion(pkg.claudeCodeVersion ?? "");
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ export function findExecutableOnPath(name: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
61
+ const pathEnv = env.PATH ?? env.Path;
62
+ if (!pathEnv) return undefined;
63
+ const exts = process.platform === "win32"
64
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean)
65
+ : [""];
66
+ const names = process.platform === "win32" && !exts.some((ext) => name.toUpperCase().endsWith(ext.toUpperCase()))
67
+ ? [name, ...exts.map((ext) => name + ext)]
68
+ : [name];
69
+ for (const dir of pathEnv.split(delimiter)) {
70
+ if (!dir) continue;
71
+ for (const candidateName of names) {
72
+ const candidate = join(dir, candidateName);
73
+ try {
74
+ accessSync(candidate, constants.X_OK);
75
+ return candidate;
76
+ } catch {}
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ export function readClaudeCliVersion(executable: string): ClaudeVersion | undefined {
83
+ if (versionCache.has(executable)) return versionCache.get(executable);
84
+ try {
85
+ const result = spawnSync(executable, ["--version"], {
86
+ encoding: "utf8",
87
+ timeout: 8000,
88
+ env: process.env,
89
+ windowsHide: true,
90
+ });
91
+ const version = parseClaudeVersion(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
92
+ versionCache.set(executable, version);
93
+ return version;
94
+ } catch {
95
+ versionCache.set(executable, undefined);
96
+ return undefined;
97
+ }
98
+ }
99
+
100
+ function tooOldError(modelId: string, min: ClaudeVersion, found: string): string {
101
+ return `${modelId} requires Claude Code ${formatClaudeVersion(min)} or newer (${found}). Install a current claude on PATH, or set provider.pathToClaudeCodeExecutable in ~/.pi/agent/claude-bridge.json.`;
102
+ }
103
+
104
+ export function resolveClaudeCodeExecutable(
105
+ modelId: string,
106
+ configured?: string,
107
+ deps: ClaudeExecutableDeps = {},
108
+ ): ClaudeExecutableResolution {
109
+ const bundledVersion = deps.bundledVersion ?? readBundledClaudeCodeVersion;
110
+ const findOnPath = deps.findOnPath ?? (() => findExecutableOnPath("claude"));
111
+ const readVersion = deps.readVersion ?? readClaudeCliVersion;
112
+ const minText = minClaudeCodeVersionForModel(modelId);
113
+ const min = minText ? parseClaudeVersion(minText) : undefined;
114
+
115
+ if (configured) {
116
+ if (min) {
117
+ const version = readVersion(configured);
118
+ if (version && compareClaudeVersion(version, min) < 0) {
119
+ return {
120
+ source: "configured",
121
+ error: tooOldError(modelId, min, `provider.pathToClaudeCodeExecutable is ${formatClaudeVersion(version)} at ${configured}`),
122
+ };
123
+ }
124
+ }
125
+ return { path: configured, source: "configured" };
126
+ }
127
+
128
+ if (!min) return { source: "bundled" };
129
+
130
+ const bundled = bundledVersion();
131
+ if (bundled && compareClaudeVersion(bundled, min) >= 0) {
132
+ return { source: "bundled" };
133
+ }
134
+
135
+ const pathClaude = findOnPath();
136
+ if (pathClaude) {
137
+ const version = readVersion(pathClaude);
138
+ if (version && compareClaudeVersion(version, min) >= 0) {
139
+ return { path: pathClaude, source: "path" };
140
+ }
141
+ }
142
+
143
+ if (bundled) {
144
+ return { source: "bundled", error: tooOldError(modelId, min, `bundled CLI is ${formatClaudeVersion(bundled)}`) };
145
+ }
146
+
147
+ // Bundle version unknown and PATH missing/unreadable: let the SDK try.
148
+ return { source: "bundled" };
149
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { calculateCost, type AssistantMessage, type AssistantMessageEventStream, type Context, type ImageContent, type Model, type SimpleStreamOptions, type TextContent, type Tool, type UserMessage } from "@earendil-works/pi-ai";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
3
  import { getModels } from "@earendil-works/pi-ai/compat";
4
- import { compact, generateBranchSummary, type BranchSummaryResult, type CompactionEntry, type ExtensionAPI, type ExtensionContext, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
+ import { type ExtensionAPI, type ExtensionContext, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
5
5
  import { query, type EffortLevel, type SDKMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
6
6
  import type { Base64ImageSource, ContentBlockParam } from "@anthropic-ai/sdk/resources";
7
7
  import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
@@ -24,6 +24,7 @@ import {
24
24
  import { collectCarriedAttachments, placeCarriedAttachments, type CarriedAttachment } from "./attachments.js";
25
25
  import { createToolServer } from "./mcp-server.js";
26
26
  import { CC_CHILD_ENV, resolveClaudeChildEnv, type AnthropicAuthRegistry } from "./child-env.js";
27
+ import { resolveClaudeCodeExecutable } from "./claude-executable.js";
27
28
 
28
29
  // Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
29
30
  const _piAi = piAi as any;
@@ -87,7 +88,7 @@ function debug(...args: unknown[]) {
87
88
  // Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
88
89
  // CLI subprocess to write its own debug log to a file we choose, and also
89
90
  // forward its stderr into our debug stream. Drops straight into the real SDK's
90
- // Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
91
+ // Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:2091 (debug, debugFile,
91
92
  // stderr). Without this, CC's internal view of the world is invisible to us
92
93
  // and "No conversation found" / empty-error reports are unactionable.
93
94
  let nextCliDebugSeq = 1;
@@ -365,18 +366,33 @@ function newAssistantOutput(model: Model<any>, text: string, stopReason: Assista
365
366
  };
366
367
  }
367
368
 
368
- function extractIsolatedSummaryPrompt(messages: Context["messages"]): string {
369
+ function extractStandalonePrompt(context: Context): string {
370
+ if (context.tools?.length) {
371
+ throw new Error(`standalone provider requests do not support tools (got ${context.tools.length})`);
372
+ }
373
+ const messages = context.messages;
369
374
  if (messages.length !== 1 || messages[0].role !== "user") {
370
375
  throw new Error(
371
- `isolatedStreamFn: expected exactly 1 user message, got ${messages.length} ` +
376
+ `standalone provider request expected exactly 1 user message, got ${messages.length} ` +
372
377
  `(${messages.map((m) => m.role).join(",")})`,
373
378
  );
374
379
  }
375
380
  const promptText = extractUserPrompt(messages);
376
- if (!promptText) throw new Error("isolatedStreamFn: summarization prompt is empty");
381
+ if (!promptText) throw new Error("standalone provider request prompt is empty");
377
382
  return promptText;
378
383
  }
379
384
 
385
+ /** Pi marks summaries and extension-owned nested completions as no-cache,
386
+ * standalone requests. They must never enter the resumable provider path: its
387
+ * prompt capture and shared Claude Code session belong to the interactive
388
+ * agent, not to an unrelated planner or summarizer. */
389
+ function isStandaloneRequest(context: Context, options?: SimpleStreamOptions): boolean {
390
+ return options?.cacheRetention === "none"
391
+ && context.tools === undefined
392
+ && context.messages.length === 1
393
+ && context.messages[0]?.role === "user";
394
+ }
395
+
380
396
  /** Failure text for an SDK result, or undefined when it succeeded. CC reports API failures
381
397
  * (429 capacity, overload, prompt-too-long) with `is_error` on an otherwise success-shaped
382
398
  * result; the dedicated error subtypes carry `errors` instead. */
@@ -406,13 +422,13 @@ function describeRateLimitFailure(rejection: { rateLimitType?: string; resetsAt?
406
422
  return `Claude rate limit${kind}${resets}: ${failure}`;
407
423
  }
408
424
 
409
- function isolatedStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
425
+ function standaloneStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
410
426
  const stream = newAssistantMessageEventStream();
411
- void runIsolatedSummary(model, context, options, stream);
427
+ void runStandaloneRequest(model, context, options, stream);
412
428
  return stream;
413
429
  }
414
430
 
415
- async function runIsolatedSummary(
431
+ async function runStandaloneRequest(
416
432
  model: Model<any>,
417
433
  context: Context,
418
434
  options: SimpleStreamOptions | undefined,
@@ -427,12 +443,20 @@ async function runIsolatedSummary(
427
443
  };
428
444
 
429
445
  try {
430
- const promptText = extractIsolatedSummaryPrompt(context.messages);
446
+ const promptText = extractStandalonePrompt(context);
431
447
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
432
- const compactProviderSettings = loadConfig(cwd).provider;
433
- const claudeExecutable = compactProviderSettings?.pathToClaudeCodeExecutable;
448
+ const standaloneProviderSettings = loadConfig(cwd).provider;
449
+ const claudeExecutableResolution = resolveClaudeCodeExecutable(model.id, standaloneProviderSettings?.pathToClaudeCodeExecutable);
450
+ if (claudeExecutableResolution.error) throw new Error(claudeExecutableResolution.error);
451
+ const claudeExecutable = claudeExecutableResolution.path;
434
452
  const cliModel = claudeCodeModelId(model, longContextSettings);
435
- debug(`compact summary: spawn model=${cliModel} registeredModel=${model.id} promptLen=${promptText.length}`);
453
+ const effort = options?.reasoning
454
+ ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
455
+ ?? REASONING_TO_EFFORT[options.reasoning]
456
+ : undefined;
457
+ const extraArgs: Record<string, string | null> = {};
458
+ if (effort || adaptiveThinkingAlwaysOn(model.id)) extraArgs["thinking-display"] = "summarized";
459
+ debug(`standalone: spawn model=${cliModel} registeredModel=${model.id} promptLen=${promptText.length} effort=${effort ?? "default"}`);
436
460
  const childEnv = await resolveClaudeChildEnv(piModelRegistry);
437
461
 
438
462
  sdkQuery = query({
@@ -449,8 +473,10 @@ async function runIsolatedSummary(
449
473
  systemPrompt: context.systemPrompt,
450
474
  model: cliModel,
451
475
  maxTurns: 1,
476
+ extraArgs,
477
+ ...(effort ? { effort } : {}),
452
478
  ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
453
- ...makeCliDebugOptions("compact-summary"),
479
+ ...makeCliDebugOptions("standalone"),
454
480
  },
455
481
  });
456
482
 
@@ -462,11 +488,12 @@ async function runIsolatedSummary(
462
488
  let assistantText = "";
463
489
  let finalText = "";
464
490
  let errorText: string | undefined;
491
+ let resultUsage: Record<string, number | undefined> | undefined;
465
492
  let firstEventLogged = false;
466
493
 
467
494
  for await (const message of sdkQuery) {
468
495
  if (!firstEventLogged) {
469
- debug(`compact summary: first event type=${message.type}`);
496
+ debug(`standalone: first event type=${message.type}`);
470
497
  firstEventLogged = true;
471
498
  }
472
499
  if (wasAborted) break;
@@ -476,15 +503,16 @@ async function runIsolatedSummary(
476
503
  if (block.type === "text" && typeof block.text === "string") assistantText += block.text;
477
504
  }
478
505
  } else if (message.type === "result") {
479
- logServedContextWindow("compact summary", message, model);
506
+ logServedContextWindow("standalone", message, model);
480
507
  errorText = resultErrorText(message);
508
+ resultUsage = (message as SDKMessage & { usage?: Record<string, number | undefined> }).usage;
481
509
  if (!errorText && message.subtype === "success") finalText = message.result || assistantText;
482
510
  }
483
511
  }
484
512
 
485
513
  if (wasAborted) {
486
514
  const output = newAssistantOutput(model, "", "aborted", "Operation aborted");
487
- debug("compact summary: aborted");
515
+ debug("standalone: aborted");
488
516
  stream.push({ type: "error", reason: "aborted", error: output });
489
517
  stream.end();
490
518
  return;
@@ -492,19 +520,21 @@ async function runIsolatedSummary(
492
520
 
493
521
  const text = finalText || assistantText;
494
522
  if (errorText || !text.trim()) {
495
- const msg = errorText ?? "Claude Code summary returned empty text";
496
- debug(`compact summary: error ${msg}`);
523
+ const msg = errorText ?? "Claude Code standalone request returned empty text";
524
+ debug(`standalone: error ${msg}`);
497
525
  stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", msg) });
498
526
  stream.end();
499
527
  return;
500
528
  }
501
529
 
502
- debug(`compact summary: done textLen=${text.length}`);
503
- stream.push({ type: "done", reason: "stop", message: newAssistantOutput(model, text, "stop") });
530
+ const output = newAssistantOutput(model, text, "stop");
531
+ if (resultUsage) updateUsage(output, resultUsage, model);
532
+ debug(`standalone: done textLen=${text.length}`);
533
+ stream.push({ type: "done", reason: "stop", message: output });
504
534
  stream.end();
505
535
  } catch (err) {
506
536
  const msg = errorMessage(err);
507
- debug("runIsolatedSummary threw; pushing terminal error", err);
537
+ debug("runStandaloneRequest threw; pushing terminal error", err);
508
538
  stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", msg) });
509
539
  stream.end();
510
540
  } finally {
@@ -513,17 +543,6 @@ async function runIsolatedSummary(
513
543
  }
514
544
  }
515
545
 
516
- function reinjectPriorCompactionFileOps(branchEntries: Array<{ type: string; details?: unknown }>, preparation: { fileOps: { read: Set<string>; edited: Set<string> } }): void {
517
- const prior = [...branchEntries]
518
- .reverse()
519
- .find((entry): entry is CompactionEntry => entry.type === "compaction");
520
- const details = prior?.details as { readFiles?: unknown; modifiedFiles?: unknown } | undefined;
521
- if (!Array.isArray(details?.readFiles) || !Array.isArray(details?.modifiedFiles)) return;
522
- for (const file of details.readFiles) preparation.fileOps.read.add(String(file));
523
- for (const file of details.modifiedFiles) preparation.fileOps.edited.add(String(file));
524
- debug(`compact takeover: re-injected prior file ops read=${details.readFiles.length} modified=${details.modifiedFiles.length}`);
525
- }
526
-
527
546
  interface SyncResult {
528
547
  sessionId: string | null;
529
548
  preserveSharedSession?: boolean;
@@ -640,8 +659,8 @@ function syncSharedSession(
640
659
  // the completion handler). Remove this branch and a subagent resumes — then
641
660
  // overwrites — the parent's session.
642
661
  //
643
- // It is NOT, despite an earlier comment here, the isolated compact-summary
644
- // path: runIsolatedSummary never calls syncSharedSession at all.
662
+ // It is not the standalone no-cache path: runStandaloneRequest never calls
663
+ // syncSharedSession at all.
645
664
  //
646
665
  // Only reachable when needsRebuild is false — user-facing history rewrites
647
666
  // (/compact, session_tree, /new, fork) always set needsRebuild or clear
@@ -719,7 +738,8 @@ export const __test = {
719
738
  drainForAbort,
720
739
  CC_CHILD_ENV,
721
740
  buildMcpServers,
722
- branchSummaryOutcome,
741
+ isStandaloneRequest,
742
+ extractStandalonePrompt,
723
743
  };
724
744
 
725
745
  // --- Provider helpers: tool name mapping ---
@@ -830,25 +850,6 @@ function reportLeaks(label: string): void {
830
850
  );
831
851
  }
832
852
 
833
- /** What pi's branch summary means for the navigation it was asked for.
834
- *
835
- * Cancelling on failure matches pi's own path, which rethrows a summary error out
836
- * of the navigation rather than moving without one. Separated from the event
837
- * handler so this decision is testable without a Claude Code subprocess — driving
838
- * `generateBranchSummary` itself would only be testing pi. */
839
- function branchSummaryOutcome(result: BranchSummaryResult): { cancel: true } | { summary: { summary: string; details: unknown; usage?: BranchSummaryResult["usage"] } } {
840
- if (result.aborted) return { cancel: true };
841
- if (result.error) throw new Error(result.error);
842
- debug(`session_before_tree: takeover complete summaryLen=${result.summary?.length ?? 0}`);
843
- return {
844
- summary: {
845
- summary: result.summary ?? "",
846
- details: { readFiles: result.readFiles ?? [], modifiedFiles: result.modifiedFiles ?? [] },
847
- usage: result.usage,
848
- },
849
- };
850
- }
851
-
852
853
  function contextForToolResults(results: McpResult[]): QueryContext | undefined {
853
854
  for (const result of results) {
854
855
  const id = result.toolCallId;
@@ -1421,9 +1422,15 @@ function drainForAbort(c: QueryContext, promptStream: PromptStream): void {
1421
1422
  c.releasePendingToolCalls("Operation aborted");
1422
1423
  }
1423
1424
 
1424
- /** Provider entry point. Pi calls this for each new prompt and each tool result.
1425
- * Two cases: tool result delivery (active query) or fresh query. */
1425
+ /** Provider entry point. Pi calls this for normal agent turns, but also for
1426
+ * standalone no-cache completions made by compaction and extensions. Route the
1427
+ * latter before touching prompt captures or resumable-session state. */
1426
1428
  function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1429
+ if (isStandaloneRequest(context, options)) {
1430
+ debug(`provider: routing standalone cacheRetention=none request to isolated subprocess`);
1431
+ return standaloneStreamFn(model, context, options);
1432
+ }
1433
+
1427
1434
  showStartupNoticeOnce();
1428
1435
  const stream = newAssistantMessageEventStream();
1429
1436
 
@@ -1492,6 +1499,19 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1492
1499
  const queryCtx = isReentrant ? new QueryContext() : ctx();
1493
1500
  debug(`provider: fresh query setup, isReentrant=${isReentrant}, activeContexts=${activeQueryContexts.size}`);
1494
1501
 
1502
+ // Fail before claiming a stream if this model needs a newer CLI than we have.
1503
+ const claudeExecutableResolution = resolveClaudeCodeExecutable(model.id, providerSettings.pathToClaudeCodeExecutable);
1504
+ if (claudeExecutableResolution.error) {
1505
+ debug(`provider: ${claudeExecutableResolution.error}`);
1506
+ stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", claudeExecutableResolution.error) });
1507
+ stream.end();
1508
+ return stream;
1509
+ }
1510
+ 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
+
1495
1515
  // Resolved first: an unaccountable system prompt throws, and doing that before
1496
1516
  // anything is claimed or reset leaves no half-built query behind — in particular
1497
1517
  // no stream claimed on the shared context that nobody will ever end.
@@ -1561,7 +1581,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1561
1581
  // programmatically and ignore filesystem MCP entries — applied unconditionally because
1562
1582
  // settingSources is left at CC's default, which loads all sources.
1563
1583
  const strictMcpConfigEnabled = providerSettings.strictMcpConfig !== false;
1564
- const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
1565
1584
 
1566
1585
  // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
1567
1586
  // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
@@ -1813,39 +1832,6 @@ export default function (pi: ExtensionAPI) {
1813
1832
  clearSession("session_shutdown");
1814
1833
  });
1815
1834
 
1816
- pi.on("session_before_compact", async (event, ctx) => {
1817
- if (ctx.model?.baseUrl !== "claude-bridge") return undefined;
1818
- debug(
1819
- `session_before_compact: takeover reason=${event.reason} willRetry=${event.willRetry} ` +
1820
- `isSplitTurn=${event.preparation.isSplitTurn} messages=${event.preparation.messagesToSummarize.length} ` +
1821
- `turnPrefix=${event.preparation.turnPrefixMessages.length}`,
1822
- );
1823
- try {
1824
- reinjectPriorCompactionFileOps(event.branchEntries, event.preparation);
1825
- const compaction = await compact(
1826
- event.preparation,
1827
- ctx.model,
1828
- undefined,
1829
- undefined,
1830
- event.customInstructions,
1831
- event.signal,
1832
- undefined,
1833
- isolatedStreamFn,
1834
- undefined,
1835
- );
1836
- debug(`session_before_compact: takeover complete summaryLen=${compaction.summary.length}`);
1837
- return { compaction };
1838
- } catch (err) {
1839
- const msg = errorMessage(err);
1840
- debug("session_before_compact: takeover failed; cancelling to avoid native compact fallback", err);
1841
- ctx.ui?.notify?.(
1842
- `pi-claude-agent-sdk compact failed (${msg}); cancelled to avoid known hang. Retry, switch model, or reduce context.`,
1843
- "error",
1844
- );
1845
- return { cancel: true };
1846
- }
1847
- });
1848
-
1849
1835
  // pi /compact and session-tree navigation (rewind / fork-at-point /
1850
1836
  // branch switch) both mutate pi's messages array out from under the
1851
1837
  // bridge. syncSharedSession's REUSE check would otherwise see
@@ -1862,38 +1848,6 @@ export default function (pi: ExtensionAPI) {
1862
1848
  pi.on("session_compact", (event) => markRebuild(`session_compact:${event.reason}:willRetry=${event.willRetry}`));
1863
1849
  pi.on("session_tree", () => markRebuild("session_tree"));
1864
1850
 
1865
- // Branch summarization — rewind or fork-at-point with "summarize" — is the other
1866
- // place pi asks the model for a summary, and unlike compaction it runs through
1867
- // the *agent's* stream function (agent-session passes `streamFn:
1868
- // this.agent.streamFunction`). On a bridge model that reaches this provider
1869
- // carrying pi's internal summarization prompt, which no `before_agent_start`
1870
- // ever recorded, so the prompt-capture resolver has nothing to resolve it to.
1871
- // Take it over the way compaction is taken over: the summary runs as its own
1872
- // Claude Code subprocess, never touching the live session or the resolver.
1873
- pi.on("session_before_tree", async (event, ctx) => {
1874
- if (ctx.model?.baseUrl !== "claude-bridge") return undefined;
1875
- const { entriesToSummarize, userWantsSummary, customInstructions, replaceInstructions } = event.preparation;
1876
- if (!userWantsSummary || entriesToSummarize.length === 0) return undefined;
1877
- debug(`session_before_tree: takeover entries=${entriesToSummarize.length} target=${event.preparation.targetId.slice(0, 8)}`);
1878
- try {
1879
- const result = await generateBranchSummary(entriesToSummarize, {
1880
- model: ctx.model,
1881
- signal: event.signal,
1882
- customInstructions,
1883
- replaceInstructions,
1884
- streamFn: isolatedStreamFn,
1885
- });
1886
- return branchSummaryOutcome(result);
1887
- } catch (err) {
1888
- debug("session_before_tree: takeover failed; cancelling navigation", err);
1889
- ctx.ui?.notify?.(
1890
- `pi-claude-agent-sdk branch summary failed (${errorMessage(err)}); navigation cancelled.`,
1891
- "error",
1892
- );
1893
- return { cancel: true };
1894
- }
1895
- });
1896
-
1897
1851
  // --- Provider ---
1898
1852
  //
1899
1853
  // Guard against re-registration when the module is loaded multiple times
package/src/models.ts CHANGED
@@ -120,6 +120,14 @@ export function thinkingBoundToPrefix(modelId: string): boolean {
120
120
  return modelId === "claude-fable-5-1" || modelId.startsWith("claude-fable-5-1[");
121
121
  }
122
122
 
123
+ /** Minimum Claude Code CLI version that will accept this model. Undefined
124
+ * means the SDK's bundled CLI is fine. Fable 5.1 400s on 2.1.141 with
125
+ * "version 2.1.251 or newer is required". */
126
+ export function minClaudeCodeVersionForModel(modelId: string): string | undefined {
127
+ if (modelId === "claude-fable-5-1" || modelId.startsWith("claude-fable-5-1[")) return "2.1.251";
128
+ return undefined;
129
+ }
130
+
123
131
  export function resolveModel<T extends { id: string }>(models: T[], input: string): T | undefined {
124
132
  const lower = input.toLowerCase();
125
133
  // Exact match first: otherwise `claude-fable-5` would hit `claude-fable-5-1`