pi-claude-agent-sdk 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/assets/claude-bridge1.png +0 -0
- package/assets/claude-bridge2.png +0 -0
- package/package.json +68 -0
- package/src/agents-md.ts +20 -0
- package/src/askclaude-ui.ts +90 -0
- package/src/config.ts +78 -0
- package/src/convert.ts +186 -0
- package/src/extract-tool-results.ts +47 -0
- package/src/index.ts +2036 -0
- package/src/mcp-server.ts +86 -0
- package/src/models.ts +95 -0
- package/src/prompt-stream.ts +102 -0
- package/src/query-state.ts +79 -0
- package/src/session-verify.ts +38 -0
- package/src/skills.ts +24 -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,114 @@
|
|
|
1
|
+
# pi-claude-agent-sdk
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-claude-agent-sdk)
|
|
4
|
+
|
|
5
|
+
Pi extension that integrates Claude Code via the [Agent SDK](https://github.com/anthropics/claude-agent-sdk-typescript). Forked from [pi-claude-bridge](https://github.com/elidickinson/pi-claude-bridge) by Eli Dickinson, which was based initially on [claude-agent-sdk-pi](https://github.com/prateekmedia/claude-agent-sdk-pi) by Prateek Sunal. Adds streaming, MCP tool bridging, custom pi tool bridging, session resume/persistence, context sync, thinking support, skills forwarding, and the AskClaude tool.
|
|
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. As of June 15, 2026 it uses subscription quota just like Claude Code direct does.
|
|
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-agent-sdk
|
|
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
|
+
**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).
|
|
31
|
+
|
|
32
|
+
## AskClaude Tool
|
|
33
|
+
|
|
34
|
+
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:
|
|
35
|
+
|
|
36
|
+
- "Ask Claude to plan a fix"
|
|
37
|
+
- "If you get stuck, ask claude for help"
|
|
38
|
+
- "Ask claude to review the plan in @foo.md, implement it, then ask an isolated=true claude to review the implementation"
|
|
39
|
+
- "Ask claude to poke holes in this theory"
|
|
40
|
+
- "Find all the places in the codebase that handle auth"
|
|
41
|
+
|
|
42
|
+
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."
|
|
43
|
+
|
|
44
|
+
### Parameters
|
|
45
|
+
|
|
46
|
+
- **`prompt`** — the question or task for Claude Code
|
|
47
|
+
- **`mode`** — `read` (default, read files and search/fetch on web), `none`, or `full` (read+write+bash, disable this mode with `allowFullMode: false` in config)
|
|
48
|
+
- **`model`** — `opus` (default), `sonnet`, `haiku`, or a full model ID
|
|
49
|
+
- **`thinking`** — effort level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`
|
|
50
|
+
- **`isolated`** — when `true`, Claude gets a clean session with no conversation history (default: `false`)
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
Config: `~/.pi/agent/claude-bridge.json` (global) or the project Pi config directory, usually `.pi/claude-bridge.json` (project; merged over global).
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"askClaude": {
|
|
59
|
+
"enabled": true,
|
|
60
|
+
"allowFullMode": true,
|
|
61
|
+
"defaultIsolated": false,
|
|
62
|
+
"description": "Custom tool description override"
|
|
63
|
+
},
|
|
64
|
+
"provider": {
|
|
65
|
+
"plan": "max",
|
|
66
|
+
"longContextExtraUsage": false,
|
|
67
|
+
"strictMcpConfig": true,
|
|
68
|
+
"pathToClaudeCodeExecutable": "/home/you/.nix-profile/bin/claude"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`askClaude`:
|
|
74
|
+
- `enabled` — register the AskClaude tool (default `true`)
|
|
75
|
+
- `name` — override the tool's pi-side name (default `"AskClaude"`)
|
|
76
|
+
- `label` — override the TUI label (default `"Ask Claude Code"`)
|
|
77
|
+
- `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."*
|
|
78
|
+
- `defaultMode` — `"read"` (default), `"none"`, or `"full"`
|
|
79
|
+
- `defaultIsolated` — start each call in a fresh session (default `false`)
|
|
80
|
+
- `allowFullMode` — allow `mode: "full"`; set `false` to lock it out
|
|
81
|
+
- `appendSkills` — forward pi's skills block into the system prompt (default `true`)
|
|
82
|
+
|
|
83
|
+
`provider`:
|
|
84
|
+
- `plan` (default `"pro"`) — set to `"max"` for Max (or Team Premium/Enterprise) to enable Opus 4.6 with 1M 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.
|
|
85
|
+
- `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.
|
|
86
|
+
- `appendSystemPrompt` — append pi's project context files (global and ancestor `AGENTS.md` / `CLAUDE.md`) and skills (default `true`)
|
|
87
|
+
- `settingSources` — CC filesystem settings to load; only applied when `appendSystemPrompt: false`
|
|
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
|
+
**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.
|
|
94
|
+
|
|
95
|
+
## Tests
|
|
96
|
+
|
|
97
|
+
`npm run test:unit` for offline tests (`tests/unit-*.mjs`: queue, import, skills).
|
|
98
|
+
|
|
99
|
+
`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`).
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
## Debugging
|
|
104
|
+
|
|
105
|
+
Set `CLAUDE_BRIDGE_DEBUG=1` to enable debug output:
|
|
106
|
+
|
|
107
|
+
- **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`.
|
|
108
|
+
- **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.
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
## Maintenance
|
|
113
|
+
|
|
114
|
+
After a Claude Code release, review `MODE_DISALLOWED_TOOLS` in `src/index.ts` — it gates which CC tools the AskClaude subagent may invoke per mode (`read` / `full` / `none`). Add new agentic tools (PlanMode, Task spawning, etc.) to the appropriate mode lists if they shouldn't be available to subagents.
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-claude-agent-sdk",
|
|
3
|
+
"version": "0.7.0",
|
|
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
|
+
"contributors": [
|
|
15
|
+
"Evan Verma"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/pi-pod/pi-claude-agent-sdk.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/pi-pod/pi-claude-agent-sdk#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/pi-pod/pi-claude-agent-sdk/issues"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"assets"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test:unit": "node --import tsx --import ./tests/lib/setup.mjs --test tests/unit-*.mjs",
|
|
37
|
+
"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",
|
|
38
|
+
"test:usage": "tests/usage-test.sh",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"type": "module",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.141",
|
|
44
|
+
"@anthropic-ai/sdk": "^0.73.0",
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
46
|
+
"cc-session-io": "^0.3.2",
|
|
47
|
+
"change-case": "^5.4.4",
|
|
48
|
+
"typebox": "^1.3.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@earendil-works/pi-ai": ">=0.82.1",
|
|
52
|
+
"@earendil-works/pi-coding-agent": ">=0.82.1",
|
|
53
|
+
"@earendil-works/pi-tui": ">=0.82.1"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@earendil-works/pi-ai": "^0.82.1",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^0.82.1",
|
|
58
|
+
"@earendil-works/pi-tui": "^0.82.1",
|
|
59
|
+
"@types/node": "^24.13.2",
|
|
60
|
+
"tsx": "^4.22.4",
|
|
61
|
+
"typescript": "^6.0.3"
|
|
62
|
+
},
|
|
63
|
+
"pi": {
|
|
64
|
+
"extensions": [
|
|
65
|
+
"./src/index.ts"
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/agents-md.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Pi owns context-file discovery. Reuse its public loader so Claude receives
|
|
2
|
+
// the same global and hierarchical AGENTS.md/CLAUDE.md instructions as Pi.
|
|
3
|
+
|
|
4
|
+
import { getAgentDir, loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
type ContextFile = { path: string; content: string };
|
|
7
|
+
|
|
8
|
+
export function extractAgentsAppend(cwd: string = process.cwd()): string | undefined {
|
|
9
|
+
return formatProjectContext(loadProjectContextFiles({ cwd, agentDir: getAgentDir() }));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatProjectContext(contextFiles: ContextFile[]): string | undefined {
|
|
13
|
+
if (contextFiles.length === 0) return undefined;
|
|
14
|
+
|
|
15
|
+
let prompt = "<project_context>\n\nProject-specific instructions and guidelines:\n\n";
|
|
16
|
+
for (const { path, content } of contextFiles) {
|
|
17
|
+
prompt += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
|
|
18
|
+
}
|
|
19
|
+
return `${prompt}</project_context>`;
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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 type { SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
8
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
10
|
+
import { dirname, join } from "path";
|
|
11
|
+
|
|
12
|
+
export interface Config {
|
|
13
|
+
/** Date (YYYY-MM-DD) the one-time startup notice was shown. Written by the extension, not the user. */
|
|
14
|
+
startupNoticeShown?: string;
|
|
15
|
+
askClaude?: {
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
name?: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
defaultMode?: "full" | "read" | "none";
|
|
21
|
+
defaultIsolated?: boolean;
|
|
22
|
+
allowFullMode?: boolean;
|
|
23
|
+
appendSkills?: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** Low-level Claude Agent SDK plumbing. Most users won't need these. */
|
|
26
|
+
provider?: {
|
|
27
|
+
appendSystemPrompt?: boolean;
|
|
28
|
+
settingSources?: SettingSource[];
|
|
29
|
+
strictMcpConfig?: boolean;
|
|
30
|
+
autoMemoryEnabled?: boolean;
|
|
31
|
+
pathToClaudeCodeExecutable?: string;
|
|
32
|
+
// Subscription plan tier. Setting to "max" enables Opus 4.6 at 1M context
|
|
33
|
+
plan?: "pro" | "max";
|
|
34
|
+
// Set to true to opt into metered 1M context usage ("extra usage" in
|
|
35
|
+
// Anthropic billing). Enables Sonnet 4.6 [1m] on every plan and Opus 4.6
|
|
36
|
+
// [1m] on Pro.
|
|
37
|
+
longContextExtraUsage?: boolean;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function tryParseJson(path: string): Partial<Config> {
|
|
42
|
+
if (!existsSync(path)) return {};
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error(`claude-bridge: failed to parse ${path}: ${e}`);
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function claudeCodeSettings(provider: Config["provider"] = {}): { autoMemoryEnabled: boolean } {
|
|
52
|
+
return { autoMemoryEnabled: provider.autoMemoryEnabled ?? false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function globalConfigPath(): string {
|
|
56
|
+
return join(getAgentDir(), "claude-bridge.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Record today's date in the global config so the startup notice shows once. Preserves every other field. */
|
|
60
|
+
export function markStartupNoticeShown(): string {
|
|
61
|
+
const path = globalConfigPath();
|
|
62
|
+
// en-CA renders YYYY-MM-DD in local time; toISOString() would report UTC.
|
|
63
|
+
const today = new Date().toLocaleDateString("en-CA");
|
|
64
|
+
const next = { ...tryParseJson(path), startupNoticeShown: today };
|
|
65
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
66
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function loadConfig(cwd: string): Config {
|
|
71
|
+
const global = tryParseJson(globalConfigPath());
|
|
72
|
+
const project = tryParseJson(join(cwd, CONFIG_DIR_NAME, "claude-bridge.json"));
|
|
73
|
+
return {
|
|
74
|
+
startupNoticeShown: project.startupNoticeShown ?? global.startupNoticeShown,
|
|
75
|
+
askClaude: { ...global.askClaude, ...project.askClaude },
|
|
76
|
+
provider: { ...global.provider, ...project.provider },
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/convert.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Pure pi→Anthropic message conversion helpers.
|
|
2
|
+
// Extracted so they can be tested without pulling in the full extension runtime.
|
|
3
|
+
|
|
4
|
+
import type { Message as PiMessage } from "@earendil-works/pi-ai";
|
|
5
|
+
import type { Message as SessionMessage } from "cc-session-io";
|
|
6
|
+
import { pascalCase } from "change-case";
|
|
7
|
+
import { MCP_TOOL_PREFIX } from "./skills.js";
|
|
8
|
+
|
|
9
|
+
export const PROVIDER_ID = "claude-bridge";
|
|
10
|
+
|
|
11
|
+
// Pi tool names under Claude Code's builtin names. Only ever correct on the
|
|
12
|
+
// AskClaude path, where CC runs its own tools — see mapPiToolNameToSdk.
|
|
13
|
+
export const PI_TO_SDK_TOOL_NAME: Record<string, string> = {
|
|
14
|
+
read: "Read", write: "Write", edit: "Edit", bash: "Bash",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function sanitizeToolId(id: string, cache: Map<string, string>): string {
|
|
18
|
+
const existing = cache.get(id);
|
|
19
|
+
if (existing) return existing;
|
|
20
|
+
const clean = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
21
|
+
cache.set(id, clean);
|
|
22
|
+
return clean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A pi tool name as the name a rebuilt transcript has to call it by.
|
|
26
|
+
*
|
|
27
|
+
* Whether a map is passed is what distinguishes the two query shapes, because
|
|
28
|
+
* they need opposite answers:
|
|
29
|
+
*
|
|
30
|
+
* - **With a map — the provider path.** The query runs `tools: []`, so every
|
|
31
|
+
* tool Claude can call is a pi tool served over MCP, and its name is
|
|
32
|
+
* `mcp__custom-tools__<pi name>` by construction (resolveMcpTools). The map
|
|
33
|
+
* is consulted first only because it carries the served tool's exact casing.
|
|
34
|
+
* A name it lacks is a tool pi ran that we do not serve now — AskClaude,
|
|
35
|
+
* excluded on purpose, or an extension since disabled — and naming that after
|
|
36
|
+
* a Claude Code builtin would tell the model a builtin it cannot call is
|
|
37
|
+
* available and was already used. That is the prompt condition behind the
|
|
38
|
+
* phantom-call deadlock fixed in 122914dd, and the read direction refuses the
|
|
39
|
+
* same names for the same reason (piToolNameFor in index.ts).
|
|
40
|
+
* - **Without a map — the AskClaude path.** CC runs its own tools there, so
|
|
41
|
+
* builtin names are real, matching mapToolName in the other direction.
|
|
42
|
+
*/
|
|
43
|
+
export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<string, string>): string {
|
|
44
|
+
if (!name) return "";
|
|
45
|
+
const normalized = name.toLowerCase();
|
|
46
|
+
// Pi history holds pi tool names. Our own SDK prefix can only reach here by
|
|
47
|
+
// feeding already-converted names back through the conversion, and prefixing
|
|
48
|
+
// twice invents a tool nobody serves.
|
|
49
|
+
if (normalized.startsWith(MCP_TOOL_PREFIX)) {
|
|
50
|
+
throw new Error(`mapPiToolNameToSdk: "${name}" is already an SDK tool name — pi history holds pi tool names`);
|
|
51
|
+
}
|
|
52
|
+
if (!customToolNameToSdk) return PI_TO_SDK_TOOL_NAME[normalized] ?? pascalCase(name);
|
|
53
|
+
return customToolNameToSdk.get(name) ?? customToolNameToSdk.get(normalized) ?? `${MCP_TOOL_PREFIX}${name}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function messageContentToText(
|
|
57
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
58
|
+
): string {
|
|
59
|
+
if (typeof content === "string") return content;
|
|
60
|
+
if (!Array.isArray(content)) return "";
|
|
61
|
+
const parts = [];
|
|
62
|
+
let hasText = false;
|
|
63
|
+
for (const block of content) {
|
|
64
|
+
if (block.type === "text" && block.text) { parts.push(block.text); hasText = true; }
|
|
65
|
+
else if (block.type !== "text" && block.type !== "image") { parts.push(`[${block.type}]`); }
|
|
66
|
+
}
|
|
67
|
+
return hasText ? parts.join("\n") : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Tool results are flattened to text, which is how Claude Code stores most of
|
|
71
|
+
// them. Images are the exception: they have no text form, so a result carrying
|
|
72
|
+
// one keeps the block array shape instead (also what CC writes for screenshots).
|
|
73
|
+
function toolResultContent(
|
|
74
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
75
|
+
): string | Array<Record<string, unknown>> {
|
|
76
|
+
if (typeof content === "string" || !Array.isArray(content)) return messageContentToText(content) || "";
|
|
77
|
+
const images = content.filter((b) => b.type === "image" && b.data && b.mimeType);
|
|
78
|
+
if (!images.length) return messageContentToText(content) || "";
|
|
79
|
+
const blocks: Array<Record<string, unknown>> = [];
|
|
80
|
+
for (const block of content) {
|
|
81
|
+
if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
|
|
82
|
+
else if (block.type === "image" && block.data && block.mimeType) {
|
|
83
|
+
blocks.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
|
|
84
|
+
} else if (block.type !== "text" && block.type !== "image") {
|
|
85
|
+
// Same marker messageContentToText leaves for unrecognized blocks, so the
|
|
86
|
+
// text and image paths describe an extension's output the same way.
|
|
87
|
+
blocks.push({ type: "text", text: `[${block.type}]` });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return blocks;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Convert pi message array to Anthropic API format. */
|
|
94
|
+
export function convertPiMessages(
|
|
95
|
+
messages: PiMessage[],
|
|
96
|
+
customToolNameToSdk?: Map<string, string>,
|
|
97
|
+
): { anthropicMessages: SessionMessage[]; sanitizedIds: Map<string, string> } {
|
|
98
|
+
const anthropicMessages = [];
|
|
99
|
+
const sanitizedIds = new Map();
|
|
100
|
+
// The user message collecting this assistant turn's tool results, if one has
|
|
101
|
+
// been emitted yet, and the index of the assistant message it belongs to. Both
|
|
102
|
+
// are cleared at every assistant message — see the toolResult branch.
|
|
103
|
+
let turnResults: { role: "user"; content: Array<Record<string, unknown>> } | null = null;
|
|
104
|
+
let turnAssistantIdx: number | null = null;
|
|
105
|
+
|
|
106
|
+
for (const msg of messages) {
|
|
107
|
+
if (msg.role === "user") {
|
|
108
|
+
if (typeof msg.content === "string") {
|
|
109
|
+
anthropicMessages.push({ role: "user", content: msg.content || "[empty]" });
|
|
110
|
+
} else if (Array.isArray(msg.content)) {
|
|
111
|
+
const parts = [];
|
|
112
|
+
for (const block of msg.content) {
|
|
113
|
+
if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
|
|
114
|
+
else if (block.type === "image" && block.data && block.mimeType) {
|
|
115
|
+
parts.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
anthropicMessages.push({ role: "user", content: parts.length ? parts : "[image]" });
|
|
119
|
+
} else {
|
|
120
|
+
anthropicMessages.push({ role: "user", content: "[empty]" });
|
|
121
|
+
}
|
|
122
|
+
} else if (msg.role === "assistant") {
|
|
123
|
+
turnResults = null;
|
|
124
|
+
turnAssistantIdx = anthropicMessages.length;
|
|
125
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
126
|
+
const blocks = [];
|
|
127
|
+
for (const block of content) {
|
|
128
|
+
if (block.type === "text" && block.text) {
|
|
129
|
+
blocks.push({ type: "text", text: block.text });
|
|
130
|
+
} else if (block.type === "thinking") {
|
|
131
|
+
// Only replay thinking Claude Code itself produced. A signature minted
|
|
132
|
+
// by any other provider — including pi's own Anthropic provider — is
|
|
133
|
+
// not ours to hand back, and Anthropic rejects ones it can't verify.
|
|
134
|
+
const sig = block.thinkingSignature;
|
|
135
|
+
if (msg.provider === PROVIDER_ID && sig) {
|
|
136
|
+
blocks.push({ type: "thinking", thinking: block.thinking ?? "", signature: sig });
|
|
137
|
+
}
|
|
138
|
+
} else if (block.type === "toolCall") {
|
|
139
|
+
const toolName = mapPiToolNameToSdk(block.name, customToolNameToSdk);
|
|
140
|
+
blocks.push({ type: "tool_use", id: sanitizeToolId(block.id, sanitizedIds), name: toolName, input: block.arguments ?? {} });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
|
|
144
|
+
anthropicMessages.push({ role: "assistant", content: blocks });
|
|
145
|
+
} else if (msg.role === "toolResult") {
|
|
146
|
+
// Pi records one message per tool result, and repairToolPairing only
|
|
147
|
+
// pairs results that share the user message directly after their
|
|
148
|
+
// assistant message. Split across messages, the second and later results
|
|
149
|
+
// match no pending tool_use id: they are dropped and replaced with a
|
|
150
|
+
// synthetic "[no tool result recorded]", so every rebuild silently
|
|
151
|
+
// destroyed the output of parallel tool calls. Session.importMessages
|
|
152
|
+
// applies the repair itself, so this cannot be opted out of by skipping
|
|
153
|
+
// our own call. (Claude Code's live writer splits a turn across records
|
|
154
|
+
// — one per content block, one per result — so the single-message shape
|
|
155
|
+
// is repairToolPairing's requirement, not a copy of CC's own layout;
|
|
156
|
+
// tests/int-cc-contracts.mjs pins both facts.)
|
|
157
|
+
//
|
|
158
|
+
// Collecting into the turn's first result message rather than the
|
|
159
|
+
// immediately preceding one also handles a steer landing mid-execution,
|
|
160
|
+
// which pi records between the results (see extractAllToolResults).
|
|
161
|
+
// The results also have to sit *directly* after their assistant message:
|
|
162
|
+
// repairToolPairing consumes the turn's pending ids at the first user
|
|
163
|
+
// message that follows it, so a steer arriving before the first result —
|
|
164
|
+
// what any steer during a slow first tool looks like — would otherwise
|
|
165
|
+
// take the stubs and strand every real result behind it.
|
|
166
|
+
//
|
|
167
|
+
// Both hoists reorder the steer against wall-clock: Claude sees results
|
|
168
|
+
// that were still running when the steer arrived. Claude Code normalizes
|
|
169
|
+
// to the same order — it records a mid-turn steer as an `attachment`, and
|
|
170
|
+
// reorderAttachmentsForAPI (claude-code-rip src/utils/messages.ts:1481)
|
|
171
|
+
// bubbles attachments up to the nearest assistant or tool_result message
|
|
172
|
+
// and re-inserts them after it. The on-disk form differs, the order does not.
|
|
173
|
+
const block = { type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content: toolResultContent(msg.content), is_error: msg.isError };
|
|
174
|
+
if (turnResults) {
|
|
175
|
+
turnResults.content.push(block);
|
|
176
|
+
} else {
|
|
177
|
+
turnResults = { role: "user", content: [block] };
|
|
178
|
+
// A result with no assistant message before it is malformed history;
|
|
179
|
+
// appending keeps it in order for repairToolPairing to discard.
|
|
180
|
+
anthropicMessages.splice(turnAssistantIdx === null ? anthropicMessages.length : turnAssistantIdx + 1, 0, turnResults);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { anthropicMessages, sanitizedIds };
|
|
186
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Tool-result extraction: walks the context tail to collect this turn's
|
|
2
|
+
// tool results. Pi appends results to context and calls the provider again;
|
|
3
|
+
// this scrapes them back out. Walks past user messages (steer/followUp) that
|
|
4
|
+
// pi may inject between toolResults. Stops at the nearest assistant message
|
|
5
|
+
// (turn boundary).
|
|
6
|
+
// Extracted from index.ts so tests can import without activating the extension.
|
|
7
|
+
|
|
8
|
+
export type McpContent = Array<
|
|
9
|
+
| { type: "text"; text: string }
|
|
10
|
+
| { type: "image"; data: string; mimeType: string }
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
export interface McpResult {
|
|
14
|
+
content: McpContent;
|
|
15
|
+
isError?: boolean;
|
|
16
|
+
toolCallId?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toolResultToMcpContent(
|
|
21
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
22
|
+
): McpContent {
|
|
23
|
+
if (typeof content === "string") return [{ type: "text", text: content || "" }];
|
|
24
|
+
if (!Array.isArray(content)) return [{ type: "text", text: "" }];
|
|
25
|
+
const blocks: McpContent = [];
|
|
26
|
+
for (const block of content) {
|
|
27
|
+
if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
|
|
28
|
+
else if (block.type === "image" && block.data && block.mimeType) blocks.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
|
29
|
+
}
|
|
30
|
+
return blocks.length ? blocks : [{ type: "text", text: "" }];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Returns { results, stopIdx } so callers can log the walk boundary.
|
|
34
|
+
export function extractAllToolResults(
|
|
35
|
+
messages: Array<{ role: string; content?: unknown; toolCallId?: string; isError?: boolean; [key: string]: unknown }>,
|
|
36
|
+
): { results: McpResult[]; stopIdx: number } {
|
|
37
|
+
const results: McpResult[] = [];
|
|
38
|
+
let stopIdx = -1;
|
|
39
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
40
|
+
const msg = messages[i];
|
|
41
|
+
if (msg.role === "toolResult") {
|
|
42
|
+
results.unshift({ content: toolResultToMcpContent(msg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>), isError: msg.isError, toolCallId: msg.toolCallId });
|
|
43
|
+
} else if (msg.role === "assistant") { stopIdx = i; break; }
|
|
44
|
+
// user messages: skip (steer/followUp injected mid-tool-execution)
|
|
45
|
+
}
|
|
46
|
+
return { results, stopIdx };
|
|
47
|
+
}
|