pi-claude-agent-sdk 0.8.1 → 0.8.3

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.3",
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,27 @@
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
+ getSharedPromptCaptures,
21
+ projectPromptCapture,
22
+ PromptCaptures,
23
+ } from "./prompt-capture.js";
24
+ import { collectCarriedAttachments, placeCarriedAttachments, type CarriedAttachment } from "./attachments.js";
20
25
  import { createToolServer } from "./mcp-server.js";
21
26
  import { CC_CHILD_ENV, resolveClaudeChildEnv, type AnthropicAuthRegistry } from "./child-env.js";
22
27
 
@@ -41,6 +46,19 @@ const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
41
46
  const RECORD_STREAM_PATH = process.env.CLAUDE_BRIDGE_RECORD_STREAM;
42
47
 
43
48
 
49
+ // Pi owns context files on the provider path, so Claude Code must not load its
50
+ // own on top: otherwise a project CLAUDE.md arrives twice, and the user's
51
+ // ~/.claude/CLAUDE.md — a persona written for a harness that is not the one
52
+ // running — arrives at all, stamped "These instructions OVERRIDE any default
53
+ // behavior" and outranking Pi's own AGENTS.md.
54
+ //
55
+ // Excludes rather than settingSources: the source gate that suppresses CLAUDE.md
56
+ // is the same one that reads settings.json, where Bedrock/Vertex users keep
57
+ // `env` and `apiKeyHelper`. Patterns are matched with picomatch against absolute
58
+ // paths; "**/CLAUDE.md" covers the user, ancestor, project and .claude/ copies,
59
+ // while rules need their own. Managed/policy memory is not excludable by design.
60
+ const CLAUDE_MD_EXCLUDES = ["**/CLAUDE.md", "**/.claude/rules/**"];
61
+
44
62
  // Ensure log directories exist when debug is enabled
