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