pi-claude-agent-sdk 0.8.1 → 0.8.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 CHANGED
@@ -6,7 +6,7 @@ Pi extension that integrates Claude Code as a pi model provider via the [Agent S
6
6
 
7
7
  Use Opus/Sonnet/Haiku as models in pi, with all tool calls flowing through pi's TUI.
8
8
 
9
- **FYI:** Anthropic [announced and then unannounced](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) a change to how you would be billed for tools that use the Agent SDK like this one. As of June 15, 2026 it uses subscription quota just like Claude Code direct does.
9
+ **FYI:** Anthropic [announced and then unannounced](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) a change to how you would be billed for tools that use the Agent SDK like this one. It currently uses your regular subscription quota just like Claude Code.
10
10
 
11
11
  <p>
12
12
  <a href="assets/claude-bridge1.png"><img src="assets/claude-bridge1.png" width="49%"></a>&nbsp;
@@ -47,8 +47,6 @@ Config: `~/.pi/agent/claude-bridge.json` (global) or the project Pi config direc
47
47
  `provider`:
48
48
  - `plan` (default `"max"`) — Max (or Team Premium/Enterprise). Set to `"pro"` on a Pro plan so Opus 4.6 stays at 200K context. If it's unset, the first interactive session points this out once, then records `startupNoticeShown` (the date, `YYYY-MM-DD`) in the global config so it doesn't nag again.
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
- - `appendSystemPrompt` — append pi's project context files (global and ancestor `AGENTS.md` / `CLAUDE.md`) and skills (default `true`)
51
- - `settingSources` — CC filesystem settings to load; only applied when `appendSystemPrompt: false`
52
50
  - `strictMcpConfig` — block MCP servers from `~/.claude.json` / `.mcp.json` (default `true`). Cloud MCP (Gmail/Drive via claude.ai OAuth) is always blocked.
53
51
  - `autoMemoryEnabled` — enable Claude Code's auto-memory system (default `false`)
54
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"`.
@@ -71,3 +69,9 @@ Set `CLAUDE_BRIDGE_DEBUG=1` to enable debug output:
71
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.
72
70
 
73
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
+
73
+ ## Known issues
74
+
75
+ **Sessions get rebuilt more often than they need to be, and a rebuild is expensive.** The bridge rewrites Claude Code's session from pi's history whenever pi's messages move underneath it — after an abort, `/compact`, tree navigation, or an API error. Measured over this repo's own bridge log, a rebuild boundary loses the prompt cache roughly 58% of the time against 26% for a plain resume, so an abort-heavy session costs noticeably more than a clean one. Aborts alone are 46% of rebuilds.
76
+
77
+ **Files Claude Code edits are not carried across a rebuild.** CC records the post-edit contents as an `edited_text_file` attachment; those aren't carried, because they hang off a tool-result record rather than a prompt and so have no stable position to restore them to. The edit itself survives — it's in the history as a tool call and its result — so this costs Claude the file snapshot, not the knowledge that it made the change. `@file` expansions *are* carried.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-agent-sdk",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "private": false,
5
5
  "description": "Pi extension that uses Claude Code (via Agent SDK) as a model provider.",
6
6
  "keywords": [
@@ -41,9 +41,8 @@
41
41
  "type": "module",
42
42
  "dependencies": {
43
43
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
44
- "@anthropic-ai/sdk": "^0.73.0",
45
44
  "@modelcontextprotocol/sdk": "^1.29.0",
46
- "cc-session-io": "^0.3.2",
45
+ "cc-session-io": "^0.4.0",
47
46
  "change-case": "^5.4.4"
48
47
  },
49
48
  "peerDependencies": {
@@ -51,11 +50,12 @@
51
50
  "@earendil-works/pi-coding-agent": ">=0.82.1"
52
51
  },
53
52
  "devDependencies": {
54
- "@earendil-works/pi-ai": "^0.82.1",
55
- "@earendil-works/pi-coding-agent": "^0.82.1",
53
+ "@anthropic-ai/sdk": "^0.73.0",
54
+ "@earendil-works/pi-ai": "^0.83.0",
55
+ "@earendil-works/pi-coding-agent": "^0.83.0",
56
56
  "@types/node": "^24.13.2",
57
57
  "tsx": "^4.22.4",
58
- "typebox": "^1.3.0",
58
+ "typebox": "^1.3.7",
59
59
  "typescript": "^6.0.3"
60
60
  },
61
61
  "pi": {
package/src/agents-md.ts CHANGED
@@ -1,14 +1,8 @@
1
- // Pi owns context-file discovery. Reuse its public loader so Claude receives
2
- // the same global and hierarchical AGENTS.md/CLAUDE.md instructions as Pi.
3
-
4
- import { getAgentDir, loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
1
+ // Pi owns context-file discovery; the bridge only formats the list Pi loaded so
2
+ // Claude receives the same instructions, in the same order, that Pi applies.
5
3
 
6
4
  type ContextFile = { path: string; content: string };
7
5
 
8
- export function extractAgentsAppend(cwd: string = process.cwd()): string | undefined {
9
- return formatProjectContext(loadProjectContextFiles({ cwd, agentDir: getAgentDir() }));
10
- }
11
-
12
6
  export function formatProjectContext(contextFiles: ContextFile[]): string | undefined {
13
7
  if (contextFiles.length === 0) return undefined;
14
8
 
@@ -0,0 +1,135 @@
1
+ // Carrying Claude Code's own attachments across a session rebuild.
2
+ //
3
+ // CC expands an `@file` mention itself — pi passes `@` through untouched — and
4
+ // writes the expansion as a `type: "attachment"` record in its session file. pi
5
+ // never sees it, so rebuilding a session from pi's history drops the file while
6
+ // keeping the prompt text that referred to it: the model silently loses
7
+ // something it was reasoning about, with nothing logged.
8
+ //
9
+ // Extracted from index.ts so tests can import it without activating the extension.
10
+
11
+ import type { JsonlRecord, ImportAttachment } from "cc-session-io";
12
+ import { messageContentToText } from "./convert.js";
13
+
14
+ // Only `@file` expansions are carried. They are the one thing pi genuinely never
15
+ // sees, so a rebuild is the only chance to keep them.
16
+ //
17
+ // `edited_text_file` is deliberately excluded even though it also carries file
18
+ // content. CC writes one after editing a file, and the edit itself is already in
19
+ // pi's history as a tool call and its result, so the attachment duplicates context
20
+ // the rebuild reproduces anyway. It also usually hangs off a *tool result* record
21
+ // rather than a prompt, which has no position in the ordinal scheme below — on
22
+ // real sessions that left 81 of them unresolvable (see
23
+ // diag/attachment-coverage.mjs). Half-carrying a kind is worse than not claiming
24
+ // it: the ones that slipped through would be an arbitrary subset.
25
+ //
26
+ // Everything else CC rewrites every turn (`skill_listing`, `task_reminder`,
27
+ // `agent_listing_delta`, `mcp_instructions_delta`, …) and loses nothing.
28
+ const CONTENT_BEARING = new Set(["file"]);
29
+
30
+ export type CarriedAttachment = {
31
+ attachment: { type: string; [key: string]: unknown };
32
+ /** Position of the parent among the session's text-bearing user records. */
33
+ userOrdinal: number;
34
+ /** That record's text, to verify the ordinal still points at the same turn. */
35
+ parentText: string;
36
+ };
37
+
38
+ type Rec = Record<string, unknown>;
39
+
40
+ /** A user record holding a prompt, as opposed to one holding tool results. */
41
+ function userPromptText(record: Rec): string | undefined {
42
+ if (record.type !== "user") return undefined;
43
+ const content = (record.message as Rec | undefined)?.content;
44
+ if (Array.isArray(content) && content.some((b) => (b as Rec)?.type === "tool_result")) return undefined;
45
+ const text = messageContentToText(content as never);
46
+ return text ? text : undefined;
47
+ }
48
+
49
+ /**
50
+ * Content-bearing attachments in a session, each tagged with where its parent
51
+ * sits among the text-bearing user records.
52
+ *
53
+ * The ordinal is the mapping key rather than the record index: a rebuild does not
54
+ * reproduce the old record list one-for-one — `importMessages` splits a message
55
+ * carrying tool results into two records, and CC appends records of its own — but
56
+ * the sequence of user prompts is the same conversation either way.
57
+ *
58
+ * Attachments also chain to one another, so an ordinal is resolved transitively up
59
+ * the parent links until it reaches a prompt. Most real attachments are
60
+ * `edited_text_file` records CC writes after editing a file, which have nothing to
61
+ * do with at-mentions; only their position in the conversation matters here.
62
+ */
63
+ export function collectCarriedAttachments(records: readonly JsonlRecord[]): CarriedAttachment[] {
64
+ const ordinalOf = new Map<string, number>();
65
+ const textOf = new Map<string, string>();
66
+ let ordinal = 0;
67
+ const carried: CarriedAttachment[] = [];
68
+
69
+ for (const raw of records) {
70
+ const record = raw as Rec;
71
+ const prompt = userPromptText(record);
72
+ if (prompt !== undefined) {
73
+ ordinalOf.set(record.uuid as string, ordinal++);
74
+ textOf.set(record.uuid as string, prompt);
75
+ continue;
76
+ }
77
+ if (record.type !== "attachment") continue;
78
+ const parent = record.parentUuid as string | null;
79
+ // Attachments chain to each other — a run of them hangs off one prompt, and
80
+ // 63 of 179 in real sessions parent to another attachment rather than to a
81
+ // message. Inherit the ordinal so the whole run keys to the prompt that
82
+ // caused it. Recorded for every attachment, not just the ones carried, since
83
+ // a content-bearing one can chain off a `skill_listing` we ignore.
84
+ if (parent === null || !ordinalOf.has(parent)) continue;
85
+ const inherited = ordinalOf.get(parent)!;
86
+ ordinalOf.set(record.uuid as string, inherited);
87
+ textOf.set(record.uuid as string, textOf.get(parent)!);
88
+
89
+ const attachment = record.attachment as { type: string; [key: string]: unknown } | undefined;
90
+ if (!attachment || !CONTENT_BEARING.has(attachment.type)) continue;
91
+ carried.push({ attachment, userOrdinal: inherited, parentText: textOf.get(parent)! });
92
+ }
93
+ return carried;
94
+ }
95
+
96
+ /**
97
+ * Resolve each carried attachment to a position in the array about to be
98
+ * imported — the messages *after* conversion and repair, since that is the index
99
+ * space `importMessages` reads. Repair is idempotent, so an already-repaired array
100
+ * passes through its second run unchanged and the indices stay valid.
101
+ *
102
+ * Deliberately conservative: attaching a file to the wrong turn tells the model it
103
+ * saw something at a point it did not, which is worse than the loss this exists to
104
+ * prevent. So the ordinal has to land on a prompt whose text still matches; any
105
+ * disagreement is reported and dropped rather than approximated.
106
+ */
107
+ export function placeCarriedAttachments(
108
+ carried: readonly CarriedAttachment[],
109
+ messages: readonly { role: string; content: unknown }[],
110
+ ): { attachments: ImportAttachment[]; skipped: string[] } {
111
+ const prompts: { index: number; text: string }[] = [];
112
+ messages.forEach((msg, index) => {
113
+ if (msg.role !== "user") return;
114
+ if (Array.isArray(msg.content) && msg.content.some((b) => (b as Rec)?.type === "tool_result")) return;
115
+ const text = messageContentToText(msg.content as never);
116
+ if (text) prompts.push({ index, text });
117
+ });
118
+
119
+ const attachments: ImportAttachment[] = [];
120
+ const skipped: string[] = [];
121
+ for (const item of carried) {
122
+ const name = String(item.attachment.filename ?? item.attachment.type);
123
+ const candidate = prompts[item.userOrdinal];
124
+ if (!candidate) {
125
+ skipped.push(`${name}: prompt #${item.userOrdinal} is no longer in history`);
126
+ continue;
127
+ }
128
+ if (candidate.text !== item.parentText) {
129
+ skipped.push(`${name}: prompt #${item.userOrdinal} changed`);
130
+ continue;
131
+ }
132
+ attachments.push({ afterIndex: candidate.index, attachment: item.attachment });
133
+ }
134
+ return { attachments, skipped };
135
+ }
package/src/config.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  // unparseable files are ignored (error to console.error, empty object
5
5
  // returned) so the extension always starts.
6
6
 
7
- import type { SettingSource } from "@anthropic-ai/claude-agent-sdk";
8
7
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
9
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
9
  import { dirname, join } from "path";
@@ -14,8 +13,6 @@ export interface Config {
14
13
  startupNoticeShown?: string;
15
14
  /** Low-level Claude Agent SDK plumbing. Most users won't need these. */
16
15
  provider?: {
17
- appendSystemPrompt?: boolean;
18
- settingSources?: SettingSource[];
19
16
  strictMcpConfig?: boolean;
20
17
  autoMemoryEnabled?: boolean;
21
18
  pathToClaudeCodeExecutable?: string;
@@ -46,12 +43,26 @@ export function globalConfigPath(): string {
46
43
  return join(getAgentDir(), "claude-bridge.json");
47
44
  }
48
45
 
49
- /** Record today's date in the global config so the startup notice shows once. Preserves every other field. */
46
+ /** Record today's date in the global config so the startup notice shows once, preserving every
47
+ * other field. Returns the config path for display either way.
48
+ *
49
+ * Parses directly rather than through tryParseJson, which reports an unparseable file as `{}`:
50
+ * spreading that would replace a user's whole config with just this marker the first time they
51
+ * leave a trailing comma in it. Losing the notice is the cheaper failure, so the write is
52
+ * skipped and the notice simply shows again next session. */
50
53
  export function markStartupNoticeShown(): string {
51
54
  const path = globalConfigPath();
55
+ let existing: Partial<Config> = {};
56
+ if (existsSync(path)) {
57
+ try {
58
+ existing = JSON.parse(readFileSync(path, "utf-8"));
59
+ } catch (e) {
60
+ console.error(`claude-bridge: leaving ${path} alone, it does not parse: ${e}`);
61
+ return path;
62
+ }
63
+ }
52
64
  // en-CA renders YYYY-MM-DD in local time; toISOString() would report UTC.
53
- const today = new Date().toLocaleDateString("en-CA");
54
- const next = { ...tryParseJson(path), startupNoticeShown: today };
65
+ const next = { ...existing, startupNoticeShown: new Date().toLocaleDateString("en-CA") };
55
66
  mkdirSync(dirname(path), { recursive: true });
56
67
  writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
57
68
  return path;
package/src/convert.ts CHANGED
@@ -108,13 +108,25 @@ function toolResultContent(
108
108
  return blocks;
109
109
  }
110
110
 
111
+ /** What convertPiMessages discarded, for the debug line in index.ts. */
112
+ export type DroppedContent = {
113
+ thinking: number;
114
+ abortedTurns: number;
115
+ providers: Set<string>;
116
+ other: Map<string, number>;
117
+ };
118
+
111
119
  /** Convert pi message array to Anthropic API format. */
112
120
  export function convertPiMessages(
113
121
  messages: PiMessage[],
114
122
  customToolNameToSdk?: Map<string, string>,
115
- ): { anthropicMessages: SessionMessage[]; sanitizedIds: Map<string, string> } {
123
+ ): { anthropicMessages: SessionMessage[]; sanitizedIds: Map<string, string>; dropped: DroppedContent } {
116
124
  const anthropicMessages = [];
117
125
  const sanitizedIds = new Map();
126
+ // What conversion discarded. Nothing downstream can tell: a stripped thinking
127
+ // block and a message that never carried one convert to the same thing, so
128
+ // without this the loss is invisible in the log and in a captured request.
129
+ const dropped: DroppedContent = { thinking: 0, abortedTurns: 0, providers: new Set(), other: new Map() };
118
130
  // The user message collecting this assistant turn's tool results, if one has
119
131
  // been emitted yet, and the index of the assistant message it belongs to. Both
120
132
  // are cleared at every assistant message — see the toolResult branch.
@@ -138,8 +150,6 @@ export function convertPiMessages(
138
150
  anthropicMessages.push({ role: "user", content: "[empty]" });
139
151
  }
140
152
  } else if (msg.role === "assistant") {
141
- turnResults = null;
142
- turnAssistantIdx = anthropicMessages.length;
143
153
  const content = Array.isArray(msg.content) ? msg.content : [];
144
154
  const blocks = [];
145
155
  for (const block of content) {
@@ -152,13 +162,39 @@ export function convertPiMessages(
152
162
  const sig = block.thinkingSignature;
153
163
  if (msg.provider === PROVIDER_ID && sig) {
154
164
  blocks.push({ type: "thinking", thinking: block.thinking ?? "", signature: sig });
165
+ } else {
166
+ dropped.thinking++;
167
+ dropped.providers.add(msg.provider ?? "unknown");
155
168
  }
156
169
  } else if (block.type === "toolCall") {
157
170
  const toolName = mapPiToolNameToSdk(block.name, customToolNameToSdk);
158
171
  blocks.push({ type: "tool_use", id: sanitizeToolId(block.id, sanitizedIds), name: toolName, input: block.arguments ?? {} });
172
+ } else {
173
+ dropped.other.set(block.type, (dropped.other.get(block.type) ?? 0) + 1);
159
174
  }
160
175
  }
176
+ // A turn the user aborted before anything streamed carries no content at
177
+ // all. Standing a placeholder in its place invents a reply the assistant
178
+ // never made, and because it lands early in the prefix it costs the whole
179
+ // downstream prompt cache every time the session is rebuilt. Drop it:
180
+ // Session.importMessages imposes no alternation, and a turn with no blocks
181
+ // has no tool_use ids needing a synthetic result. Left before the turn
182
+ // bookkeeping so a stray result still attaches to the last assistant
183
+ // message actually emitted.
184
+ //
185
+ // Do NOT clear turnResults/turnAssistantIdx here. It looks like the tidy
186
+ // thing to do, but an abort between two parallel results — assistant[X,Y],
187
+ // R_X, aborted turn, R_Y — would then start a second results message for
188
+ // R_Y. repairToolPairing consumes both pending ids at the first one, stubs
189
+ // Y there and drops the real R_Y as unmatched, destroying the parallel
190
+ // result this merge exists to preserve. unit-import.mjs pins the shape.
191
+ if (!content.length) { dropped.abortedTurns++; continue; }
192
+ // Blocks were present but every one was filtered — content really was
193
+ // dropped here, so keep the slot and say so. Empty content is rejected by
194
+ // the API, and dropping the message would break tool pairing.
161
195
  if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
196
+ turnResults = null;
197
+ turnAssistantIdx = anthropicMessages.length;
162
198
  anthropicMessages.push({ role: "assistant", content: blocks });
163
199
  } else if (msg.role === "toolResult") {
164
200
  // Pi records one message per tool result, and repairToolPairing only
@@ -200,5 +236,5 @@ export function convertPiMessages(
200
236
  }
201
237
  }
202
238
 
203
- return { anthropicMessages, sanitizedIds };
239
+ return { anthropicMessages, sanitizedIds, dropped };
204
240
  }
package/src/index.ts CHANGED
@@ -1,22 +1,26 @@
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, type CompactionEntry, type ExtensionAPI, type ExtensionContext, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
+ import { compact, generateBranchSummary, type BranchSummaryResult, type CompactionEntry, 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
- import { createSession, deleteSession, repairToolPairing } from "cc-session-io";
7
+ import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
8
8
  import { appendFileSync, mkdirSync, realpathSync, statSync } from "fs";
9
9
  import { homedir } from "os";
10
10
  import { dirname, join } from "path";
11
11
  import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
12
12
  import { applyLongContext, buildModels, claudeCodeModelId, type LongContextSettings } from "./models.js";
13
- import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
13
+ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
14
14
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
15
15
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
16
16
  import { QueryContext, ctx } from "./query-state.js";
17
17
  import { makePromptStream, userMessage, type PromptStream } from "./prompt-stream.js";
18
18
  import { claudeCodeSettings, loadConfig, markStartupNoticeShown, type Config } from "./config.js";
19
- import { extractAgentsAppend } from "./agents-md.js";
19
+ import {
20
+ projectPromptCapture,
21
+ PromptCaptures,
22
+ } from "./prompt-capture.js";
23
+ import { collectCarriedAttachments, placeCarriedAttachments, type CarriedAttachment } from "./attachments.js";
20
24
  import { createToolServer } from "./mcp-server.js";
21
25
  import { CC_CHILD_ENV, resolveClaudeChildEnv, type AnthropicAuthRegistry } from "./child-env.js";
22
26
 
@@ -41,6 +45,19 @@ const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
41
45
  const RECORD_STREAM_PATH = process.env.CLAUDE_BRIDGE_RECORD_STREAM;
42
46
 
43
47
 
48
+ // Pi owns context files on the provider path, so Claude Code must not load its
49
+ // own on top: otherwise a project CLAUDE.md arrives twice, and the user's
50
+ // ~/.claude/CLAUDE.md — a persona written for a harness that is not the one
51
+ // running — arrives at all, stamped "These instructions OVERRIDE any default
52
+ // behavior" and outranking Pi's own AGENTS.md.
53
+ //
54
+ // Excludes rather than settingSources: the source gate that suppresses CLAUDE.md
55
+ // is the same one that reads settings.json, where Bedrock/Vertex users keep
56
+ // `env` and `apiKeyHelper`. Patterns are matched with picomatch against absolute
57
+ // paths; "**/CLAUDE.md" covers the user, ancestor, project and .claude/ copies,
58
+ // while rules need their own. Managed/policy memory is not excludable by design.
59
+ const CLAUDE_MD_EXCLUDES = ["**/CLAUDE.md", "**/.claude/rules/**"];
60
+
44
61
  // Ensure log directories exist when debug is enabled
45
62
  if (DEBUG) {
46
63
  try {
@@ -158,19 +175,43 @@ interface SessionState {
158
175
  forceRotate?: boolean;
159
176
  }
160
177
 
178
+ /**
179
+ * Claude Code's `@file` expansions from the session about to be replaced.
180
+ *
181
+ * Must be called before `deleteSession`, which wipes the file they live in —
182
+ * reading after it yields nothing, with no error to notice.
183
+ */
184
+ function readCarriedAttachments(sessionId: string, cwd: string): CarriedAttachment[] {
185
+ try {
186
+ const previous = openSession({ sessionId, projectPath: cwd, claudeDir: process.env.CLAUDE_CONFIG_DIR });
187
+ return collectCarriedAttachments(previous.records);
188
+ } catch (error) {
189
+ // A post-abort rebuild reads a file the killed CC subprocess may have been
190
+ // midway through writing, and cc-session-io parses each line with a bare
191
+ // JSON.parse, so a truncated last line throws. Throwing here would turn a
192
+ // lost attachment into a failed turn; carrying none is exactly what happened
193
+ // before this existed, so the failure mode is bounded by the status quo.
194
+ debug(`WARNING: could not read attachments from session ${sessionId.slice(0, 8)}:`, error);
195
+ return [];
196
+ }
197
+ }
198
+
161
199
  let sharedSession: SessionState | null = null;
162
200
 
163
201
  // Convert pi messages to Anthropic API format for session import.
164
- // Lossy: non-Anthropic thinking blocks are dropped (no valid signature), and only
165
- // text/image/toolCall block types are handled. If all blocks in an assistant message
166
- // are filtered, the message is dropped — which can create invalid sequences (e.g.
167
- // two user messages in a row, or tool_result without preceding tool_use).
202
+ // Lossy: only text, thinking and toolCall blocks survive, and thinking only when
203
+ // Claude Code itself minted the signature. An assistant message whose blocks all
204
+ // filter out keeps its slot with a placeholder, since dropping it can create a
205
+ // tool_result with no preceding tool_use. A turn aborted before anything streamed
206
+ // is dropped instead — it never had content, and inventing one diverges from the
207
+ // prefix Claude Code cached.
168
208
  function convertAndImportMessages(
169
209
  session: ReturnType<typeof createSession>,
170
210
  messages: Context["messages"],
171
211
  customToolNameToSdk?: Map<string, string>,
212
+ carried?: readonly CarriedAttachment[],
172
213
  ): void {
173
- const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
214
+ const { anthropicMessages, sanitizedIds, dropped } = convertPiMessages(messages, customToolNameToSdk);
174
215
 
175
216
  debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
176
217
  debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
@@ -179,6 +220,16 @@ function convertAndImportMessages(
179
220
  if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
180
221
  return `[${i}]${m.role}:?`;
181
222
  }).join(" "));
223
+ // The roles line above shows only what survived, so a stripped block is
224
+ // indistinguishable there from one that never existed. Name the losses.
225
+ const droppedParts = [
226
+ dropped.thinking ? `${dropped.thinking} thinking (${[...dropped.providers].sort().join(", ")})` : "",
227
+ dropped.abortedTurns ? `${dropped.abortedTurns} aborted turn(s)` : "",
228
+ ...[...dropped.other].map(([type, n]) => `${n} ${type}`),
229
+ ].filter(Boolean);
230
+ if (droppedParts.length > 0) {
231
+ debug(`convertAndImportMessages: dropped ${droppedParts.join(", ")}`);
232
+ }
182
233
  if (sanitizedIds.size > 0) {
183
234
  debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
184
235
  [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
@@ -188,7 +239,21 @@ function convertAndImportMessages(
188
239
  if (repaired.length !== anthropicMessages.length) {
189
240
  debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
190
241
  }
191
- if (repaired.length) session.importMessages(repaired);
242
+ // Placement runs against the repaired array because that is the index space
243
+ // importMessages reads. Attachments are links in CC's uuid chain, so they have
244
+ // to be written in order with the messages, not appended afterwards.
245
+ const placed = carried?.length
246
+ ? placeCarriedAttachments(carried, repaired as unknown as { role: string; content: unknown }[])
247
+ : undefined;
248
+ if (placed?.skipped.length) {
249
+ debug(`convertAndImportMessages: dropped ${placed.skipped.length} carried attachment(s): ${placed.skipped.join("; ")}`);
250
+ }
251
+ if (placed?.attachments.length) {
252
+ debug(`convertAndImportMessages: carrying ${placed.attachments.length} attachment(s) across the rebuild`);
253
+ }
254
+ if (repaired.length) {
255
+ session.importMessages(repaired, placed?.attachments.length ? { attachments: placed.attachments } : undefined);
256
+ }
192
257
  }
193
258
 
194
259
  // Pi doesn't pass tool results directly — it appends them to the context and calls
@@ -317,6 +382,24 @@ function resultErrorText(message: SDKMessage): string | undefined {
317
382
  return `Claude Code failed: ${result.subtype ?? "unknown result"}`;
318
383
  }
319
384
 
385
+ /** Name a failure as a rate limit when a rejection preceded it.
386
+ *
387
+ * pi has no typed rate-limit error — `stopReason` is only ever `"error"` and the sole carrier
388
+ * is `errorMessage` — so everything that reacts to a rate limit pattern-matches that string:
389
+ * pi-subagents gates `fallbackModels` on a 35-pattern list, and key-rotating extensions use
390
+ * their own. Claude Code words a subscription limit as "You're out of extra usage · resets
391
+ * 6:30pm", which matches none of them, so an exhausted quota reads as a fatal error and the
392
+ * fallback chain never runs (issue #58).
393
+ *
394
+ * Leading with "Claude rate limit" rather than appending keeps the phrase in any truncated
395
+ * render, and avoids the `<tool> failed (exit N):` shape that pi-subagents treats as a tool
396
+ * failure and refuses to retry. */
397
+ function describeRateLimitFailure(rejection: { rateLimitType?: string; resetsAt?: number }, failure: string): string {
398
+ const kind = rejection.rateLimitType ? ` (${rejection.rateLimitType})` : "";
399
+ const resets = rejection.resetsAt ? ` — resets ${new Date(rejection.resetsAt * 1000).toLocaleTimeString()}` : "";
400
+ return `Claude rate limit${kind}${resets}: ${failure}`;
401
+ }
402
+
320
403
  function isolatedStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
321
404
  const stream = newAssistantMessageEventStream();
322
405
  void runIsolatedSummary(model, context, options, stream);
@@ -576,6 +659,8 @@ function syncSharedSession(
576
659
  // and for any tools that key off them. Skipped only when there's a
577
660
  // concurrent writer we shouldn't race — see forceRotate docs above.
578
661
  const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
662
+ // Before deleteSession — it wipes the file these live in.
663
+ const carried = previousSessionId !== undefined ? readCarriedAttachments(previousSessionId, cwd) : [];
579
664
  if (preserveId) {
580
665
  // Wipe prior jsonl + companion dir (no-op if nothing to wipe).
581
666
  deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
@@ -586,17 +671,19 @@ function syncSharedSession(
586
671
  ...(preserveId ? { sessionId: previousSessionId } : {}),
587
672
  ...(modelId ? { model: modelId } : {}),
588
673
  });
589
- convertAndImportMessages(session, priorMessages, customToolNameToSdk);
674
+ convertAndImportMessages(session, priorMessages, customToolNameToSdk, carried);
590
675
  session.save();
591
- verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
676
+ // records, not messages: `messages` filters out the attachment records that
677
+ // carrying an `@file` expansion across a rebuild writes into the same file.
678
+ verifyWrittenSession(session.jsonlPath, session.sessionId, session.records.length, cwd);
592
679
  sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
593
680
  if (previousSessionId === undefined) {
594
- debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
681
+ debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.records.length} records`);
595
682
  } else if (preserveId) {
596
683
  const missedCount = priorMessages.length - previousCursor;
597
- debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
684
+ debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.records.length} records`);
598
685
  } else {
599
- debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.messages.length} records`);
686
+ debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.records.length} records`);
600
687
  }
601
688
  debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
602
689
  debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
@@ -614,6 +701,9 @@ export const __test = {
614
701
  getSharedSession() {
615
702
  return sharedSession;
616
703
  },
704
+ setPiUI(ui: ExtensionUIContext | null) {
705
+ piUI = ui;
706
+ },
617
707
  syncSharedSession,
618
708
  extractUserPromptBlocks,
619
709
  consumeQuery,
@@ -623,6 +713,7 @@ export const __test = {
623
713
  drainForAbort,
624
714
  CC_CHILD_ENV,
625
715
  buildMcpServers,
716
+ branchSummaryOutcome,
626
717
  };
627
718
 
628
719
  // --- Provider helpers: tool name mapping ---
@@ -682,26 +773,73 @@ const activeQueryContexts = new Set<QueryContext>();
682
773
  // provider query rather than session_start: the notice persists a flag to the global
683
774
  // config, and firing it on startup would write that file for every pi session that
684
775
  // merely has this extension installed.
685
- let planNoticePending = false;
776
+ let pendingNotices: string[] = [];
686
777
 
687
- function showPlanNoticeOnce(): void {
778
+ function showStartupNoticeOnce(): void {
688
779
  // `hasUI` is true in RPC mode too — it means dialogs are possible, not that a
689
780
  // human is watching. Only a terminal user can act on this.
690
- if (!planNoticePending || piMode !== "tui") return;
691
- planNoticePending = false;
781
+ if (pendingNotices.length === 0 || piMode !== "tui") return;
782
+ const notices = pendingNotices;
783
+ pendingNotices = [];
692
784
  const path = markStartupNoticeShown();
693
- piUI?.notify(
694
- `pi-claude-agent-sdk: assuming a Max plan. On Pro, set provider.plan to "pro" in ${path} so Opus 4.6 stays at 200K context.`,
695
- "info",
785
+ // pi wraps the whole notify string in the theme's dim foreground; the inner reset
786
+ // drops back to the terminal default rather than dim, which is fine here.
787
+ const title = `\x1b[33mWelcome to pi-claude-agent-sdk\x1b[39m — settings live in ${path}`;
788
+ const bullets = [...notices, "This message only appears once. See README.md for more."].map((n) => `• ${n}`);
789
+ piUI?.notify([title, ...bullets, "─".repeat(64)].join("\n"), "info");
790
+ }
791
+
792
+ // Captures of what pi assembled per agent; see src/prompt-capture.ts for why this
793
+ // is keyed rather than held in a single slot.
794
+ const promptCaptures = new PromptCaptures(256, (diagnostic) => {
795
+ const first = diagnostic.matches[0];
796
+ debug(
797
+ `prompt-capture: no match for ${diagnostic.systemPrompt.length}-char system prompt. `
798
+ + (first
799
+ ? `closest known (${first.key.length}-char) shares its first ${first.firstDivergent} chars and diverges at offset ${first.firstDivergent}: `
800
+ + JSON.stringify(diagnostic.systemPrompt.slice(first.firstDivergent - 40, first.firstDivergent + 60))
801
+ : "no known captures to compare against."
802
+ ) + ` known keys=${diagnostic.matches.length}`,
803
+ );
804
+ });
805
+
806
+ /** Whatever a settled session left behind, named in one greppable line.
807
+ *
808
+ * Every one of these should be empty once the last turn ends, and each is a leak
809
+ * that costs something real: a retained context routes a later orphaned tool result
810
+ * into the delivery path and returns a stream nobody ends; a pending tool call is an
811
+ * MCP handler Claude Code is still waiting on; a live prompt stream is an unresolved
812
+ * ack. The activeQueryContexts leak was present on every single happy-path run and
813
+ * no test noticed, because nothing asserted that anything ends clean — so assert it
814
+ * where the real sessions are, and let diag/audit-warnings.mjs scan for it. */
815
+ function reportLeaks(label: string): void {
816
+ const pendingCalls = [...activeQueryContexts].reduce((n, c) => n + c.pendingToolCalls.size, 0);
817
+ const liveStreams = [...activeQueryContexts].filter((c) => c.promptStream !== null).length;
818
+ if (activeQueryContexts.size === 0 && pendingCalls === 0 && liveStreams === 0) return;
819
+ debug(
820
+ `WARNING: ${label} left state behind — contexts=${activeQueryContexts.size} `
821
+ + `pendingToolCalls=${pendingCalls} promptStreams=${liveStreams}`,
696
822
  );
697
823
  }
698
824
 
699
- // The user's own system prompt customisation (`--system-prompt`,
700
- // `--append-system-prompt`), captured from before_agent_start. pi's assembled
701
- // `context.systemPrompt` can't be forwarded wholesale — it describes pi's tools
702
- // and harness and would fight Claude Code's own preset — but the user's text is
703
- // theirs and has to reach the model, so it is kept separately.
704
- let userSystemPrompt: { custom?: string; append?: string } = {};
825
+ /** What pi's branch summary means for the navigation it was asked for.
826
+ *
827
+ * Cancelling on failure matches pi's own path, which rethrows a summary error out
828
+ * of the navigation rather than moving without one. Separated from the event
829
+ * handler so this decision is testable without a Claude Code subprocess — driving
830
+ * `generateBranchSummary` itself would only be testing pi. */
831
+ function branchSummaryOutcome(result: BranchSummaryResult): { cancel: true } | { summary: { summary: string; details: unknown; usage?: BranchSummaryResult["usage"] } } {
832
+ if (result.aborted) return { cancel: true };
833
+ if (result.error) throw new Error(result.error);
834
+ debug(`session_before_tree: takeover complete summaryLen=${result.summary?.length ?? 0}`);
835
+ return {
836
+ summary: {
837
+ summary: result.summary ?? "",
838
+ details: { readFiles: result.readFiles ?? [], modifiedFiles: result.modifiedFiles ?? [] },
839
+ usage: result.usage,
840
+ },
841
+ };
842
+ }
705
843
 
706
844
  function contextForToolResults(results: McpResult[]): QueryContext | undefined {
707
845
  for (const result of results) {
@@ -1091,6 +1229,12 @@ async function consumeQuery(
1091
1229
  logServedContextWindow("result", message, model);
1092
1230
  resultError = resultErrorText(message);
1093
1231
  if (resultError !== undefined) {
1232
+ // Consume the rejection alongside the failure it caused, so a later
1233
+ // unrelated failure on this query doesn't inherit the label.
1234
+ if (queryCtx.rateLimitRejection) {
1235
+ resultError = describeRateLimitFailure(queryCtx.rateLimitRejection, resultError);
1236
+ queryCtx.rateLimitRejection = null;
1237
+ }
1094
1238
  debug(`consumeQuery: error result, subtype=${message.subtype}, error=${resultError}`);
1095
1239
  if (queryCtx.turnOutput) {
1096
1240
  queryCtx.turnOutput.stopReason = "error";
@@ -1102,10 +1246,31 @@ async function consumeQuery(
1102
1246
  const info = (message as any).rate_limit_info;
1103
1247
  debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1104
1248
  if (info?.status === "rejected") {
1105
- const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
1249
+ // Held so the failure Claude Code sends next can be named as a rate limit.
1250
+ queryCtx.rateLimitRejection = info;
1251
+ // The "rate limited" notice below supersedes warnings; re-arm so the next
1252
+ // window's warnings fire even if it opens straight into allowed_warning.
1253
+ queryCtx.lastRateLimitWarnStep = null;
1254
+ queryCtx.lastRateLimitWarnThreshold = undefined;
1255
+ // resetsAt is Unix seconds, not milliseconds.
1256
+ const resetsAt = info.resetsAt ? new Date(info.resetsAt * 1000).toLocaleTimeString() : "unknown";
1106
1257
  piUI?.notify(`Claude rate limited (${info.rateLimitType ?? "unknown"}) — resets at ${resetsAt}`, "warning");
1258
+ } else if (info?.status === "allowed") {
1259
+ // Back under the threshold (window reset) — re-arm the warning dedupe.
1260
+ queryCtx.lastRateLimitWarnStep = null;
1261
+ queryCtx.lastRateLimitWarnThreshold = undefined;
1107
1262
  } else if (info?.status === "allowed_warning") {
1108
- piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1263
+ // utilization is a fraction (0..1); allowed_warning fires once it crosses surpassedThreshold.
1264
+ const percent = Math.round((info.utilization ?? 0) * 100);
1265
+ // The SDK emits one event per request, so only re-notify when the level
1266
+ // rises past a new 5% step or the threshold changes.
1267
+ const step = Math.floor(percent / 5);
1268
+ const rose = queryCtx.lastRateLimitWarnStep === null || step > queryCtx.lastRateLimitWarnStep;
1269
+ if (rose || info.surpassedThreshold !== queryCtx.lastRateLimitWarnThreshold) {
1270
+ queryCtx.lastRateLimitWarnStep = step;
1271
+ queryCtx.lastRateLimitWarnThreshold = info.surpassedThreshold;
1272
+ piUI?.notify(`Claude rate limit warning: ${percent}% used (${info.rateLimitType ?? ""})`, "warning");
1273
+ }
1109
1274
  }
1110
1275
  continue;
1111
1276
  }
@@ -1251,7 +1416,7 @@ function drainForAbort(c: QueryContext, promptStream: PromptStream): void {
1251
1416
  /** Provider entry point. Pi calls this for each new prompt and each tool result.
1252
1417
  * Two cases: tool result delivery (active query) or fresh query. */
1253
1418
  function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1254
- showPlanNoticeOnce();
1419
+ showStartupNoticeOnce();
1255
1420
  const stream = newAssistantMessageEventStream();
1256
1421
 
1257
1422
  // DEBUG: trace followUp message triggering
@@ -1319,6 +1484,21 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1319
1484
  const queryCtx = isReentrant ? new QueryContext() : ctx();
1320
1485
  debug(`provider: fresh query setup, isReentrant=${isReentrant}, activeContexts=${activeQueryContexts.size}`);
1321
1486
 
1487
+ // Resolved first: an unaccountable system prompt throws, and doing that before
1488
+ // anything is claimed or reset leaves no half-built query behind — in particular
1489
+ // no stream claimed on the shared context that nobody will ever end.
1490
+ const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1491
+ // Build from what Pi loaded for this run, so `--no-context-files` and
1492
+ // `--no-skills` reach Claude Code by leaving nothing to forward. A sub-agent's
1493
+ // custom override embeds its parent's assembled Pi prompt; recursive projection
1494
+ // replaces that exact inherited prompt with its already-safe portable parts.
1495
+ const promptCapture = promptCaptures.resolveOrDerive(context.systemPrompt);
1496
+ const systemPromptAppend = promptCapture
1497
+ ? projectPromptCapture(promptCapture, {
1498
+ skillReadTool: mcpTools.some((tool) => tool.name === "read") ? "mcp" : "none",
1499
+ })
1500
+ : undefined;
1501
+
1322
1502
  // 2. Fresh child context — constructor already gave us clean Maps and empty
1323
1503
  // arrays. For a reused top-level context, clear explicitly.
1324
1504
  claimCurrentPiStream(stream, "fresh-query", queryCtx);
@@ -1331,7 +1511,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1331
1511
  queryCtx.resetTurnState(model);
1332
1512
  queryCtx.latestCursor = 0;
1333
1513
 
1334
- const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1335
1514
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1336
1515
  // cliModel is the actual id sent to Claude Code (may carry [1m]); model.id is the
1337
1516
  // pi-registered id. Log cliModel so debug lines reflect what CC actually received.
@@ -1367,24 +1546,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1367
1546
  .catch((error) => debug(`provider: initial prompt push rejected:`, error));
1368
1547
  queryCtx.promptStream = promptStream;
1369
1548
  const mcpServers = buildMcpServers(mcpTools, queryCtx);
1370
- const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
1371
- const agentsAppend = appendSystemPrompt ? extractAgentsAppend(cwd) : undefined;
1372
- const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
1373
- // Last, so the user's own instructions win over anything the bridge adds, and
1374
- // ungated by appendSystemPrompt: that setting suppresses context the bridge
1375
- // injects on its own, not what the user explicitly asked for.
1376
- const appendParts = [agentsAppend, skillsAppend, userSystemPrompt.custom, userSystemPrompt.append]
1377
- .filter((part): part is string => Boolean(part));
1378
- const systemPromptAppend = appendParts.length > 0 ? appendParts.join("\n\n") : undefined;
1379
1549
 
1380
1550
  // MCP auto-loading suppression: CC reads MCP servers from ~/.claude.json (top-level
1381
1551
  // + per-project) and .mcp.json. Since pi executes tools (not CC), those are pure
1382
1552
  // token overhead. --strict-mcp-config tells the binary to use ONLY mcpServers passed
1383
1553
  // programmatically and ignore filesystem MCP entries — applied unconditionally because
1384
- // settingSources=undefined does NOT give isolation (the CC default loads all sources).
1385
- const settingSources: SettingSource[] | undefined = appendSystemPrompt
1386
- ? undefined
1387
- : providerSettings.settingSources ?? ["user", "project"];
1554
+ // settingSources is left at CC's default, which loads all sources.
1388
1555
  const strictMcpConfigEnabled = providerSettings.strictMcpConfig !== false;
1389
1556
  const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
1390
1557
 
@@ -1417,14 +1584,26 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1417
1584
  tools: [],
1418
1585
  permissionMode: "bypassPermissions",
1419
1586
  includePartialMessages: true,
1420
- settings: claudeCodeSettings(providerSettings),
1587
+ // includeGitInstructions:false drops the gitStatus block from the preset.
1588
+ // That block is the trailing suffix of the cached system block, and a
1589
+ // git-state transition (new file, staging, commit) rewrites it — busting
1590
+ // the prompt cache for the whole conversation from there on (see
1591
+ // diag/probe-git-cache.mjs). The bridge re-invokes CC per turn, so this
1592
+ // hit on every transition. Cost here is nil: the setting also strips
1593
+ // CC's git-workflow guidance from its Bash tool prompt, but the provider
1594
+ // path runs CC with `tools: []`, so those definitions never ship.
1595
+ // AskClaude keeps CC's native tools and its guidance — unaffected.
1596
+ settings: {
1597
+ ...claudeCodeSettings(providerSettings),
1598
+ claudeMdExcludes: CLAUDE_MD_EXCLUDES,
1599
+ includeGitInstructions: false,
1600
+ },
1421
1601
  systemPrompt: {
1422
1602
  type: "preset", preset: "claude_code",
1423
1603
  append: systemPromptAppend ? systemPromptAppend : undefined,
1424
1604
  },
1425
1605
  extraArgs,
1426
1606
  ...(effort ? { effort } : {}),
1427
- ...(settingSources ? { settingSources } : {}),
1428
1607
  ...(mcpServers ? { mcpServers } : {}),
1429
1608
  ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1430
1609
  ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
@@ -1434,7 +1613,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1434
1613
  debug("provider: fresh query",
1435
1614
  `model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
1436
1615
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1437
- `appendSys=${appendSystemPrompt} strictMcp=${strictMcpConfigEnabled}`,
1616
+ `ctxFiles=${promptCapture?.contextFiles.length ?? 0} strictMcp=${strictMcpConfigEnabled}`,
1438
1617
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1439
1618
 
1440
1619
  // Resolve Pi's Anthropic credential before every fresh child. OAuth refresh is
@@ -1543,7 +1722,15 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1543
1722
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1544
1723
  promptStream.fail(new Error("query ended"));
1545
1724
  if (queryCtx.promptStream === promptStream) queryCtx.promptStream = null;
1546
- if (queryCtx.activeQuery === authPending || (sdkQuery && queryCtx.activeQuery === sdkQuery)) {
1725
+ // A later query claiming this context sets activeQuery to its own handle;
1726
+ // null means the .then/.catch above cleared ours and nothing replaced it.
1727
+ // Testing only for `=== sdkQuery` would never fire on the non-reentrant
1728
+ // path, leaving the top-level context in the routing set forever — where a
1729
+ // later orphaned tool result matches its stale turnToolCallIds and takes
1730
+ // the delivery branch, returning a stream nothing ends.
1731
+ // authPending covers the window before Claude Code starts, when sdkQuery
1732
+ // is still null.
1733
+ if (queryCtx.activeQuery === authPending || queryCtx.activeQuery === sdkQuery || queryCtx.activeQuery === null) {
1547
1734
  queryCtx.releasePendingToolCalls("Query ended");
1548
1735
  queryCtx.activeQuery = null;
1549
1736
  activeQueryContexts.delete(queryCtx);
@@ -1572,7 +1759,9 @@ export default function (pi: ExtensionAPI) {
1572
1759
  };
1573
1760
  const registeredModels = applyLongContext(MODELS, longContextSettings);
1574
1761
 
1575
- planNoticePending = config.provider?.plan === undefined && !config.startupNoticeShown;
1762
+ if (!config.startupNoticeShown) {
1763
+ 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.');
1764
+ }
1576
1765
 
1577
1766
  // Reset shared session on pi session lifecycle events
1578
1767
  const clearSession = (event: string) => {
@@ -1601,9 +1790,18 @@ export default function (pi: ExtensionAPI) {
1601
1790
  // still depends on, so both flags are forwarded as an append.
1602
1791
  pi.on("before_agent_start", (event) => {
1603
1792
  const options = event.systemPromptOptions;
1604
- userSystemPrompt = { custom: options?.customPrompt, append: options?.appendSystemPrompt };
1793
+ const hasRead = !options?.selectedTools || options.selectedTools.includes("read");
1794
+ promptCaptures.record(event.systemPrompt, {
1795
+ custom: options?.customPrompt,
1796
+ append: options?.appendSystemPrompt,
1797
+ contextFiles: options?.contextFiles ?? [],
1798
+ skills: hasRead ? options?.skills ?? [] : [],
1799
+ });
1800
+ });
1801
+ pi.on("session_shutdown", () => {
1802
+ reportLeaks("session_shutdown");
1803
+ clearSession("session_shutdown");
1605
1804
  });
1606
- pi.on("session_shutdown", () => clearSession("session_shutdown"));
1607
1805
 
1608
1806
  pi.on("session_before_compact", async (event, ctx) => {
1609
1807
  if (ctx.model?.baseUrl !== "claude-bridge") return undefined;
@@ -1654,6 +1852,38 @@ export default function (pi: ExtensionAPI) {
1654
1852
  pi.on("session_compact", (event) => markRebuild(`session_compact:${event.reason}:willRetry=${event.willRetry}`));
1655
1853
  pi.on("session_tree", () => markRebuild("session_tree"));
1656
1854
 
1855
+ // Branch summarization — rewind or fork-at-point with "summarize" — is the other
1856
+ // place pi asks the model for a summary, and unlike compaction it runs through
1857
+ // the *agent's* stream function (agent-session passes `streamFn:
1858
+ // this.agent.streamFunction`). On a bridge model that reaches this provider
1859
+ // carrying pi's internal summarization prompt, which no `before_agent_start`
1860
+ // ever recorded, so the prompt-capture resolver has nothing to resolve it to.
1861
+ // Take it over the way compaction is taken over: the summary runs as its own
1862
+ // Claude Code subprocess, never touching the live session or the resolver.
1863
+ pi.on("session_before_tree", async (event, ctx) => {
1864
+ if (ctx.model?.baseUrl !== "claude-bridge") return undefined;
1865
+ const { entriesToSummarize, userWantsSummary, customInstructions, replaceInstructions } = event.preparation;
1866
+ if (!userWantsSummary || entriesToSummarize.length === 0) return undefined;
1867
+ debug(`session_before_tree: takeover entries=${entriesToSummarize.length} target=${event.preparation.targetId.slice(0, 8)}`);
1868
+ try {
1869
+ const result = await generateBranchSummary(entriesToSummarize, {
1870
+ model: ctx.model,
1871
+ signal: event.signal,
1872
+ customInstructions,
1873
+ replaceInstructions,
1874
+ streamFn: isolatedStreamFn,
1875
+ });
1876
+ return branchSummaryOutcome(result);
1877
+ } catch (err) {
1878
+ debug("session_before_tree: takeover failed; cancelling navigation", err);
1879
+ ctx.ui?.notify?.(
1880
+ `pi-claude-agent-sdk branch summary failed (${errorMessage(err)}); navigation cancelled.`,
1881
+ "error",
1882
+ );
1883
+ return { cancel: true };
1884
+ }
1885
+ });
1886
+
1657
1887
  // --- Provider ---
1658
1888
  //
1659
1889
  // Guard against re-registration when the module is loaded multiple times
@@ -0,0 +1,315 @@
1
+ import type { Skill } from "@earendil-works/pi-coding-agent";
2
+ import { formatProjectContext } from "./agents-md.js";
3
+ import { renderSkillsBlock, type SkillReadTool } from "./skills.js";
4
+
5
+ // What pi assembled for one agent, kept so the bridge can append only the
6
+ // portable parts after Claude Code's own preset.
7
+
8
+ export type PromptCaptureInput = {
9
+ custom?: string;
10
+ append?: string;
11
+ contextFiles: { path: string; content: string }[];
12
+ skills: Skill[];
13
+ };
14
+
15
+ type InheritedPrompt = {
16
+ start: number;
17
+ end: number;
18
+ parent: PromptCapture;
19
+ };
20
+
21
+ export type PromptCapture = PromptCaptureInput & {
22
+ assembledPrompt: string;
23
+ /** Exact previously assembled prompts embedded in `custom`. */
24
+ inherited: InheritedPrompt[];
25
+ };
26
+
27
+ /**
28
+ * Captures keyed by the fully assembled prompt pi sends to a provider.
29
+ *
30
+ * A sub-agent's systemPromptOverride embeds its parent's assembled prompt
31
+ * verbatim. Pi currently exposes that override as an ordinary custom prompt,
32
+ * without provenance. Linking exact prior keys recovers the inheritance graph
33
+ * without recognizing pi prose or sub-agent markers. If pi later exposes an
34
+ * inherited-system-prompt field, it should replace this inference.
35
+ */
36
+ export type PromptCaptureDiagnostic = {
37
+ /** The prompt that matched nothing: the full system prompt is too big to log
38
+ * inline, so a fingerprint plus the closest match's first divergent offset
39
+ * are enough to recognize the pump.
40
+ *
41
+ * Closest is by shared prefix — the case that matters here is pi itself
42
+ * rebuilding the prompt outside `before_agent_start` (a changed tool list or
43
+ * fresh resource discovery), which edits near the boundary, and a prefix key
44
+ * gets us to within a handful of characters of where. */
45
+ systemPrompt: string;
46
+ matches: { key: string; firstDivergent: number }[];
47
+ };
48
+
49
+ export class PromptCaptures {
50
+ private readonly captures = new Map<string, PromptCapture>();
51
+ /** Invoked with everything that would otherwise be lost when resolution throws,
52
+ * so the bridge can write it to its debug log. Kept off the throw path itself:
53
+ * the resolver is hot and the caller may own a faster sink than string-building.
54
+ *
55
+ * Set by the bridge on the shared instance; tests that want the diagnostic can
56
+ * pass one per instance. */
57
+ private readonly onDiagnose: (diagnostic: PromptCaptureDiagnostic) => void;
58
+
59
+ /** Pi rebuilds prompts when tools change, so retain only recent lookup keys.
60
+ * Inheritance edges hold direct references and survive key eviction.
61
+ *
62
+ * Set well above any plausible working set because the costs are lopsided: a
63
+ * capture is tens of KB, while evicting one that is still live fails the turn.
64
+ * A parent that fans out to more distinct sub-agent prompts than this before its
65
+ * own next turn would be evicted despite being in use. The bound exists only to
66
+ * cap an extension that rebuilds the prompt every turn, which would otherwise
67
+ * grow keys without limit. */
68
+ constructor(private readonly limit = 256, onDiagnose?: (diagnostic: PromptCaptureDiagnostic) => void) {
69
+ this.onDiagnose = onDiagnose ?? (() => {});
70
+ }
71
+
72
+ record(systemPrompt: string, input: PromptCaptureInput): void {
73
+ const existing = this.captures.get(systemPrompt);
74
+ const customChanged = existing?.custom !== input.custom;
75
+ const capture = existing ?? {
76
+ ...input,
77
+ assembledPrompt: systemPrompt,
78
+ contextFiles: [],
79
+ skills: [],
80
+ inherited: [],
81
+ };
82
+
83
+ capture.custom = input.custom;
84
+ capture.append = input.append;
85
+ capture.contextFiles = input.contextFiles.map((file) => ({ ...file }));
86
+ capture.skills = [...input.skills];
87
+ if (!existing || customChanged) {
88
+ capture.inherited = this.findInheritedPrompts(systemPrompt, input.custom);
89
+ }
90
+
91
+ // Mutate an existing node in place so descendants retain a live reference,
92
+ // then re-insert its key so Map order tracks recency.
93
+ this.touch(systemPrompt, capture);
94
+ }
95
+
96
+ /** Exact lookup only. Callers serving a query want `resolveOrDerive`. */
97
+ resolve(systemPrompt?: string): PromptCapture | undefined {
98
+ if (!systemPrompt) return undefined;
99
+ const capture = this.captures.get(systemPrompt);
100
+ if (capture) this.touch(systemPrompt, capture);
101
+ return capture;
102
+ }
103
+
104
+ /** Recency is by use, not just by record. A parent agent records its prompt once
105
+ * and then only ever resolves it, so counting writes alone ages it out behind the
106
+ * sub-agent prompts churning past it — observed in a real 135-message session,
107
+ * where the parent's own prompt was evicted and its next turn resolved to
108
+ * nothing. */
109
+ private touch(systemPrompt: string, capture: PromptCapture): void {
110
+ this.captures.delete(systemPrompt);
111
+ this.captures.set(systemPrompt, capture);
112
+ // Trims here, not only in record(): reviving an evicted node re-adds a key that
113
+ // was not in the map, so without this a run of revivals grows it without bound.
114
+ for (const key of this.captures.keys()) {
115
+ if (this.captures.size <= this.limit) break;
116
+ this.captures.delete(key);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The capture to project for one query, for both the provider and AskClaude.
122
+ *
123
+ * An exact key is the normal case. A prompt that only *embeds* known prompts —
124
+ * anything that wrapped what Pi assembled after we recorded it — resolves to a
125
+ * transient descendant over the whole prompt, so projection swaps each embedded
126
+ * capture for its portable parts and carries everything around them through
127
+ * unchanged. That surrounding text belongs to whatever did the wrapping, and
128
+ * dropping it would be exactly the silent instruction loss this exists to
129
+ * prevent. The descendant is not retained — its key is not ours to own.
130
+ *
131
+ * Throws when a prompt can be accounted for by neither route. Returning an empty
132
+ * capture instead would hand Claude Code a turn with none of the user's context
133
+ * files, skills, custom prompt or append text, and say so only in a debug line —
134
+ * silently discarding policy the user wrote down. A failed turn is recoverable;
135
+ * a turn that quietly ignored its instructions is not.
136
+ */
137
+ resolveOrDerive(systemPrompt?: string): PromptCapture | undefined {
138
+ if (!systemPrompt) return undefined;
139
+ const exact = this.captures.get(systemPrompt);
140
+ if (exact) {
141
+ this.touch(systemPrompt, exact);
142
+ return exact;
143
+ }
144
+
145
+ // A capture outlives its lookup key: eviction drops the key while inheritance
146
+ // edges keep the node alive. findInheritedPrompts deliberately skips a node whose
147
+ // key *is* the prompt, so without this an evicted exact match would derive
148
+ // nothing and throw. Touching it puts the key back.
149
+ const revived = this.reachableCaptures().find((node) => node.assembledPrompt === systemPrompt);
150
+ if (revived) {
151
+ this.touch(systemPrompt, revived);
152
+ return revived;
153
+ }
154
+
155
+ const embedded = this.findInheritedPrompts(systemPrompt, systemPrompt);
156
+ if (embedded.length === 0) {
157
+ const matches = this.closestKnown(systemPrompt);
158
+ this.onDiagnose({ systemPrompt, matches });
159
+ throw new Error(
160
+ `prompt-capture: no capture for this ${systemPrompt.length}-char system prompt, and it embeds none of the ${this.captures.size} known. `
161
+ + `Closest known match diverges at offset ${matches[0]?.firstDivergent ?? "?"} (${matches.length ? matches[0].key.length : 0}-char key). `
162
+ + `Claude Code would receive none of this turn's context files, skills or custom instructions. `
163
+ + `The usual cause is an extension loaded after claude-bridge that rewrites the system prompt from before_agent_start — `
164
+ + `one that wraps it is fine, one that rebuilds or strips it leaves nothing to match. `
165
+ + `(Also possible: pi rebuilt the prompt outside before_agent_start — a late-registered tool or fresh resource discovery.)`,
166
+ );
167
+ }
168
+
169
+ // `custom` is the prompt itself and the edges keep their original offsets, so
170
+ // projectCustom substitutes the embedded captures in place and preserves every
171
+ // byte between and around them.
172
+ return { assembledPrompt: systemPrompt, custom: systemPrompt, contextFiles: [], skills: [], inherited: embedded };
173
+ }
174
+
175
+ get size(): number {
176
+ return this.captures.size;
177
+ }
178
+
179
+ /** Longest shared-prefix matches, best first, for the throw diagnostic. */
180
+ private closestKnown(systemPrompt: string): { key: string; firstDivergent: number }[] {
181
+ let shared = 0;
182
+ const matches: { key: string; firstDivergent: number }[] = [];
183
+ for (const key of this.captures.keys()) {
184
+ const limit = Math.min(key.length, systemPrompt.length);
185
+ let i = 0;
186
+ while (i < limit && key.charCodeAt(i) === systemPrompt.charCodeAt(i)) i++;
187
+ if (i >= shared) {
188
+ if (i > shared) {
189
+ shared = i;
190
+ matches.length = 0;
191
+ }
192
+ matches.push({ key, firstDivergent: i });
193
+ }
194
+ }
195
+ return matches;
196
+ }
197
+
198
+ private findInheritedPrompts(systemPrompt: string, custom?: string): InheritedPrompt[] {
199
+ if (!custom) return [];
200
+
201
+ const candidates: Array<InheritedPrompt & { length: number }> = [];
202
+ for (const parent of this.reachableCaptures()) {
203
+ const key = parent.assembledPrompt;
204
+ if (key === systemPrompt || key.length === 0) continue;
205
+ for (let start = custom.indexOf(key); start !== -1; start = custom.indexOf(key, start + key.length)) {
206
+ candidates.push({ start, end: start + key.length, length: key.length, parent });
207
+ }
208
+ }
209
+
210
+ // A grandchild contains both its parent's key and the grandparent key
211
+ // nested inside it. Keep the longest exact non-overlapping matches.
212
+ candidates.sort((a, b) => b.length - a.length || a.start - b.start);
213
+ const selected: InheritedPrompt[] = [];
214
+ for (const candidate of candidates) {
215
+ if (selected.some((edge) => candidate.start < edge.end && candidate.end > edge.start)) continue;
216
+ selected.push({ start: candidate.start, end: candidate.end, parent: candidate.parent });
217
+ }
218
+ return selected.sort((a, b) => a.start - b.start);
219
+ }
220
+
221
+ private reachableCaptures(): PromptCapture[] {
222
+ const result: PromptCapture[] = [];
223
+ const seen = new Set<PromptCapture>();
224
+ const visit = (capture: PromptCapture): void => {
225
+ if (seen.has(capture)) return;
226
+ seen.add(capture);
227
+ result.push(capture);
228
+ for (const edge of capture.inherited) visit(edge.parent);
229
+ };
230
+ for (const capture of this.captures.values()) visit(capture);
231
+ return result;
232
+ }
233
+ }
234
+
235
+ export function projectPromptCapture(
236
+ capture: PromptCapture,
237
+ options: { skillReadTool: SkillReadTool },
238
+ ): string | undefined {
239
+ return projectCapture(capture, options, new Set());
240
+ }
241
+
242
+ /** Skills visible through inherited prompts, ancestor first and once per file. */
243
+ export function collectPromptSkills(capture: PromptCapture): Skill[] {
244
+ const result: Skill[] = [];
245
+ const seenPaths = new Set<string>();
246
+ const visited = new Set<PromptCapture>();
247
+ const visiting = new Set<PromptCapture>();
248
+
249
+ const visit = (node: PromptCapture): void => {
250
+ if (visited.has(node)) return;
251
+ if (visiting.has(node)) throw new Error("Cyclic prompt inheritance");
252
+ visiting.add(node);
253
+ for (const edge of node.inherited) visit(edge.parent);
254
+ for (const skill of node.skills) {
255
+ if (skill.disableModelInvocation || seenPaths.has(skill.filePath)) continue;
256
+ seenPaths.add(skill.filePath);
257
+ result.push(skill);
258
+ }
259
+ visiting.delete(node);
260
+ visited.add(node);
261
+ };
262
+
263
+ visit(capture);
264
+ return result;
265
+ }
266
+
267
+ function projectCapture(
268
+ capture: PromptCapture,
269
+ options: { skillReadTool: SkillReadTool },
270
+ visiting: Set<PromptCapture>,
271
+ ): string | undefined {
272
+ if (visiting.has(capture)) throw new Error("Cyclic prompt inheritance");
273
+ visiting.add(capture);
274
+ try {
275
+ const inheritedSkillPaths = new Set(
276
+ capture.inherited.flatMap((edge) => collectPromptSkills(edge.parent).map((skill) => skill.filePath)),
277
+ );
278
+ const ownSkillPaths = new Set<string>();
279
+ const ownSkills = capture.skills.filter((skill) => {
280
+ if (skill.disableModelInvocation || inheritedSkillPaths.has(skill.filePath) || ownSkillPaths.has(skill.filePath)) {
281
+ return false;
282
+ }
283
+ ownSkillPaths.add(skill.filePath);
284
+ return true;
285
+ });
286
+
287
+ const custom = projectCustom(capture, options, visiting);
288
+ const parts = [
289
+ formatProjectContext(capture.contextFiles),
290
+ renderSkillsBlock(ownSkills, options.skillReadTool),
291
+ custom,
292
+ capture.append,
293
+ ].filter((part): part is string => Boolean(part));
294
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
295
+ } finally {
296
+ visiting.delete(capture);
297
+ }
298
+ }
299
+
300
+ function projectCustom(
301
+ capture: PromptCapture,
302
+ options: { skillReadTool: SkillReadTool },
303
+ visiting: Set<PromptCapture>,
304
+ ): string | undefined {
305
+ if (!capture.custom || capture.inherited.length === 0) return capture.custom;
306
+
307
+ let result = "";
308
+ let cursor = 0;
309
+ for (const edge of capture.inherited) {
310
+ result += capture.custom.slice(cursor, edge.start);
311
+ result += projectCapture(edge.parent, options, visiting) ?? "";
312
+ cursor = edge.end;
313
+ }
314
+ return result + capture.custom.slice(cursor);
315
+ }
@@ -28,6 +28,12 @@ export class QueryContext {
28
28
  turnToolCallIds: string[] = [];
29
29
  /** Streaming-input handle for the active query — how steers reach CC mid-turn. */
30
30
  promptStream: PromptStream | null = null;
31
+ /** Last rate-limit rejection seen on this query. Claude Code sends it just before the
32
+ * failure it caused, which is the only thing tying the two together. */
33
+ rateLimitRejection: { rateLimitType?: string; resetsAt?: number } | null = null;
34
+ /** Highest 5% utilization bucket we notified for, so repeat rate_limit_event spam is suppressed. */
35
+ lastRateLimitWarnStep: number | null = null;
36
+ lastRateLimitWarnThreshold: number | undefined;
31
37
 
32
38
  // Per-turn (reset together)
33
39
  turnOutput: AssistantMessage | null = null;
package/src/skills.ts CHANGED
@@ -1,19 +1,15 @@
1
- // Skills block extraction + MCP naming constants.
2
- // Extracted from index.ts so tests can import without activating the extension.
1
+ import { formatSkillsForPrompt, type Skill } from "@earendil-works/pi-coding-agent";
3
2
 
4
3
  export const MCP_SERVER_NAME = "custom-tools";
5
4
  export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
6
5
 
7
- // Extract skills block from pi's system prompt for forwarding to Claude Code.
8
- export function extractSkillsBlock(systemPrompt?: string): string | undefined {
9
- if (!systemPrompt) return undefined;
10
- const startMarker = "The following skills provide specialized instructions for specific tasks.";
11
- const endMarker = "</available_skills>";
12
- const start = systemPrompt.indexOf(startMarker);
13
- if (start === -1) return undefined;
14
- const end = systemPrompt.indexOf(endMarker, start);
15
- if (end === -1) return undefined;
16
- return rewriteSkillsBlock(systemPrompt.slice(start, end + endMarker.length).trim());
6
+ export type SkillReadTool = "mcp" | "native" | "none";
7
+
8
+ export function renderSkillsBlock(skills: Skill[], readTool: SkillReadTool): string | undefined {
9
+ if (readTool === "none" || skills.length === 0) return undefined;
10
+ const block = formatSkillsForPrompt(skills).trim();
11
+ if (!block) return undefined;
12
+ return readTool === "mcp" ? rewriteSkillsBlock(block) : block;
17
13
  }
18
14
 
19
15
  export function rewriteSkillsBlock(skillsBlock: string): string {