@schuettc/pi-claude-bridge 0.7.0-schuettc.1
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/LICENSE +21 -0
- package/README.md +118 -0
- package/assets/claude-bridge1.png +0 -0
- package/assets/claude-bridge2.png +0 -0
- package/package.json +63 -0
- package/src/agents-md.ts +14 -0
- package/src/askclaude-ui.ts +90 -0
- package/src/attachments.ts +135 -0
- package/src/config.ts +89 -0
- package/src/convert.ts +222 -0
- package/src/extract-tool-results.ts +47 -0
- package/src/index.ts +2454 -0
- package/src/mcp-server.ts +86 -0
- package/src/models.ts +95 -0
- package/src/prompt-capture.ts +315 -0
- package/src/prompt-stream.ts +102 -0
- package/src/query-state.ts +85 -0
- package/src/session-verify.ts +38 -0
- package/src/skills.ts +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eli Dickinson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# pi-claude-bridge
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-claude-bridge)
|
|
4
|
+
|
|
5
|
+
Pi extension that integrates Claude Code via the [Agent SDK](https://github.com/anthropics/claude-agent-sdk-typescript). Based initially on [claude-agent-sdk-pi](https://github.com/prateekmedia/claude-agent-sdk-pi) by Prateek Sunal. This fork adds streaming, MCP tool bridging, custom pi tool bridging, session resume/persistence, context sync, thinking support, skills forwarding, and many correctness fixes.
|
|
6
|
+
|
|
7
|
+
1. **Provider** — Use Opus/Sonnet/Haiku as models in pi, with all tool calls flowing through pi's TUI
|
|
8
|
+
2. **AskClaude tool** — Delegate tasks or questions to Claude Code when using another provider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
**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.
|
|
12
|
+
|
|
13
|
+
<p>
|
|
14
|
+
<a href="assets/claude-bridge1.png"><img src="assets/claude-bridge1.png" width="49%"></a>
|
|
15
|
+
<a href="assets/claude-bridge2.png"><img src="assets/claude-bridge2.png" width="49%"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pi install npm:pi-claude-bridge
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Provider
|
|
25
|
+
|
|
26
|
+
Use `/model` to select `claude-bridge/claude-fable-5`, `claude-bridge/claude-opus-5`, `claude-bridge/claude-opus-4-8`, `claude-bridge/claude-opus-4-7`, `claude-bridge/claude-opus-4-6`, `claude-bridge/claude-sonnet-5`, `claude-bridge/claude-sonnet-4-6`, or `claude-bridge/claude-haiku-4-5`.
|
|
27
|
+
|
|
28
|
+
Behind the scenes, pi's tools are bridged to Claude Code but it should all work like normal in pi. Bash commands get a 120-second default timeout (matching Claude Code's default) since pi's bash has no timeout by default. Skills in pi are copied over to Claude Code's system prompt so should work as they would with any other pi provider. Steering works mid-turn: a message sent while Claude is running a tool reaches it at that tool boundary, not after the whole turn finishes.
|
|
29
|
+
|
|
30
|
+
**Other extensions' own model calls:** some extensions run a small agent of their own — to summarize, label or take notes — rather than going through the conversation. On a bridge model those calls used to crash pi outright; they now run as their own Claude Code session, using the calling extension's system prompt and tools and leaving your conversation's session alone. Each one is a Claude Code subprocess on the model you selected, so if an extension makes them often, consider pointing it at a cheaper model where it lets you.
|
|
31
|
+
|
|
32
|
+
**1M Context:** Opus 5, Opus 4.8, and Opus 4.7 get 1M context by default. Opus 4.6 only gets 1M if you're on a Max plan or pay for Extra Usage. Sonnet 4.6 only gets 1M if you pay for Extra Usage. You will need to set `provider.plan` and/or `provider.longContextExtraUsage` for 1M context in Opus 4.6/Sonnet 4.6 as described in [Configuration](#configuration).
|
|
33
|
+
|
|
34
|
+
## AskClaude Tool
|
|
35
|
+
|
|
36
|
+
Opt-in: set `askClaude.enabled` to `true` (see [Configuration](#configuration)). Available when using any non-claude-bridge provider. Pi's LLM can delegate tasks to Claude Code and wait for it to answer a question or perform a task. Examples of how to use:
|
|
37
|
+
|
|
38
|
+
- "Ask Claude to plan a fix"
|
|
39
|
+
- "If you get stuck, ask claude for help"
|
|
40
|
+
- "Ask claude to review the plan in @foo.md, implement it, then ask an isolated=true claude to review the implementation"
|
|
41
|
+
- "Ask claude to poke holes in this theory"
|
|
42
|
+
- "Find all the places in the codebase that handle auth"
|
|
43
|
+
|
|
44
|
+
You could also create skills or add something to AGENTS.md to e.g. "Always call Ask Claude to review complicated feature implementations before considering the task complete."
|
|
45
|
+
|
|
46
|
+
### Parameters
|
|
47
|
+
|
|
48
|
+
- **`prompt`** — the question or task for Claude Code
|
|
49
|
+
- **`mode`** — `read` (default, read files and search/fetch on web), `none`, or `full` (read+write+bash, disable this mode with `allowFullMode: false` in config)
|
|
50
|
+
- **`model`** — `opus` (default), `sonnet`, `haiku`, or a full model ID
|
|
51
|
+
- **`thinking`** — effort level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`
|
|
52
|
+
- **`isolated`** — when `true`, Claude gets a clean session with no conversation history (default: `false`)
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
Config: `~/.pi/agent/claude-bridge.json` (global) or the project Pi config directory, usually `.pi/claude-bridge.json` (project; merged over global).
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"askClaude": {
|
|
61
|
+
"enabled": true,
|
|
62
|
+
"allowFullMode": true,
|
|
63
|
+
"defaultIsolated": false,
|
|
64
|
+
"description": "Custom tool description override"
|
|
65
|
+
},
|
|
66
|
+
"provider": {
|
|
67
|
+
"plan": "max",
|
|
68
|
+
"longContextExtraUsage": false,
|
|
69
|
+
"strictMcpConfig": true,
|
|
70
|
+
"pathToClaudeCodeExecutable": "/home/you/.nix-profile/bin/claude"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`askClaude`:
|
|
76
|
+
- `enabled` — register the AskClaude tool (default `false`). If it's unset, the startup notice below points this out once.
|
|
77
|
+
- `name` — override the tool's pi-side name (default `"AskClaude"`)
|
|
78
|
+
- `label` — override the TUI label (default `"Ask Claude Code"`)
|
|
79
|
+
- `description` — override the tool description. Default when `allowFullMode: true`: *"Delegate to Claude Code for a second opinion or analysis (code review, architecture questions, debugging theories), or to autonomously handle a task. Defaults to read-only mode — use full mode when the user wants to delegate a task that requires changes. Prefer to handle straightforward tasks yourself."*
|
|
80
|
+
- `defaultMode` — `"read"` (default), `"none"`, or `"full"`
|
|
81
|
+
- `defaultIsolated` — start each call in a fresh session (default `false`)
|
|
82
|
+
- `allowFullMode` — allow `mode: "full"`; set `false` to lock it out
|
|
83
|
+
- `appendSkills` — forward pi's skills block into the system prompt (default `true`)
|
|
84
|
+
|
|
85
|
+
`provider`:
|
|
86
|
+
- `plan` (default `"pro"`) — set to `"max"` if you have a Max (or Team Premium/Enterprise) Anthropic plan. This enables Opus with 1M context.
|
|
87
|
+
- `longContextExtraUsage` — set to `true` to enable 1M context models even if they cost money through Extra Usage on your plan. 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.
|
|
88
|
+
- `strictMcpConfig` — block MCP servers from `~/.claude.json` / `.mcp.json` (default `true`). Cloud MCP (Gmail/Drive via claude.ai OAuth) is always blocked.
|
|
89
|
+
- `autoMemoryEnabled` — enable Claude Code's auto-memory system (default `false`)
|
|
90
|
+
- `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"`.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
**Startup notice:** the first interactive session to reach Claude Code lists whichever of `provider.plan` and `askClaude.enabled` you have left unset, then records `startupNoticeShown` (the date, `YYYY-MM-DD`) in the global config so it doesn't nag again.
|
|
94
|
+
|
|
95
|
+
**Extension providers and models.json:** pi's `modelOverrides` in `~/.pi/agent/models.json` do not currently apply to extension-registered providers (like claude-bridge). Overriding `contextWindow` or other fields requires editing `src/models.ts` directly.
|
|
96
|
+
|
|
97
|
+
## Tests
|
|
98
|
+
|
|
99
|
+
`npm run test:unit` for offline tests (`tests/unit-*.mjs`: queue, import, skills).
|
|
100
|
+
|
|
101
|
+
`npm test` for the full suite, which adds integration tests that hit APIs (`tests/int-*.{sh,mjs}`: smoke, multi-turn, cache, session-resume, session-rebuild, tool-message). Set `CLAUDE_BRIDGE_TESTING_ALT_MODEL` in `.env.test` for the alt-provider smoke test (e.g. `openrouter/z-ai/glm-4.7-flash`).
|
|
102
|
+
|
|
103
|
+
Integration tests spawn real `pi` and Claude Code subprocesses, so they need write access to `~/.claude` for CC's session state — a sandbox that blocks it makes the next turn's `--resume` fail with `No conversation found with session ID`. The RPC harness probes for this at startup and fails fast.
|
|
104
|
+
|
|
105
|
+
## Debugging
|
|
106
|
+
|
|
107
|
+
Set `CLAUDE_BRIDGE_DEBUG=1` to enable debug output:
|
|
108
|
+
|
|
109
|
+
- **Bridge log** at `~/.pi/agent/claude-bridge.log` — every provider call, session sync decision, tool result delivery, and CC's stderr. Override location with `CLAUDE_BRIDGE_DEBUG_PATH`.
|
|
110
|
+
- **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 `askclaude` (sub-delegation). Useful when a resume fails or CC misbehaves internally — shows the CLI's own view of session loading, API requests, and tool calls.
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
## Known issues
|
|
115
|
+
|
|
116
|
+
**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.
|
|
117
|
+
|
|
118
|
+
**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.
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@schuettc/pi-claude-bridge",
|
|
3
|
+
"version": "0.7.0-schuettc.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Pi extension that uses Claude Code (via Agent SDK) as a model provider and adds an AskClaude tool.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"extension",
|
|
10
|
+
"claude-code",
|
|
11
|
+
"claude-agent-sdk"
|
|
12
|
+
],
|
|
13
|
+
"author": "Eli Dickinson",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/schuettc/pi-claude-bridge.git"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"assets"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test:unit": "node --import tsx --import ./tests/lib/setup.mjs --test tests/unit-*.mjs",
|
|
30
|
+
"test": "set -a && [ -f .env.test ] && . .env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
|
|
31
|
+
"test:usage": "tests/usage-test.sh",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"type": "module",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.141",
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
|
+
"cc-session-io": "^0.4.0",
|
|
39
|
+
"change-case": "^5.4.4"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": ">=0.82.1",
|
|
43
|
+
"@earendil-works/pi-coding-agent": ">=0.82.1",
|
|
44
|
+
"@earendil-works/pi-tui": ">=0.82.1",
|
|
45
|
+
"typebox": "*"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@anthropic-ai/sdk": "^0.73.0",
|
|
49
|
+
"@earendil-works/pi-agent-core": "^0.83.0",
|
|
50
|
+
"@earendil-works/pi-ai": "^0.83.0",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^0.83.0",
|
|
52
|
+
"@earendil-works/pi-tui": "^0.83.0",
|
|
53
|
+
"@types/node": "^24.13.2",
|
|
54
|
+
"tsx": "^4.22.4",
|
|
55
|
+
"typebox": "^1.3.7",
|
|
56
|
+
"typescript": "^6.0.3"
|
|
57
|
+
},
|
|
58
|
+
"pi": {
|
|
59
|
+
"extensions": [
|
|
60
|
+
"./src/index.ts"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/agents-md.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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.
|
|
3
|
+
|
|
4
|
+
type ContextFile = { path: string; content: string };
|
|
5
|
+
|
|
6
|
+
export function formatProjectContext(contextFiles: ContextFile[]): string | undefined {
|
|
7
|
+
if (contextFiles.length === 0) return undefined;
|
|
8
|
+
|
|
9
|
+
let prompt = "<project_context>\n\nProject-specific instructions and guidelines:\n\n";
|
|
10
|
+
for (const { path, content } of contextFiles) {
|
|
11
|
+
prompt += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
|
|
12
|
+
}
|
|
13
|
+
return `${prompt}</project_context>`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Status-line rendering helpers for the AskClaude tool.
|
|
2
|
+
//
|
|
3
|
+
// While Claude Code runs inside an AskClaude call, the pi TUI can't surface
|
|
4
|
+
// each tool_use individually — there's only one status row for the whole
|
|
5
|
+
// delegation. These helpers shape a tool_use record into a short, path-aware
|
|
6
|
+
// label (e.g. "Read(src/foo.ts)", "Bash(git log --oneline…)") and collapse
|
|
7
|
+
// runs of the same tool so the line doesn't flicker. Used only by
|
|
8
|
+
// promptAndWait; the provider path exposes tools directly through pi's TUI
|
|
9
|
+
// and doesn't need this.
|
|
10
|
+
|
|
11
|
+
export interface ToolCallState {
|
|
12
|
+
name: string;
|
|
13
|
+
status: string;
|
|
14
|
+
rawInput?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function extractPath(rawInput: unknown): string | undefined {
|
|
18
|
+
if (!rawInput || typeof rawInput !== "object") return undefined;
|
|
19
|
+
const input = rawInput as Record<string, unknown>;
|
|
20
|
+
if (typeof input.file_path === "string") return input.file_path;
|
|
21
|
+
if (typeof input.path === "string") return input.path;
|
|
22
|
+
if (typeof input.command === "string") return input.command.substring(0, 80);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shortPath(p: string): string {
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
if (p.startsWith(cwd + "/")) return p.slice(cwd.length + 1);
|
|
29
|
+
if (p.startsWith("/")) {
|
|
30
|
+
const parts = p.split("/");
|
|
31
|
+
if (parts.length > 3) return parts.slice(-2).join("/");
|
|
32
|
+
}
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatToolAction(tc: ToolCallState): string | undefined {
|
|
37
|
+
const path = extractPath(tc.rawInput);
|
|
38
|
+
const verb = tc.name.toLowerCase().split(/\s/)[0];
|
|
39
|
+
if (verb === "read" || verb === "readfile") {
|
|
40
|
+
return path ? `Read(${shortPath(path)})` : "Read";
|
|
41
|
+
} else if (verb === "glob") {
|
|
42
|
+
const input = tc.rawInput as Record<string, unknown> | undefined;
|
|
43
|
+
const pat = typeof input?.pattern === "string" ? input.pattern.slice(0, 40) : "";
|
|
44
|
+
return pat ? `Glob(${pat})` : "Glob";
|
|
45
|
+
} else if (verb === "edit" || verb === "write" || verb === "writefile" || verb === "multiedit") {
|
|
46
|
+
return path ? `Edit(${shortPath(path)})` : "Edit";
|
|
47
|
+
} else if (verb === "bashoutput") {
|
|
48
|
+
return undefined; // redundant with preceding Bash call
|
|
49
|
+
} else if (verb === "bash" || verb === "terminal") {
|
|
50
|
+
return path ? `Bash(${path})` : "Bash";
|
|
51
|
+
} else if (verb === "agent") {
|
|
52
|
+
const input = tc.rawInput as Record<string, unknown> | undefined;
|
|
53
|
+
return `Agent(${String(input?.description ?? "").slice(0, 40)})`;
|
|
54
|
+
} else if (verb === "grep") {
|
|
55
|
+
const input = tc.rawInput as Record<string, unknown> | undefined;
|
|
56
|
+
const pat = typeof input?.pattern === "string" ? input.pattern.slice(0, 40) : "";
|
|
57
|
+
return pat ? `Grep(${pat})` : "Grep";
|
|
58
|
+
} else if (verb === "skill") {
|
|
59
|
+
const input = tc.rawInput as Record<string, unknown> | undefined;
|
|
60
|
+
const name = typeof input?.skill === "string" ? input.skill.slice(0, 40) : "";
|
|
61
|
+
return name ? `Skill(${name})` : "Skill";
|
|
62
|
+
} else if (verb === "todowrite" || verb === "taskcreate" || verb === "taskupdate") {
|
|
63
|
+
const todos = Array.isArray((tc.rawInput as any)?.todos) ? (tc.rawInput as any).todos : [];
|
|
64
|
+
const current = todos.find((t: any) => t.status === "in_progress") ?? todos.find((t: any) => t.status === "pending");
|
|
65
|
+
const label = current ? String(current.content ?? "").slice(0, 40) : "";
|
|
66
|
+
return label || undefined;
|
|
67
|
+
} else if (verb === "askclaude") {
|
|
68
|
+
// Recursive — don't show AskClaude in its own action summary
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return tc.name;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildActionSummary(calls: Map<string, ToolCallState>): string {
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
let prevVerb = "";
|
|
77
|
+
for (const [, tc] of calls) {
|
|
78
|
+
const action = formatToolAction(tc);
|
|
79
|
+
if (!action) continue;
|
|
80
|
+
const verb = tc.name.toLowerCase().split(/\s/)[0];
|
|
81
|
+
// Collapse consecutive calls to the same tool — keep only the latest
|
|
82
|
+
if (verb === prevVerb) {
|
|
83
|
+
parts[parts.length - 1] = action;
|
|
84
|
+
} else {
|
|
85
|
+
parts.push(action);
|
|
86
|
+
}
|
|
87
|
+
prevVerb = verb;
|
|
88
|
+
}
|
|
89
|
+
return parts.join("; ");
|
|
90
|
+
}
|
|
@@ -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
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// User-facing extension config. Loaded once at extension registration from
|
|
2
|
+
// the global agent dir (getAgentDir(), e.g. ~/.pi/agent/claude-bridge.json)
|
|
3
|
+
// and the project Pi config directory, project overriding global. Missing or
|
|
4
|
+
// unparseable files are ignored (error to console.error, empty object
|
|
5
|
+
// returned) so the extension always starts.
|
|
6
|
+
|
|
7
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
9
|
+
import { dirname, join } from "path";
|
|
10
|
+
|
|
11
|
+
export interface Config {
|
|
12
|
+
/** Date (YYYY-MM-DD) the one-time startup notice was shown. Written by the extension, not the user. */
|
|
13
|
+
startupNoticeShown?: string;
|
|
14
|
+
askClaude?: {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
name?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
defaultMode?: "full" | "read" | "none";
|
|
20
|
+
defaultIsolated?: boolean;
|
|
21
|
+
allowFullMode?: boolean;
|
|
22
|
+
appendSkills?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** Low-level Claude Agent SDK plumbing. Most users won't need these. */
|
|
25
|
+
provider?: {
|
|
26
|
+
strictMcpConfig?: boolean;
|
|
27
|
+
autoMemoryEnabled?: boolean;
|
|
28
|
+
pathToClaudeCodeExecutable?: string;
|
|
29
|
+
// Subscription plan tier. Setting to "max" enables Opus 4.6 at 1M context
|
|
30
|
+
plan?: "pro" | "max";
|
|
31
|
+
// Set to true to opt into metered 1M context usage ("extra usage" in
|
|
32
|
+
// Anthropic billing). Enables Sonnet 4.6 [1m] on every plan and Opus 4.6
|
|
33
|
+
// [1m] on Pro.
|
|
34
|
+
longContextExtraUsage?: boolean;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function tryParseJson(path: string): Partial<Config> {
|
|
39
|
+
if (!existsSync(path)) return {};
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(`claude-bridge: failed to parse ${path}: ${e}`);
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function claudeCodeSettings(provider: Config["provider"] = {}): { autoMemoryEnabled: boolean } {
|
|
49
|
+
return { autoMemoryEnabled: provider.autoMemoryEnabled ?? false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function globalConfigPath(): string {
|
|
53
|
+
return join(getAgentDir(), "claude-bridge.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Record today's date in the global config so the startup notice shows once, preserving every
|
|
57
|
+
* other field. Returns the config path for display either way.
|
|
58
|
+
*
|
|
59
|
+
* Parses directly rather than through tryParseJson, which reports an unparseable file as `{}`:
|
|
60
|
+
* spreading that would replace a user's whole config with just this marker the first time they
|
|
61
|
+
* leave a trailing comma in it. Losing the notice is the cheaper failure, so the write is
|
|
62
|
+
* skipped and the notice simply shows again next session. */
|
|
63
|
+
export function markStartupNoticeShown(): string {
|
|
64
|
+
const path = globalConfigPath();
|
|
65
|
+
let existing: Partial<Config> = {};
|
|
66
|
+
if (existsSync(path)) {
|
|
67
|
+
try {
|
|
68
|
+
existing = JSON.parse(readFileSync(path, "utf-8"));
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error(`claude-bridge: leaving ${path} alone, it does not parse: ${e}`);
|
|
71
|
+
return path;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// en-CA renders YYYY-MM-DD in local time; toISOString() would report UTC.
|
|
75
|
+
const next = { ...existing, startupNoticeShown: new Date().toLocaleDateString("en-CA") };
|
|
76
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function loadConfig(cwd: string): Config {
|
|
82
|
+
const global = tryParseJson(globalConfigPath());
|
|
83
|
+
const project = tryParseJson(join(cwd, CONFIG_DIR_NAME, "claude-bridge.json"));
|
|
84
|
+
return {
|
|
85
|
+
startupNoticeShown: project.startupNoticeShown ?? global.startupNoticeShown,
|
|
86
|
+
askClaude: { ...global.askClaude, ...project.askClaude },
|
|
87
|
+
provider: { ...global.provider, ...project.provider },
|
|
88
|
+
};
|
|
89
|
+
}
|