45
63
  if (DEBUG) {
46
64
  try {
@@ -116,6 +134,10 @@ function diagDump(label: string, data: Record<string, unknown>) {
116
134
  //
117
135
  // On session_shutdown (including /reload), clearSession() resets this so a fresh
118
136
  // registration can occur for the next session.
137
+ //
138
+ // The prompt-capture table is shared the same way (PROMPT_CAPTURES_KEY): skipping
139
+ // re-registration is not enough when the first copy's before_agent_start handler
140
+ // is dropped and a second copy records into a different Map.
119
141
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
120
142
 
121
143
  // MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
@@ -158,19 +180,43 @@ interface SessionState {
158
180
  forceRotate?: boolean;
159
181
  }
160
182
 
183
+ /**
184
+ * Claude Code's `@file` expansions from the session about to be replaced.
185
+ *
186
+ * Must be called before `deleteSession`, which wipes the file they live in —
187
+ * reading after it yields nothing, with no error to notice.
188
+ */
189
+ function readCarriedAttachments(sessionId: string, cwd: string): CarriedAttachment[] {
190
+ try {
191
+ const previous = openSession({ sessionId, projectPath: cwd, claudeDir: process.env.CLAUDE_CONFIG_DIR });
192
+ return collectCarriedAttachments(previous.records);
193
+ } catch (error) {
194
+ // A post-abort rebuild reads a file the killed CC subprocess may have been
195
+ // midway through writing, and cc-session-io parses each line with a bare
196
+ // JSON.parse, so a truncated last line throws. Throwing here would turn a
197
+ // lost attachment into a failed turn; carrying none is exactly what happened
198
+ // before this existed, so the failure mode is bounded by the status quo.
199
+ debug(`WARNING: could not read attachments from session ${sessionId.slice(0, 8)}:`, error);
200
+ return [];
201
+ }
202
+ }
203
+
161
204
  let sharedSession: SessionState | null = null;
162
205
 
163
206
  // 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).
207
+ // Lossy: only text, thinking and toolCall blocks survive, and thinking only when
208
+ // Claude Code itself minted the signature. An assistant message whose blocks all
209
+ // filter out keeps its slot with a placeholder, since dropping it can create a
210
+ // tool_result with no preceding tool_use. A turn aborted before anything streamed
211
+ // is dropped instead — it never had content, and inventing one diverges from the
212
+ // prefix Claude Code cached.
168
213
  function convertAndImportMessages(
169
214
  session: ReturnType<typeof createSession>,
170
215
  messages: Context["messages"],
171
216
  customToolNameToSdk?: Map<string, string>,
217
+ carried?: readonly CarriedAttachment[],
172
218
  ): void {
173
- const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
219
+ const { anthropicMessages, sanitizedIds, dropped } = convertPiMessages(messages, customToolNameToSdk);
174
220
 
175
221
  debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
176
222
  debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
@@ -179,6 +225,16 @@ function convertAndImportMessages(
179
225
  if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
180
226
  return `[${i}]${m.role}:?`;
181
227
  }).join(" "));
228
+ // The roles line above shows only what survived, so a stripped block is
229
+ // indistinguishable there from one that never existed. Name the losses.
230
+ const droppedParts = [
231
+ dropped.thinking ? `${dropped.thinking} thinking (${[...dropped.providers].sort().join(", ")})` : "",
232
+ dropped.abortedTurns ? `${dropped.abortedTurns} aborted turn(s)` : "",
233
+ ...[...dropped.other].map(([type, n]) => `${n} ${type}`),
234
+ ].filter(Boolean);
235
+ if (droppedParts.length > 0) {
236
+ debug(`convertAndImportMessages: dropped ${droppedParts.join(", ")}`);
237
+ }
182
238
  if (sanitizedIds.size > 0) {
183
239
  debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
184
240
  [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
@@ -188,7 +244,21 @@ function convertAndImportMessages(
188
244
  if (repaired.length !== anthropicMessages.length) {
189
245
  debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
190
246
  }
191
- if (repaired.length) session.importMessages(repaired);
247
+ // Placement runs against the repaired array because that is the index space
248
+ // importMessages reads. Attachments are links in CC's uuid chain, so they have
249
+ // to be written in order with the messages, not appended afterwards.
250
+ const placed = carried?.length
251
+ ? placeCarriedAttachments(carried, repaired as unknown as { role: string; content: unknown }[])
252
+ : undefined;
253
+ if (placed?.skipped.length) {
254
+ debug(`convertAndImportMessages: dropped ${placed.skipped.length} carried attachment(s): ${placed.skipped.join("; ")}`);
255
+ }
256
+ if (placed?.attachments.length) {
257
+ debug(`convertAndImportMessages: carrying ${placed.attachments.length} attachment(s) across the rebuild`);
258
+ }
259
+ if (repaired.length) {
260
+ session.importMessages(repaired, placed?.attachments.length ? { attachments: placed.attachments } : undefined);
261
+ }
192
262
  }
193
263
 
194
264
  // Pi doesn't pass tool results directly — it appends them to the context and calls
@@ -317,6 +387,24 @@ function resultErrorText(message: SDKMessage): string | undefined {
317
387
  return `Claude Code failed: ${result.subtype ?? "unknown result"}`;
318
388
  }
319
389
 
390
+ /** Name a failure as a rate limit when a rejection preceded it.
391
+ *
392
+ * pi has no typed rate-limit error — `stopReason` is only ever `"error"` and the sole carrier
393
+ * is `errorMessage` — so everything that reacts to a rate limit pattern-matches that string:
394
+ * pi-subagents gates `fallbackModels` on a 35-pattern list, and key-rotating extensions use
395
+ * their own. Claude Code words a subscription limit as "You're out of extra usage · resets
396
+ * 6:30pm", which matches none of them, so an exhausted quota reads as a fatal error and the
397
+ * fallback chain never runs (issue #58).
398
+ *
399
+ * Leading with "Claude rate limit" rather than appending keeps the phrase in any truncated
400
+ * render, and avoids the `<tool> failed (exit N):` shape that pi-subagents treats as a tool
401
+ * failure and refuses to retry. */
402
+ function describeRateLimitFailure(rejection: { rateLimitType?: string; resetsAt?: number }, failure: string): string {
403
+ const kind = rejection.rateLimitType ? ` (${rejection.rateLimitType})` : "";
404
+ const resets = rejection.resetsAt ? ` — resets ${new Date(rejection.resetsAt * 1000).toLocaleTimeString()}` : "";
405
+ return `Claude rate limit${kind}${resets}: ${failure}`;
406
+ }
407
+
320
408
  function isolatedStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
321
409
  const stream = newAssistantMessageEventStream();
322
410
  void runIsolatedSummary(model, context, options, stream);
@@ -576,6 +664,8 @@ function syncSharedSession(
576
664
  // and for any tools that key off them. Skipped only when there's a
577
665
  // concurrent writer we shouldn't race — see forceRotate docs above.
578
666
  const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
667
+ // Before deleteSession — it wipes the file these live in.
668
+ const carried = previousSessionId !== undefined ? readCarriedAttachments(previousSessionId, cwd) : [];
579
669
  if (preserveId) {
580
670
  // Wipe prior jsonl + companion dir (no-op if nothing to wipe).
581
671
  deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
@@ -586,17 +676,19 @@ function syncSharedSession(
586
676
  ...(preserveId ? { sessionId: previousSessionId } : {}),
587
677
  ...(modelId ? { model: modelId } : {}),
588
678
  });
589
- convertAndImportMessages(session, priorMessages, customToolNameToSdk);
679
+ convertAndImportMessages(session, priorMessages, customToolNameToSdk, carried);
590
680
  session.save();
591
- verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
681
+ // records, not messages: `messages` filters out the attachment records that
682
+ // carrying an `@file` expansion across a rebuild writes into the same file.
683
+ verifyWrittenSession(session.jsonlPath, session.sessionId, session.records.length, cwd);
592
684
  sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
593
685
  if (previousSessionId === undefined) {
594
- debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
686
+ debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.records.length} records`);
595
687
  } else if (preserveId) {
596
688
  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`);
689
+ debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.records.length} records`);
598
690
  } 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`);
691
+ 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
692
  }
601
693
  debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
602
694
  debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
@@ -614,6 +706,9 @@ export const __test = {
614
706
  getSharedSession() {
615
707
  return sharedSession;
616
708
  },
709
+ setPiUI(ui: ExtensionUIContext | null) {
710
+ piUI = ui;
711
+ },
617
712
  syncSharedSession,
618
713
  extractUserPromptBlocks,
619
714
  consumeQuery,
@@ -623,6 +718,7 @@ export const __test = {
623
718
  drainForAbort,
624
719
  CC_CHILD_ENV,
625
720
  buildMcpServers,
721
+ branchSummaryOutcome,
626
722
  };
627
723
 
628
724
  // --- Provider helpers: tool name mapping ---
@@ -682,26 +778,75 @@ const activeQueryContexts = new Set<QueryContext>();
682
778
  // provider query rather than session_start: the notice persists a flag to the global
683
779
  // config, and firing it on startup would write that file for every pi session that
684
780
  // merely has this extension installed.
685
- let planNoticePending = false;
781
+ let pendingNotices: string[] = [];
686
782
 
687
- function showPlanNoticeOnce(): void {
783
+ function showStartupNoticeOnce(): void {
688
784
  // `hasUI` is true in RPC mode too — it means dialogs are possible, not that a
689
785
  // human is watching. Only a terminal user can act on this.
690
- if (!planNoticePending || piMode !== "tui") return;
691
- planNoticePending = false;
786
+ if (pendingNotices.length === 0 || piMode !== "tui") return;
787
+ const notices = pendingNotices;
788
+ pendingNotices = [];
692
789
  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",
790
+ // pi wraps the whole notify string in the theme's dim foreground; the inner reset
791
+ // drops back to the terminal default rather than dim, which is fine here.
792
+ const title = `\x1b[33mWelcome to pi-claude-agent-sdk\x1b[39m — settings live in ${path}`;
793
+ const bullets = [...notices, "This message only appears once. See README.md for more."].map((n) => `• ${n}`);
794
+ piUI?.notify([title, ...bullets, "─".repeat(64)].join("\n"), "info");
795
+ }
796
+
797
+ // Captures of what pi assembled per agent; see src/prompt-capture.ts for why this
798
+ // is keyed rather than held in a single slot. Process-wide: a second evaluation
799
+ // of this module (user vs project package root, or a subagent) must record into
800
+ // the same table the first evaluation's streamSimple reads.
801
+ const promptCaptures = getSharedPromptCaptures(() => new PromptCaptures(256, (diagnostic) => {
802
+ const first = diagnostic.matches[0];
803
+ debug(
804
+ `prompt-capture: no match for ${diagnostic.systemPrompt.length}-char system prompt. `
805
+ + (first
806
+ ? `closest known (${first.key.length}-char) shares its first ${first.firstDivergent} chars and diverges at offset ${first.firstDivergent}: `
807
+ + JSON.stringify(diagnostic.systemPrompt.slice(first.firstDivergent - 40, first.firstDivergent + 60))
808
+ : "no known captures to compare against."
809
+ ) + ` known keys=${diagnostic.matches.length}`,
810
+ );
811
+ }));
812
+
813
+ /** Whatever a settled session left behind, named in one greppable line.
814
+ *
815
+ * Every one of these should be empty once the last turn ends, and each is a leak
816
+ * that costs something real: a retained context routes a later orphaned tool result
817
+ * into the delivery path and returns a stream nobody ends; a pending tool call is an
818
+ * MCP handler Claude Code is still waiting on; a live prompt stream is an unresolved
819
+ * ack. The activeQueryContexts leak was present on every single happy-path run and
820
+ * no test noticed, because nothing asserted that anything ends clean — so assert it
821
+ * where the real sessions are, and let diag/audit-warnings.mjs scan for it. */
822
+ function reportLeaks(label: string): void {
823
+ const pendingCalls = [...activeQueryContexts].reduce((n, c) => n + c.pendingToolCalls.size, 0);
824
+ const liveStreams = [...activeQueryContexts].filter((c) => c.promptStream !== null).length;
825
+ if (activeQueryContexts.size === 0 && pendingCalls === 0 && liveStreams === 0) return;
826
+ debug(
827
+ `WARNING: ${label} left state behind — contexts=${activeQueryContexts.size} `
828
+ + `pendingToolCalls=${pendingCalls} promptStreams=${liveStreams}`,
696
829
  );
697
830
  }
698
831
 
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 } = {};
832
+ /** What pi's branch summary means for the navigation it was asked for.
833
+ *
834
+ * Cancelling on failure matches pi's own path, which rethrows a summary error out
835
+ * of the navigation rather than moving without one. Separated from the event
836
+ * handler so this decision is testable without a Claude Code subprocess — driving
837
+ * `generateBranchSummary` itself would only be testing pi. */
838
+ function branchSummaryOutcome(result: BranchSummaryResult): { cancel: true } | { summary: { summary: string; details: unknown; usage?: BranchSummaryResult["usage"] } } {
839
+ if (result.aborted) return { cancel: true };
840
+ if (result.error) throw new Error(result.error);
841
+ debug(`session_before_tree: takeover complete summaryLen=${result.summary?.length ?? 0}`);
842
+ return {
843
+ summary: {
844
+ summary: result.summary ?? "",
845
+ details: { readFiles: result.readFiles ?? [], modifiedFiles: result.modifiedFiles ?? [] },
846
+ usage: result.usage,
847
+ },
848
+ };
849
+ }
705
850
 
706
851
  function contextForToolResults(results: McpResult[]): QueryContext | undefined {
707
852
  for (const result of results) {
@@ -1091,6 +1236,12 @@ async function consumeQuery(
1091
1236
  logServedContextWindow("result", message, model);
1092
1237
  resultError = resultErrorText(message);
1093
1238
  if (resultError !== undefined) {
1239
+ // Consume the rejection alongside the failure it caused, so a later
1240
+ // unrelated failure on this query doesn't inherit the label.
1241
+ if (queryCtx.rateLimitRejection) {
1242
+ resultError = describeRateLimitFailure(queryCtx.rateLimitRejection, resultError);
1243
+ queryCtx.rateLimitRejection = null;
1244
+ }
1094
1245
  debug(`consumeQuery: error result, subtype=${message.subtype}, error=${resultError}`);
1095
1246
  if (queryCtx.turnOutput) {
1096
1247
  queryCtx.turnOutput.stopReason = "error";
@@ -1102,10 +1253,31 @@ async function consumeQuery(
1102
1253
  const info = (message as any).rate_limit_info;
1103
1254
  debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1104
1255
  if (info?.status === "rejected") {
1105
- const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
1256
+ // Held so the failure Claude Code sends next can be named as a rate limit.
1257
+ queryCtx.rateLimitRejection = info;
1258
+ // The "rate limited" notice below supersedes warnings; re-arm so the next
1259
+ // window's warnings fire even if it opens straight into allowed_warning.
1260
+ queryCtx.lastRateLimitWarnStep = null;
1261
+ queryCtx.lastRateLimitWarnThreshold = undefined;
1262
+ // resetsAt is Unix seconds, not milliseconds.
1263
+ const resetsAt = info.resetsAt ? new Date(info.resetsAt * 1000).toLocaleTimeString() : "unknown";
1106
1264
  piUI?.notify(`Claude rate limited (${info.rateLimitType ?? "unknown"}) — resets at ${resetsAt}`, "warning");
1265
+ } else if (info?.status === "allowed") {
1266
+ // Back under the threshold (window reset) — re-arm the warning dedupe.
1267
+ queryCtx.lastRateLimitWarnStep = null;
1268
+ queryCtx.lastRateLimitWarnThreshold = undefined;
1107
1269
  } else if (info?.status === "allowed_warning") {
1108
- piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1270
+ // utilization is a fraction (0..1); allowed_warning fires once it crosses surpassedThreshold.
1271
+ const percent = Math.round((info.utilization ?? 0) * 100);
1272
+ // The SDK emits one event per request, so only re-notify when the level
1273
+ // rises past a new 5% step or the threshold changes.
1274
+ const step = Math.floor(percent / 5);
1275
+ const rose = queryCtx.lastRateLimitWarnStep === null || step > queryCtx.lastRateLimitWarnStep;
1276
+ if (rose || info.surpassedThreshold !== queryCtx.lastRateLimitWarnThreshold) {
1277
+ queryCtx.lastRateLimitWarnStep = step;
1278
+ queryCtx.lastRateLimitWarnThreshold = info.surpassedThreshold;
1279
+ piUI?.notify(`Claude rate limit warning: ${percent}% used (${info.rateLimitType ?? ""})`, "warning");
1280
+ }
1109
1281
  }
1110
1282
  continue;
1111
1283
  }
@@ -1251,7 +1423,7 @@ function drainForAbort(c: QueryContext, promptStream: PromptStream): void {
1251
1423
  /** Provider entry point. Pi calls this for each new prompt and each tool result.
1252
1424
  * Two cases: tool result delivery (active query) or fresh query. */
1253
1425
  function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1254
- showPlanNoticeOnce();
1426
+ showStartupNoticeOnce();
1255
1427
  const stream = newAssistantMessageEventStream();
1256
1428
 
1257
1429
  // DEBUG: trace followUp message triggering
@@ -1319,6 +1491,21 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1319
1491
  const queryCtx = isReentrant ? new QueryContext() : ctx();
1320
1492
  debug(`provider: fresh query setup, isReentrant=${isReentrant}, activeContexts=${activeQueryContexts.size}`);
1321
1493
 
1494
+ // Resolved first: an unaccountable system prompt throws, and doing that before
1495
+ // anything is claimed or reset leaves no half-built query behind — in particular
1496
+ // no stream claimed on the shared context that nobody will ever end.
1497
+ const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1498
+ // Build from what Pi loaded for this run, so `--no-context-files` and
1499
+ // `--no-skills` reach Claude Code by leaving nothing to forward. A sub-agent's
1500
+ // custom override embeds its parent's assembled Pi prompt; recursive projection
1501
+ // replaces that exact inherited prompt with its already-safe portable parts.
1502
+ const promptCapture = promptCaptures.resolveOrDerive(context.systemPrompt);
1503
+ const systemPromptAppend = promptCapture
1504
+ ? projectPromptCapture(promptCapture, {
1505
+ skillReadTool: mcpTools.some((tool) => tool.name === "read") ? "mcp" : "none",
1506
+ })
1507
+ : undefined;
1508
+
1322
1509
  // 2. Fresh child context — constructor already gave us clean Maps and empty
1323
1510
  // arrays. For a reused top-level context, clear explicitly.
1324
1511
  claimCurrentPiStream(stream, "fresh-query", queryCtx);
@@ -1331,7 +1518,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1331
1518
  queryCtx.resetTurnState(model);
1332
1519
  queryCtx.latestCursor = 0;
1333
1520
 
1334
- const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1335
1521
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1336
1522
  // cliModel is the actual id sent to Claude Code (may carry [1m]); model.id is the
1337
1523
  // pi-registered id. Log cliModel so debug lines reflect what CC actually received.
@@ -1367,24 +1553,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1367
1553
  .catch((error) => debug(`provider: initial prompt push rejected:`, error));
1368
1554
  queryCtx.promptStream = promptStream;
1369
1555
  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
1556
 
1380
1557
  // MCP auto-loading suppression: CC reads MCP servers from ~/.claude.json (top-level
1381
1558
  // + per-project) and .mcp.json. Since pi executes tools (not CC), those are pure
1382
1559
  // token overhead. --strict-mcp-config tells the binary to use ONLY mcpServers passed
1383
1560
  // 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"];
1561
+ // settingSources is left at CC's default, which loads all sources.
1388
1562
  const strictMcpConfigEnabled = providerSettings.strictMcpConfig !== false;
1389
1563
  const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
1390
1564
 
@@ -1417,14 +1591,26 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1417
1591
  tools: [],
1418
1592
  permissionMode: "bypassPermissions",
1419
1593
  includePartialMessages: true,
1420
- settings: claudeCodeSettings(providerSettings),
1594
+ // includeGitInstructions:false drops the gitStatus block from the preset.
1595
+ // That block is the trailing suffix of the cached system block, and a
1596
+ // git-state transition (new file, staging, commit) rewrites it — busting
1597
+ // the prompt cache for the whole conversation from there on (see
1598
+ // diag/probe-git-cache.mjs). The bridge re-invokes CC per turn, so this
1599
+ // hit on every transition. Cost here is nil: the setting also strips
1600
+ // CC's git-workflow guidance from its Bash tool prompt, but the provider
1601
+ // path runs CC with `tools: []`, so those definitions never ship.
1602
+ // AskClaude keeps CC's native tools and its guidance — unaffected.
1603
+ settings: {
1604
+ ...claudeCodeSettings(providerSettings),
1605
+ claudeMdExcludes: CLAUDE_MD_EXCLUDES,
1606
+ includeGitInstructions: false,
1607
+ },
1421
1608
  systemPrompt: {
1422
1609
  type: "preset", preset: "claude_code",
1423
1610
  append: systemPromptAppend ? systemPromptAppend : undefined,
1424
1611
  },
1425
1612
  extraArgs,
1426
1613
  ...(effort ? { effort } : {}),
1427
- ...(settingSources ? { settingSources } : {}),
1428
1614
  ...(mcpServers ? { mcpServers } : {}),
1429
1615
  ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1430
1616
  ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
@@ -1434,7 +1620,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1434
1620
  debug("provider: fresh query",
1435
1621
  `model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
1436
1622
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1437
- `appendSys=${appendSystemPrompt} strictMcp=${strictMcpConfigEnabled}`,
1623
+ `ctxFiles=${promptCapture?.contextFiles.length ?? 0} strictMcp=${strictMcpConfigEnabled}`,
1438
1624
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1439
1625
 
1440
1626
  // Resolve Pi's Anthropic credential before every fresh child. OAuth refresh is
@@ -1543,7 +1729,15 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1543
1729
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1544
1730
  promptStream.fail(new Error("query ended"));
1545
1731
  if (queryCtx.promptStream === promptStream) queryCtx.promptStream = null;
1546
- if (queryCtx.activeQuery === authPending || (sdkQuery && queryCtx.activeQuery === sdkQuery)) {
1732
+ // A later query claiming this context sets activeQuery to its own handle;
1733
+ // null means the .then/.catch above cleared ours and nothing replaced it.
1734
+ // Testing only for `=== sdkQuery` would never fire on the non-reentrant
1735
+ // path, leaving the top-level context in the routing set forever — where a
1736
+ // later orphaned tool result matches its stale turnToolCallIds and takes
1737
+ // the delivery branch, returning a stream nothing ends.
1738
+ // authPending covers the window before Claude Code starts, when sdkQuery
1739
+ // is still null.
1740
+ if (queryCtx.activeQuery === authPending || queryCtx.activeQuery === sdkQuery || queryCtx.activeQuery === null) {
1547
1741
  queryCtx.releasePendingToolCalls("Query ended");
1548
1742
  queryCtx.activeQuery = null;
1549
1743
  activeQueryContexts.delete(queryCtx);
@@ -1572,7 +1766,9 @@ export default function (pi: ExtensionAPI) {
1572
1766
  };
1573
1767
  const registeredModels = applyLongContext(MODELS, longContextSettings);
1574
1768
 
1575
- planNoticePending = config.provider?.plan === undefined && !config.startupNoticeShown;
1769
+ if (!config.startupNoticeShown) {
1770
+ 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.');
1771
+ }
1576
1772
 
1577
1773
  // Reset shared session on pi session lifecycle events
1578
1774
  const clearSession = (event: string) => {
@@ -1601,9 +1797,18 @@ export default function (pi: ExtensionAPI) {
1601
1797
  // still depends on, so both flags are forwarded as an append.
1602
1798
  pi.on("before_agent_start", (event) => {
1603
1799
  const options = event.systemPromptOptions;
1604
- userSystemPrompt = { custom: options?.customPrompt, append: options?.appendSystemPrompt };
1800
+ const hasRead = !options?.selectedTools || options.selectedTools.includes("read");
1801
+ promptCaptures.record(event.systemPrompt, {
1802
+ custom: options?.customPrompt,
1803
+ append: options?.appendSystemPrompt,
1804
+ contextFiles: options?.contextFiles ?? [],
1805
+ skills: hasRead ? options?.skills ?? [] : [],
1806
+ });
1807
+ });
1808
+ pi.on("session_shutdown", () => {
1809
+ reportLeaks("session_shutdown");
1810
+ clearSession("session_shutdown");
1605
1811
  });
1606
- pi.on("session_shutdown", () => clearSession("session_shutdown"));
1607
1812
 
1608
1813
  pi.on("session_before_compact", async (event, ctx) => {
1609
1814
  if (ctx.model?.baseUrl !== "claude-bridge") return undefined;
@@ -1654,6 +1859,38 @@ export default function (pi: ExtensionAPI) {
1654
1859
  pi.on("session_compact", (event) => markRebuild(`session_compact:${event.reason}:willRetry=${event.willRetry}`));
1655
1860
  pi.on("session_tree", () => markRebuild("session_tree"));
1656
1861
 
1862
+ // Branch summarization — rewind or fork-at-point with "summarize" — is the other
1863
+ // place pi asks the model for a summary, and unlike compaction it runs through
1864
+ // the *agent's* stream function (agent-session passes `streamFn:
1865
+ // this.agent.streamFunction`). On a bridge model that reaches this provider
1866
+ // carrying pi's internal summarization prompt, which no `before_agent_start`
1867
+ // ever recorded, so the prompt-capture resolver has nothing to resolve it to.
1868
+ // Take it over the way compaction is taken over: the summary runs as its own
1869
+ // Claude Code subprocess, never touching the live session or the resolver.
1870
+ pi.on("session_before_tree", async (event, ctx) => {
1871
+ if (ctx.model?.baseUrl !== "claude-bridge") return undefined;
1872
+ const { entriesToSummarize, userWantsSummary, customInstructions, replaceInstructions } = event.preparation;
1873
+ if (!userWantsSummary || entriesToSummarize.length === 0) return undefined;
1874
+ debug(`session_before_tree: takeover entries=${entriesToSummarize.length} target=${event.preparation.targetId.slice(0, 8)}`);
1875
+ try {
1876
+ const result = await generateBranchSummary(entriesToSummarize, {
1877
+ model: ctx.model,
1878
+ signal: event.signal,
1879
+ customInstructions,
1880
+ replaceInstructions,
1881
+ streamFn: isolatedStreamFn,
1882
+ });
1883
+ return branchSummaryOutcome(result);
1884
+ } catch (err) {
1885
+ debug("session_before_tree: takeover failed; cancelling navigation", err);
1886
+ ctx.ui?.notify?.(
1887
+ `pi-claude-agent-sdk branch summary failed (${errorMessage(err)}); navigation cancelled.`,
1888
+ "error",
1889
+ );
1890
+ return { cancel: true };
1891
+ }
1892
+ });
1893
+
1657
1894
  // --- Provider ---
1658
1895
  //
1659
1896
  // Guard against re-registration when the module is loaded multiple times
@@ -0,0 +1,342 @@
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
+ + (this.captures.size === 0
167
+ ? ` Zero known also means before_agent_start never recorded into this table — often a second copy of this extension loaded from another package root after the first registered the provider.`
168
+ : ""),
169
+ );
170
+ }
171
+
172
+ // `custom` is the prompt itself and the edges keep their original offsets, so
173
+ // projectCustom substitutes the embedded captures in place and preserves every
174
+ // byte between and around them.
175
+ return { assembledPrompt: systemPrompt, custom: systemPrompt, contextFiles: [], skills: [], inherited: embedded };
176
+ }
177
+
178
+ get size(): number {
179
+ return this.captures.size;
180
+ }
181
+
182
+ /** Longest shared-prefix matches, best first, for the throw diagnostic. */
183
+ private closestKnown(systemPrompt: string): { key: string; firstDivergent: number }[] {
184
+ let shared = 0;
185
+ const matches: { key: string; firstDivergent: number }[] = [];
186
+ for (const key of this.captures.keys()) {
187
+ const limit = Math.min(key.length, systemPrompt.length);
188
+ let i = 0;
189
+ while (i < limit && key.charCodeAt(i) === systemPrompt.charCodeAt(i)) i++;
190
+ if (i >= shared) {
191
+ if (i > shared) {
192
+ shared = i;
193
+ matches.length = 0;
194
+ }
195
+ matches.push({ key, firstDivergent: i });
196
+ }
197
+ }
198
+ return matches;
199
+ }
200
+
201
+ private findInheritedPrompts(systemPrompt: string, custom?: string): InheritedPrompt[] {
202
+ if (!custom) return [];
203
+
204
+ const candidates: Array<InheritedPrompt & { length: number }> = [];
205
+ for (const parent of this.reachableCaptures()) {
206
+ const key = parent.assembledPrompt;
207
+ if (key === systemPrompt || key.length === 0) continue;
208
+ for (let start = custom.indexOf(key); start !== -1; start = custom.indexOf(key, start + key.length)) {
209
+ candidates.push({ start, end: start + key.length, length: key.length, parent });
210
+ }
211
+ }
212
+
213
+ // A grandchild contains both its parent's key and the grandparent key
214
+ // nested inside it. Keep the longest exact non-overlapping matches.
215
+ candidates.sort((a, b) => b.length - a.length || a.start - b.start);
216
+ const selected: InheritedPrompt[] = [];
217
+ for (const candidate of candidates) {
218
+ if (selected.some((edge) => candidate.start < edge.end && candidate.end > edge.start)) continue;
219
+ selected.push({ start: candidate.start, end: candidate.end, parent: candidate.parent });
220
+ }
221
+ return selected.sort((a, b) => a.start - b.start);
222
+ }
223
+
224
+ private reachableCaptures(): PromptCapture[] {
225
+ const result: PromptCapture[] = [];
226
+ const seen = new Set<PromptCapture>();
227
+ const visit = (capture: PromptCapture): void => {
228
+ if (seen.has(capture)) return;
229
+ seen.add(capture);
230
+ result.push(capture);
231
+ for (const edge of capture.inherited) visit(edge.parent);
232
+ };
233
+ for (const capture of this.captures.values()) visit(capture);
234
+ return result;
235
+ }
236
+ }
237
+
238
+ /** Process-wide slot for the capture table.
239
+ *
240
+ * `src/index.ts` is evaluated once per package root. Pi's pre-trust pass loads
241
+ * the user install, which registers the provider; the post-trust pass then
242
+ * loads the project install as a different module and drops the first copy's
243
+ * event handlers. A per-module Map means `before_agent_start` records into a
244
+ * table the live `streamSimple` never reads.
245
+ *
246
+ * Symbol.for shares one table across those evaluations, the same way
247
+ * `ACTIVE_STREAM_SIMPLE_KEY` shares the stream. Do not use `instanceof
248
+ * PromptCaptures` to recognize the stored value: two package roots evaluate
249
+ * two copies of the class, so a cross-realm check would replace the table
250
+ * the first copy's stream already closed over. */
251
+ export const PROMPT_CAPTURES_KEY = Symbol.for("claude-bridge:promptCaptures");
252
+
253
+ export function getSharedPromptCaptures(create: () => PromptCaptures): PromptCaptures {
254
+ const g = globalThis as Record<symbol, unknown>;
255
+ const existing = g[PROMPT_CAPTURES_KEY];
256
+ if (existing) return existing as PromptCaptures;
257
+ const created = create();
258
+ g[PROMPT_CAPTURES_KEY] = created;
259
+ return created;
260
+ }
261
+
262
+ export function projectPromptCapture(
263
+ capture: PromptCapture,
264
+ options: { skillReadTool: SkillReadTool },
265
+ ): string | undefined {
266
+ return projectCapture(capture, options, new Set());
267
+ }
268
+
269
+ /** Skills visible through inherited prompts, ancestor first and once per file. */
270
+ export function collectPromptSkills(capture: PromptCapture): Skill[] {
271
+ const result: Skill[] = [];
272
+ const seenPaths = new Set<string>();
273
+ const visited = new Set<PromptCapture>();
274
+ const visiting = new Set<PromptCapture>();
275
+
276
+ const visit = (node: PromptCapture): void => {
277
+ if (visited.has(node)) return;
278
+ if (visiting.has(node)) throw new Error("Cyclic prompt inheritance");
279
+ visiting.add(node);
280
+ for (const edge of node.inherited) visit(edge.parent);
281
+ for (const skill of node.skills) {
282
+ if (skill.disableModelInvocation || seenPaths.has(skill.filePath)) continue;
283
+ seenPaths.add(skill.filePath);
284
+ result.push(skill);
285
+ }
286
+ visiting.delete(node);
287
+ visited.add(node);
288
+ };
289
+
290
+ visit(capture);
291
+ return result;
292
+ }
293
+
294
+ function projectCapture(
295
+ capture: PromptCapture,
296
+ options: { skillReadTool: SkillReadTool },
297
+ visiting: Set<PromptCapture>,
298
+ ): string | undefined {
299
+ if (visiting.has(capture)) throw new Error("Cyclic prompt inheritance");
300
+ visiting.add(capture);
301
+ try {
302
+ const inheritedSkillPaths = new Set(
303
+ capture.inherited.flatMap((edge) => collectPromptSkills(edge.parent).map((skill) => skill.filePath)),
304
+ );
305
+ const ownSkillPaths = new Set<string>();
306
+ const ownSkills = capture.skills.filter((skill) => {
307
+ if (skill.disableModelInvocation || inheritedSkillPaths.has(skill.filePath) || ownSkillPaths.has(skill.filePath)) {
308
+ return false;
309
+ }
310
+ ownSkillPaths.add(skill.filePath);
311
+ return true;
312
+ });
313
+
314
+ const custom = projectCustom(capture, options, visiting);
315
+ const parts = [
316
+ formatProjectContext(capture.contextFiles),
317
+ renderSkillsBlock(ownSkills, options.skillReadTool),
318
+ custom,
319
+ capture.append,
320
+ ].filter((part): part is string => Boolean(part));
321
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
322
+ } finally {
323
+ visiting.delete(capture);
324
+ }
325
+ }
326
+
327
+ function projectCustom(
328
+ capture: PromptCapture,
329
+ options: { skillReadTool: SkillReadTool },
330
+ visiting: Set<PromptCapture>,
331
+ ): string | undefined {
332
+ if (!capture.custom || capture.inherited.length === 0) return capture.custom;
333
+
334
+ let result = "";
335
+ let cursor = 0;
336
+ for (const edge of capture.inherited) {
337
+ result += capture.custom.slice(cursor, edge.start);
338
+ result += projectCapture(edge.parent, options, visiting) ?? "";
339
+ cursor = edge.end;
340
+ }
341
+ return result + capture.custom.slice(cursor);
342
+ }
@@ -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 {