@viniciosrab/pi-claude-bridge 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ // In-process MCP server that exposes pi tools to Claude Code.
2
+ //
3
+ // Pi declares tool parameters as TypeBox objects, which are already JSON
4
+ // Schema at runtime — the same thing MCP puts on the wire. This serves them
5
+ // verbatim instead of going through the SDK's `createSdkMcpServer`, which only
6
+ // accepts Zod and therefore forces a JSON Schema → Zod → JSON Schema round
7
+ // trip. That round trip is lossy below the top level: nested objects collapse
8
+ // to open records and `anyOf`/`const` vanish, so Claude saw only the first
9
+ // level of any tool with a nested schema — including the builtin `edit`.
10
+ //
11
+ // Handlers go on the underlying protocol server rather than through
12
+ // `McpServer.registerTool`, which is the Zod-only path. Skipping registerTool
13
+ // also skips its argument validation, which is what we want: pi validates and
14
+ // executes tools itself, and the arguments MCP sees are discarded. A rejection
15
+ // there would only prevent the handler from running, stranding the call.
16
+ //
17
+ // This rests on the Agent SDK treating what we hand it as an opaque JSON-RPC
18
+ // endpoint: `connectSdkMcpServer` in sdk.mjs calls `instance.connect(transport)`
19
+ // and nothing else, so none of McpServer's higher-level machinery is required.
20
+ // The `McpServer` wrapper is kept only because the SDK's `mcpServers` option is
21
+ // typed against that class. If this breaks after an SDK update, check whether
22
+ // the SDK began inspecting the instance — reading registered tools, or expecting
23
+ // tools/list_changed notifications we never send.
24
+
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
27
+ import type { McpResult } from "./extract-tool-results.js";
28
+
29
+ // Claude Code stamps every tools/call with the id of the tool_use block it came
30
+ // from. That is the only reliable way to pair a call with its result: call order
31
+ // is not guaranteed to match the order the tool_use blocks were emitted, so
32
+ // counting calls mispairs results as soon as the two diverge.
33
+ //
34
+ // This is a Claude Code extension, not part of the MCP spec — CC sets it in
35
+ // `src/services/mcp/client.ts` (see reference-code/claude-code-rip). If CC ever
36
+ // stops sending it, every tool call fails with the error below rather than
37
+ // silently pairing results to the wrong call, which is the intended tradeoff.
38
+ const TOOL_USE_ID_META = "claudecode/toolUseId";
39
+
40
+ export interface McpToolDef {
41
+ name: string;
42
+ description: string;
43
+ inputSchema: unknown;
44
+ handler: (toolCallId: string) => Promise<McpResult>;
45
+ }
46
+
47
+ // MCP requires an object schema. Pi types tool parameters as any TypeBox schema,
48
+ // so a scalar or array one typechecks but cannot go on the wire — that is a bug
49
+ // in the tool, and reporting it at startup names the culprit. Degrading it to
50
+ // "takes no arguments" instead would surface much later as Claude calling the
51
+ // tool with no arguments and pi's own validation rejecting them.
52
+ function assertObjectSchema(tool: McpToolDef): void {
53
+ const schema = tool.inputSchema as Record<string, unknown> | undefined;
54
+ if (!schema || schema.type !== "object") {
55
+ throw new Error(`${tool.name}: MCP tool parameters must be an object schema, got ${JSON.stringify(schema)}`);
56
+ }
57
+ }
58
+
59
+ export function createToolServer(name: string, tools: McpToolDef[]) {
60
+ const server = new McpServer({ name, version: "1.0.0" }, { capabilities: { tools: {} } });
61
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
62
+ for (const tool of tools) assertObjectSchema(tool);
63
+
64
+ server.server.setRequestHandler(ListToolsRequestSchema, () => ({
65
+ tools: tools.map((tool) => ({
66
+ name: tool.name,
67
+ description: tool.description,
68
+ inputSchema: tool.inputSchema as Record<string, unknown>,
69
+ })),
70
+ }));
71
+
72
+ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
73
+ const tool = byName.get(request.params.name);
74
+ if (!tool) throw new Error(`Unknown tool: ${request.params.name}`);
75
+ const toolCallId = request.params._meta?.[TOOL_USE_ID_META];
76
+ if (typeof toolCallId !== "string") {
77
+ throw new Error(`${tool.name}: tools/call is missing _meta["${TOOL_USE_ID_META}"] — cannot pair the result with its tool call`);
78
+ }
79
+ // Narrowed deliberately: McpResult also carries `toolCallId`, which is our
80
+ // own bookkeeping for pairing and not part of MCP's CallToolResult.
81
+ const { content, isError } = await tool.handler(toolCallId);
82
+ return { content, isError };
83
+ });
84
+
85
+ return { type: "sdk" as const, name, instance: server };
86
+ }
package/src/models.ts ADDED
@@ -0,0 +1,159 @@
1
+ // Model selection + display-order policy for the model picker. The picker is
2
+ // driven by pi-ai's anthropic catalog: models appear (and disappear) with it,
3
+ // no per-model code here. Extracted from index.ts so tests can import without
4
+ // activating the extension.
5
+ // `resolveModel` resolves family shortcuts (opus/sonnet/fable) to the newest
6
+ // matching id regardless of sort order; sort order only drives picker display.
7
+
8
+ const TWO_HUNDRED_K_CONTEXT = 200_000;
9
+ const ONE_M_CONTEXT = 1_000_000;
10
+
11
+ // pi-ai ships dated snapshot ids (claude-opus-4-5-20251101, ...) alongside the
12
+ // bare ids. They are never exposed - and must not steal first-partial-match
13
+ // shortcuts like "opus-4-5" from the bare id.
14
+ function isDatedAlias(id: string): boolean {
15
+ return /-20\d{6}$/.test(id);
16
+ }
17
+
18
+ // Family tiers for display order: flagship families first; unknown families
19
+ // sink below all known ones.
20
+ const FAMILY_ORDER = ["fable", "opus", "sonnet", "haiku"];
21
+
22
+ // Project pi-ai's model entries down to the fields pi's registerProvider expects,
23
+ // newest generation first. Context-dependent display labels are applied after
24
+ // plan/long-context config is known.
25
+ // Version rank of a claude id, e.g. claude-opus-4-7 → ["opus", 4, 7]. Shared by
26
+ // the display sort and resolveModel's newest-first partial tiebreak.
27
+ function versionRank(id: string): { family: string; tuple: [number, number] } {
28
+ const [, family, major, minor] = id.split("-");
29
+ return { family, tuple: [Number(major) || 0, Number(minor) || 0] };
30
+ }
31
+
32
+ export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
33
+ return piAiModels
34
+ .filter((m) => typeof m.id === "string" && !isDatedAlias(m.id))
35
+ .sort((a, b) => {
36
+ const fa = FAMILY_ORDER.indexOf(versionRank(a.id).family);
37
+ const fb = FAMILY_ORDER.indexOf(versionRank(b.id).family);
38
+ const ta = fa === -1 ? FAMILY_ORDER.length : fa;
39
+ const tb = fb === -1 ? FAMILY_ORDER.length : fb;
40
+ if (ta !== tb) return ta - tb;
41
+ const ra = versionRank(a.id).tuple;
42
+ const rb = versionRank(b.id).tuple;
43
+ if (ra[0] !== rb[0]) return rb[0] - ra[0];
44
+ if (ra[1] !== rb[1]) return rb[1] - ra[1];
45
+ return a.id.localeCompare(b.id);
46
+ })
47
+ // Forward thinkingLevelMap so pi-ai's per-model overrides (e.g. opus-4-8
48
+ // mapping xhigh→xhigh and max→max) are visible to the effort lookup.
49
+ .map(({ id, name, reasoning, input, contextWindow, maxTokens, thinkingLevelMap }) => ({
50
+ id,
51
+ name,
52
+ reasoning, input, contextWindow, maxTokens,
53
+ thinkingLevelMap,
54
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
55
+ }));
56
+ }
57
+
58
+ export type LongContextSettings = {
59
+ plan: "pro" | "max";
60
+ longContextExtraUsage: boolean;
61
+ // Model ids whose declared 1M context Claude Code turned out not to serve;
62
+ // forces bare id at 200K without a code change.
63
+ forceTwoHundredK?: string[];
64
+ };
65
+
66
+ export type ClaudeCodeRuntimeModel = {
67
+ cliModelId: string;
68
+ contextWindow: number;
69
+ };
70
+
71
+ // Measured Claude Agent SDK behavior - see diag/CONTEXT-SIZE.md:
72
+ // - The `[1m]` suffix is the only reliable way to request 1M context through
73
+ // the SDK; bare ids serve 200K.
74
+ // - An unentitled `[1m]` id is rejected outright (400/429), failing every turn
75
+ // — worse than serving 200K, so the default is bare id at 200K and only
76
+ // measured-good ids get `[1m]`.
77
+ // - The registered contextWindow must match the window the bridge actually
78
+ // requests, or pi's status bar and compaction threshold misreport.
79
+ // [1m] ids verified to serve 1M on every plan. A new model serves 200K until
80
+ // someone measures it (diag/context-size.mjs) and adds it here.
81
+ const MEASURED_ONE_M = new Set([
82
+ "claude-fable-5",
83
+ "claude-fable-5-1",
84
+ "claude-opus-5-5",
85
+ "claude-opus-5",
86
+ "claude-opus-4-8",
87
+ "claude-opus-4-7",
88
+ "claude-sonnet-5",
89
+ ]);
90
+
91
+ // Measured exceptions: pi-ai declares 1M and the [1m] id works, but only when
92
+ // the plan allows it.
93
+ const PLAN_GATED_ONE_M: Record<string, (settings: LongContextSettings) => boolean> = {
94
+ // [1m] measured 1M on Max plan / extra usage; 429 on Pro without it.
95
+ "claude-opus-4-6": (settings) => settings.plan === "max" || settings.longContextExtraUsage,
96
+ // [1m] measured 1M with extra usage only.
97
+ "claude-sonnet-4-6": (settings) => settings.longContextExtraUsage,
98
+ };
99
+
100
+ export function resolveClaudeCodeRuntimeModel(
101
+ model: { id: string },
102
+ settings: LongContextSettings,
103
+ ): ClaudeCodeRuntimeModel {
104
+ const modelId = model.id;
105
+ if (settings.forceTwoHundredK?.includes(modelId)) {
106
+ return { cliModelId: modelId, contextWindow: TWO_HUNDRED_K_CONTEXT };
107
+ }
108
+ if (MEASURED_ONE_M.has(modelId)) {
109
+ return { cliModelId: `${modelId}[1m]`, contextWindow: ONE_M_CONTEXT };
110
+ }
111
+ const planGate = PLAN_GATED_ONE_M[modelId];
112
+ if (planGate) {
113
+ const useOneM = planGate(settings);
114
+ return {
115
+ cliModelId: useOneM ? `${modelId}[1m]` : modelId,
116
+ contextWindow: useOneM ? ONE_M_CONTEXT : TWO_HUNDRED_K_CONTEXT,
117
+ };
118
+ }
119
+ // No measured row: bare id at 200K, the safe default (see diag/CONTEXT-SIZE.md).
120
+ return { cliModelId: modelId, contextWindow: TWO_HUNDRED_K_CONTEXT };
121
+ }
122
+
123
+ export function claudeCodeModelId(model: { id: string }, settings: LongContextSettings): string {
124
+ return resolveClaudeCodeRuntimeModel(model, settings).cliModelId;
125
+ }
126
+
127
+ export function resolveModel<T extends { id: string }>(models: T[], input: string): T | undefined {
128
+ const lower = input.toLowerCase();
129
+ // Exact first, then partial (mirrors pi's tryMatchModel ordering), so a
130
+ // longer newer id containing the input (claude-fable-5-1 vs "claude-fable-5")
131
+ // cannot shadow the exact match.
132
+ return models.find((m) => m.id === lower)
133
+ ?? newestPartialMatch(models.filter((m) => m.id.includes(lower)));
134
+ }
135
+
136
+ // Newest match by version rank — independent of registration order.
137
+ function newestPartialMatch<T extends { id: string }>(candidates: T[]): T | undefined {
138
+ if (candidates.length === 0) return undefined;
139
+ return candidates.reduce((best, m) => {
140
+ const [vb, vbest] = [versionRank(m.id).tuple, versionRank(best.id).tuple];
141
+ const newer = vb[0] !== vbest[0] ? vb[0] > vbest[0] : vb[1] > vbest[1];
142
+ return newer ? m : best;
143
+ });
144
+ }
145
+
146
+ // Produce the model metadata registered with pi. The registered contextWindow must
147
+ // match the window the bridge actually requests from Claude Code, or pi's status
148
+ // bar and auto-compaction threshold will misreport. The runtime policy is based
149
+ // on measured SDK behavior - see diag/CONTEXT-SIZE.md
150
+ export function applyLongContext<T extends { id: string; name: string; contextWindow?: number | null }>(
151
+ models: T[],
152
+ settings: LongContextSettings,
153
+ ): T[] {
154
+ return models.map((m) => {
155
+ const { contextWindow } = resolveClaudeCodeRuntimeModel(m, settings);
156
+ const name = contextWindow > TWO_HUNDRED_K_CONTEXT && !/\b1M\b/i.test(m.name) ? `${m.name} 1M` : m.name;
157
+ return contextWindow === m.contextWindow && name === m.name ? m : { ...m, contextWindow, name };
158
+ });
159
+ }
@@ -0,0 +1,388 @@
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
+ /** Which bridge boundary last recorded this key (before_agent_start | agent_start | turn_start). */
24
+ source?: string;
25
+ /** Exact previously assembled prompts embedded in `custom`. */
26
+ inherited: InheritedPrompt[];
27
+ };
28
+
29
+ /**
30
+ * Captures keyed by the fully assembled prompt pi sends to a provider.
31
+ *
32
+ * A sub-agent's systemPromptOverride embeds its parent's assembled prompt
33
+ * verbatim. Pi currently exposes that override as an ordinary custom prompt,
34
+ * without provenance. Linking exact prior keys recovers the inheritance graph
35
+ * without recognizing pi prose or sub-agent markers. If pi later exposes an
36
+ * inherited-system-prompt field, it should replace this inference.
37
+ */
38
+ export type PromptCaptureDiagnostic = {
39
+ /** The prompt that matched nothing: the full system prompt is too big to log
40
+ * inline, so a fingerprint plus the closest match's first divergent offset
41
+ * are enough to recognize the pump.
42
+ *
43
+ * Closest is by shared prefix — the case that matters here is pi itself
44
+ * rebuilding the prompt outside `before_agent_start` (a changed tool list or
45
+ * fresh resource discovery), which edits near the boundary, and a prefix key
46
+ * gets us to within a handful of characters of where. */
47
+ systemPrompt: string;
48
+ matches: { key: string; firstDivergent: number; source?: string }[];
49
+ };
50
+
51
+ export class PromptCaptures {
52
+ private readonly captures = new Map<string, PromptCapture>();
53
+ /** Invoked with everything that would otherwise be lost when resolution throws,
54
+ * so the bridge can write it to its debug log. Kept off the throw path itself:
55
+ * the resolver is hot and the caller may own a faster sink than string-building.
56
+ *
57
+ * Set by the bridge on the shared instance; tests that want the diagnostic can
58
+ * pass one per instance. */
59
+ private readonly onDiagnose: (diagnostic: PromptCaptureDiagnostic) => void;
60
+
61
+ /** Pi rebuilds prompts when tools change, so retain only recent lookup keys.
62
+ * Inheritance edges hold direct references and survive key eviction.
63
+ *
64
+ * Set well above any plausible working set because the costs are lopsided: a
65
+ * capture is tens of KB, while evicting one that is still live fails the turn.
66
+ * A parent that fans out to more distinct sub-agent prompts than this before its
67
+ * own next turn would be evicted despite being in use. The bound exists only to
68
+ * cap an extension that rebuilds the prompt every turn, which would otherwise
69
+ * grow keys without limit. */
70
+ constructor(private readonly limit = 256, onDiagnose?: (diagnostic: PromptCaptureDiagnostic) => void) {
71
+ this.onDiagnose = onDiagnose ?? (() => {});
72
+ }
73
+
74
+ record(systemPrompt: string, input: PromptCaptureInput, source?: string): void {
75
+ const existing = this.captures.get(systemPrompt);
76
+ const customChanged = existing?.custom !== input.custom;
77
+ const capture = existing ?? {
78
+ ...input,
79
+ assembledPrompt: systemPrompt,
80
+ contextFiles: [],
81
+ skills: [],
82
+ inherited: [],
83
+ };
84
+
85
+ capture.custom = input.custom;
86
+ capture.append = input.append;
87
+ capture.contextFiles = input.contextFiles.map((file) => ({ ...file }));
88
+ capture.skills = [...input.skills];
89
+ capture.source = source;
90
+ if (!existing || customChanged) {
91
+ capture.inherited = this.findInheritedPrompts(systemPrompt, input.custom);
92
+ }
93
+
94
+ // Mutate an existing node in place so descendants retain a live reference,
95
+ // then re-insert its key so Map order tracks recency.
96
+ this.touch(systemPrompt, capture);
97
+ }
98
+
99
+ /** Exact lookup only. Callers serving a query want `resolveOrDerive`. */
100
+ resolve(systemPrompt?: string): PromptCapture | undefined {
101
+ if (!systemPrompt) return undefined;
102
+ const capture = this.captures.get(systemPrompt);
103
+ if (capture) this.touch(systemPrompt, capture);
104
+ return capture;
105
+ }
106
+
107
+ /** Recency is by use, not just by record. A parent agent records its prompt once
108
+ * and then only ever resolves it, so counting writes alone ages it out behind the
109
+ * sub-agent prompts churning past it — observed in a real 135-message session,
110
+ * where the parent's own prompt was evicted and its next turn resolved to
111
+ * nothing. */
112
+ private touch(systemPrompt: string, capture: PromptCapture): void {
113
+ this.captures.delete(systemPrompt);
114
+ this.captures.set(systemPrompt, capture);
115
+ // Trims here, not only in record(): reviving an evicted node re-adds a key that
116
+ // was not in the map, so without this a run of revivals grows it without bound.
117
+ for (const key of this.captures.keys()) {
118
+ if (this.captures.size <= this.limit) break;
119
+ this.captures.delete(key);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The capture to project for one query, for both the provider and AskClaude.
125
+ *
126
+ * An exact key is the normal case. A prompt that only *embeds* known prompts —
127
+ * anything that wrapped what Pi assembled after we recorded it — resolves to a
128
+ * transient descendant over the whole prompt, so projection swaps each embedded
129
+ * capture for its portable parts and carries everything around them through
130
+ * unchanged. That surrounding text belongs to whatever did the wrapping, and
131
+ * dropping it would be exactly the silent instruction loss this exists to
132
+ * prevent. The descendant is not retained — its key is not ours to own.
133
+ *
134
+ * Throws when a prompt can be accounted for by neither route. Returning an empty
135
+ * capture instead would hand Claude Code a turn with none of the user's context
136
+ * files, skills, custom prompt or append text, and say so only in a debug line —
137
+ * silently discarding policy the user wrote down. A failed turn is recoverable;
138
+ * a turn that quietly ignored its instructions is not.
139
+ */
140
+ resolveOrDerive(systemPrompt?: string): PromptCapture | undefined {
141
+ if (!systemPrompt) return undefined;
142
+ const exact = this.captures.get(systemPrompt);
143
+ if (exact) {
144
+ this.touch(systemPrompt, exact);
145
+ return exact;
146
+ }
147
+
148
+ // A capture outlives its lookup key: eviction drops the key while inheritance
149
+ // edges keep the node alive. findInheritedPrompts deliberately skips a node whose
150
+ // key *is* the prompt, so without this an evicted exact match would derive
151
+ // nothing and throw. Touching it puts the key back.
152
+ const revived = this.reachableCaptures().find((node) => node.assembledPrompt === systemPrompt);
153
+ if (revived) {
154
+ this.touch(systemPrompt, revived);
155
+ return revived;
156
+ }
157
+
158
+ // Inheritance must be tried before any tolerance/adoption route. A sub-agent
159
+ // child that embeds its parent's prompt verbatim contains every portable part
160
+ // of the parent's capture, so an "adopt the capture whose portable parts all
161
+ // appear here" heuristic (as drafted in upstream PR #76's findPortableMatch)
162
+ // placed above this route would match first, re-key the PARENT's capture under
163
+ // the child's prompt, and silently drop the child's wrapper text — exactly the
164
+ // instruction loss the throw exists to prevent. If such a route is ever added,
165
+ // it belongs below this block.
166
+ const embedded = this.findInheritedPrompts(systemPrompt, systemPrompt);
167
+ if (embedded.length === 0) {
168
+ const matches = this.closestKnown(systemPrompt);
169
+ this.onDiagnose({ systemPrompt, matches });
170
+ throw new Error(
171
+ `prompt-capture: no capture for this ${systemPrompt.length}-char system prompt, and it embeds none of the ${this.captures.size} known. `
172
+ + `Closest known match diverges at offset ${matches[0]?.firstDivergent ?? "?"} `
173
+ + `(${matches.length ? matches[0].key.length : 0}-char key${matches[0]?.source ? `, last recorded at ${matches[0].source}` : ""}). `
174
+ + `Claude Code would receive none of this turn's context files, skills or custom instructions. `
175
+ + `The usual cause is an extension loaded after claude-bridge that rewrites the system prompt from before_agent_start — `
176
+ + `one that wraps it is fine, one that rebuilds or strips it leaves nothing to match. `
177
+ + `(Also possible: pi rebuilt the prompt outside before_agent_start — a late-registered tool or fresh resource discovery.)`,
178
+ );
179
+ }
180
+
181
+ // `custom` is the prompt itself and the edges keep their original offsets, so
182
+ // projectCustom substitutes the embedded captures in place and preserves every
183
+ // byte between and around them.
184
+ return { assembledPrompt: systemPrompt, custom: systemPrompt, contextFiles: [], skills: [], inherited: embedded };
185
+ }
186
+
187
+ get size(): number {
188
+ return this.captures.size;
189
+ }
190
+
191
+ /** Longest shared-prefix matches, best first, for the throw diagnostic. */
192
+ private closestKnown(systemPrompt: string): { key: string; firstDivergent: number; source?: string }[] {
193
+ let shared = 0;
194
+ const matches: { key: string; firstDivergent: number; source?: string }[] = [];
195
+ for (const [key, capture] of this.captures.entries()) {
196
+ const limit = Math.min(key.length, systemPrompt.length);
197
+ let i = 0;
198
+ while (i < limit && key.charCodeAt(i) === systemPrompt.charCodeAt(i)) i++;
199
+ if (i >= shared) {
200
+ if (i > shared) {
201
+ shared = i;
202
+ matches.length = 0;
203
+ }
204
+ matches.push({ key, firstDivergent: i, source: capture.source });
205
+ }
206
+ }
207
+ return matches;
208
+ }
209
+
210
+ private findInheritedPrompts(systemPrompt: string, custom?: string): InheritedPrompt[] {
211
+ if (!custom) return [];
212
+
213
+ const candidates: Array<InheritedPrompt & { length: number }> = [];
214
+ for (const parent of this.reachableCaptures()) {
215
+ const key = parent.assembledPrompt;
216
+ if (key === systemPrompt || key.length === 0) continue;
217
+ for (let start = custom.indexOf(key); start !== -1; start = custom.indexOf(key, start + key.length)) {
218
+ candidates.push({ start, end: start + key.length, length: key.length, parent });
219
+ }
220
+ }
221
+
222
+ // A grandchild contains both its parent's key and the grandparent key
223
+ // nested inside it. Keep the longest exact non-overlapping matches.
224
+ candidates.sort((a, b) => b.length - a.length || a.start - b.start);
225
+ const selected: InheritedPrompt[] = [];
226
+ for (const candidate of candidates) {
227
+ if (selected.some((edge) => candidate.start < edge.end && candidate.end > edge.start)) continue;
228
+ selected.push({ start: candidate.start, end: candidate.end, parent: candidate.parent });
229
+ }
230
+ return selected.sort((a, b) => a.start - b.start);
231
+ }
232
+
233
+ private reachableCaptures(): PromptCapture[] {
234
+ const result: PromptCapture[] = [];
235
+ const seen = new Set<PromptCapture>();
236
+ const visit = (capture: PromptCapture): void => {
237
+ if (seen.has(capture)) return;
238
+ seen.add(capture);
239
+ result.push(capture);
240
+ for (const edge of capture.inherited) visit(edge.parent);
241
+ };
242
+ for (const capture of this.captures.values()) visit(capture);
243
+ return result;
244
+ }
245
+ }
246
+
247
+ /** Pi's own preamble, the first section of every prompt pi renders for a session
248
+ * without a custom prompt. Machine-generated, so operator text never carries it;
249
+ * forwarding it makes Claude Code's subscription path read the request as a
250
+ * third-party app. */
251
+ export const PI_PREAMBLE = "You are an expert coding assistant operating inside pi";
252
+
253
+ /** Both doc paths from pi's documentation-routing line. Anthropic's subscription gate
254
+ * rejects a system prompt carrying both, while either alone passes (issues #883, #88). */
255
+ const ANTHROPIC_THIRD_PARTY_TRIGGERS = ["docs/custom-provider.md", "docs/packages.md"];
256
+
257
+ /** One piece of the append, named so a refusal can say where it found the text. */
258
+ type PromptPart = { label: string; text: string };
259
+
260
+ const SHARED_CAPTURES_KEY = Symbol.for("claude-bridge:promptCaptures");
261
+
262
+ /** Isolated agents re-evaluate this module; a process-wide instance lets the pinned
263
+ * stream resolve their captures (issue #64). The first instance's onDiagnose wins —
264
+ * later callers reuse the instance as-is. Never cleared at session_shutdown: identical
265
+ * keys carry identical portable parts, so cross-session reuse is safe. */
266
+ export function sharedPromptCaptures(onDiagnose?: (diagnostic: PromptCaptureDiagnostic) => void): PromptCaptures {
267
+ const globals = globalThis as Record<symbol, PromptCaptures | undefined>;
268
+ return (globals[SHARED_CAPTURES_KEY] ??= new PromptCaptures(256, onDiagnose));
269
+ }
270
+
271
+ export function projectPromptCapture(
272
+ capture: PromptCapture,
273
+ options: { skillReadTool: SkillReadTool },
274
+ ): string | undefined {
275
+ return projectCapture(capture, options, new Set());
276
+ }
277
+
278
+ /** Skills visible through inherited prompts, ancestor first and once per file. */
279
+ export function collectPromptSkills(capture: PromptCapture): Skill[] {
280
+ const result: Skill[] = [];
281
+ const seenPaths = new Set<string>();
282
+ const visited = new Set<PromptCapture>();
283
+ const visiting = new Set<PromptCapture>();
284
+
285
+ const visit = (node: PromptCapture): void => {
286
+ if (visited.has(node)) return;
287
+ if (visiting.has(node)) throw new Error("Cyclic prompt inheritance");
288
+ visiting.add(node);
289
+ for (const edge of node.inherited) visit(edge.parent);
290
+ for (const skill of node.skills) {
291
+ if (skill.disableModelInvocation || seenPaths.has(skill.filePath)) continue;
292
+ seenPaths.add(skill.filePath);
293
+ result.push(skill);
294
+ }
295
+ visiting.delete(node);
296
+ visited.add(node);
297
+ };
298
+
299
+ visit(capture);
300
+ return result;
301
+ }
302
+
303
+ function projectCapture(
304
+ capture: PromptCapture,
305
+ options: { skillReadTool: SkillReadTool },
306
+ visiting: Set<PromptCapture>,
307
+ ): string | undefined {
308
+ if (visiting.has(capture)) throw new Error("Cyclic prompt inheritance");
309
+ visiting.add(capture);
310
+ try {
311
+ const inheritedSkillPaths = new Set(
312
+ capture.inherited.flatMap((edge) => collectPromptSkills(edge.parent).map((skill) => skill.filePath)),
313
+ );
314
+ const ownSkillPaths = new Set<string>();
315
+ const ownSkills = capture.skills.filter((skill) => {
316
+ if (skill.disableModelInvocation || inheritedSkillPaths.has(skill.filePath) || ownSkillPaths.has(skill.filePath)) {
317
+ return false;
318
+ }
319
+ ownSkillPaths.add(skill.filePath);
320
+ return true;
321
+ });
322
+
323
+ const custom = projectCustom(capture, options, visiting);
324
+ const parts: PromptPart[] = [];
325
+ const context = formatProjectContext(capture.contextFiles);
326
+ if (context) parts.push({ label: "the project context block", text: context });
327
+ const skills = renderSkillsBlock(ownSkills, options.skillReadTool);
328
+ if (skills) parts.push({ label: "the skills block", text: skills });
329
+ if (custom) parts.push({ label: "the custom prompt", text: custom });
330
+ if (capture.append) parts.push({ label: "the appended instructions", text: capture.append });
331
+ assertSendablePrompt(parts, capture);
332
+ return parts.length > 0 ? parts.map((part) => part.text).join("\n\n") : undefined;
333
+ } finally {
334
+ visiting.delete(capture);
335
+ }
336
+ }
337
+
338
+ function assertSendablePrompt(parts: readonly PromptPart[], capture: PromptCapture): void {
339
+ const findings: string[] = [];
340
+ for (const { label, text } of parts) {
341
+ const offset = preambleAtLineStart(text);
342
+ if (offset !== -1) {
343
+ findings.push(`pi's preamble ("${PI_PREAMBLE}") in ${label}, at offset ${offset} of ${text.length} chars`);
344
+ }
345
+ // The pair has to co-occur in one part; the two phrases split across parts are not detected.
346
+ if (ANTHROPIC_THIRD_PARTY_TRIGGERS.every((trigger) => text.includes(trigger))) {
347
+ findings.push(`${ANTHROPIC_THIRD_PARTY_TRIGGERS.join(" and ")} in ${label}`);
348
+ }
349
+ }
350
+ if (findings.length === 0) return;
351
+
352
+ throw new Error([
353
+ "prompt-capture: refusing to send this prompt. Claude Code's Anthropic path reads a request",
354
+ " carrying pi's harness, or the phrase pair its subscription gate rejects, as a third-party",
355
+ " app: it fails with 400 or is billed as extra usage.",
356
+ ...findings.map((finding) => ` Found: ${finding}.`),
357
+ ` Capture: ${capture.source ?? "unknown"}, ${capture.inherited.length} inherited capture(s) substituted.`,
358
+ " If this came from an inherited pi prompt, see README \"Compatibility with other extensions\".",
359
+ " If it is your own text, reword or remove it. CLAUDE_BRIDGE_DEBUG=1 writes the full prompt to",
360
+ " ~/.pi/agent/claude-bridge.log.",
361
+ ].join("\n"));
362
+ }
363
+
364
+ /** Offset of pi's preamble at the start of a line, or -1. Mid-line mentions are someone
365
+ * describing pi's prompt, not pi's prompt. */
366
+ function preambleAtLineStart(text: string): number {
367
+ for (let offset = text.indexOf(PI_PREAMBLE); offset !== -1; offset = text.indexOf(PI_PREAMBLE, offset + PI_PREAMBLE.length)) {
368
+ if (offset === 0 || text[offset - 1] === "\n") return offset;
369
+ }
370
+ return -1;
371
+ }
372
+
373
+ function projectCustom(
374
+ capture: PromptCapture,
375
+ options: { skillReadTool: SkillReadTool },
376
+ visiting: Set<PromptCapture>,
377
+ ): string | undefined {
378
+ if (!capture.custom || capture.inherited.length === 0) return capture.custom;
379
+
380
+ let result = "";
381
+ let cursor = 0;
382
+ for (const edge of capture.inherited) {
383
+ result += capture.custom.slice(cursor, edge.start);
384
+ result += projectCapture(edge.parent, options, visiting) ?? "";
385
+ cursor = edge.end;
386
+ }
387
+ return result + capture.custom.slice(cursor);
388
+ }