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 +7 -3
- package/package.json +6 -6
- package/src/agents-md.ts +2 -8
- package/src/attachments.ts +135 -0
- package/src/config.ts +17 -6
- package/src/convert.ts +40 -4
- package/src/index.ts +289 -52
- package/src/prompt-capture.ts +342 -0
- package/src/query-state.ts +6 -0
- package/src/skills.ts +8 -12
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.
|
|
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>
|
|
@@ -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.
|
|
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.
|
|
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
|
-
"@
|
|
55
|
-
"@earendil-works/pi-
|
|
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.
|
|
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
|
|
2
|
-
// the same
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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:
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
781
|
+
let pendingNotices: string[] = [];
|
|
686
782
|
|
|
687
|
-
function
|
|
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 (
|
|
691
|
-
|
|
786
|
+
if (pendingNotices.length === 0 || piMode !== "tui") return;
|
|
787
|
+
const notices = pendingNotices;
|
|
788
|
+
pendingNotices = [];
|
|
692
789
|
const path = markStartupNoticeShown();
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/query-state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
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 {
|