@viniciosrab/pi-claude-bridge 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,137 @@
1
+ # pi-claude-bridge
2
+
3
+ > **Fork note.** `@viniciosrab/pi-claude-bridge` is an automated mirror of [`pi-claude-bridge`](https://www.npmjs.com/package/pi-claude-bridge) by Eli Dickinson ([elidickinson/pi-claude-bridge](https://github.com/elidickinson/pi-claude-bridge)), rebuilt for each upstream release with one addition: the `provider.loadClaudeSettings` option ([upstream PR #142](https://github.com/elidickinson/pi-claude-bridge/pull/142)). Versions match upstream. Licensed MIT; original copyright and credit belong to Eli Dickinson. Fork: [viniciosrab/pi-claude-bridge](https://github.com/viniciosrab/pi-claude-bridge).
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@viniciosrab/pi-claude-bridge)](https://www.npmjs.com/package/@viniciosrab/pi-claude-bridge)
6
+
7
+ Pi extension that integrates Claude Code via the [Agent SDK](https://github.com/anthropics/claude-agent-sdk-typescript). Originally based on [claude-agent-sdk-pi](https://github.com/prateekmedia/claude-agent-sdk-pi) by Prateek Sunal.
8
+
9
+ 1. **Provider** — Use Opus/Sonnet/Haiku as models in pi, with all tool calls flowing through pi's TUI
10
+ 2. **AskClaude tool** — Delegate tasks or questions to Claude Code when using another provider
11
+
12
+
13
+ **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.
14
+
15
+ <p>
16
+ <a href="assets/claude-bridge1.png"><img src="assets/claude-bridge1.png" width="49%"></a>&nbsp;
17
+ <a href="assets/claude-bridge2.png"><img src="assets/claude-bridge2.png" width="49%"></a>
18
+ </p>
19
+
20
+ ## Install
21
+
22
+ ```
23
+ pi install npm:pi-claude-bridge
24
+ ```
25
+
26
+ Requires pi 0.86.1 or newer.
27
+
28
+ ## Provider
29
+
30
+ Use `/model` to select any Claude model in pi-ai's catalog, e.g. `claude-bridge/claude-fable-5-1`, `claude-bridge/claude-opus-5`, or `claude-bridge/claude-haiku-4-5`.
31
+
32
+ Behind the scenes, pi's tools are bridged to Claude Code but everything works like normal in pi. Bash commands get Claude Code's 120-second default timeout since pi's bash has none. Skills are forwarded to Claude Code's system prompt, and steering mid-turn reaches Claude at the next tool boundary.
33
+
34
+ The model list comes from pi-ai's Anthropic catalog automatically — when pi-ai adds a new Claude model, it appears in `/model` after updating the package, no bridge update needed. Dated snapshot ids (e.g. `claude-opus-4-5-20251101`) are not shown.
35
+
36
+ **1M Context:** Fable 5/5.1, Opus 5.5/5/4.8/4.7, and Sonnet 5 get 1M context. Opus 4.6 gets 1M only on a Max plan or with Extra Usage, and Sonnet 4.6 only with Extra Usage — set `provider.plan` and/or `provider.longContextExtraUsage` as described in [Configuration](#configuration).
37
+
38
+ ## AskClaude Tool
39
+
40
+ 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:
41
+
42
+ - "Ask Claude to plan a fix"
43
+ - "If you get stuck, ask claude for help"
44
+ - "Ask claude to review the plan in @foo.md, implement it, then ask an isolated=true claude to review the implementation"
45
+ - "Ask claude to poke holes in this theory"
46
+
47
+ Delegated calls don't read global `CLAUDE.md` files or Claude Code's skill listing, and always get Claude Code's own system prompt.
48
+
49
+ ### Parameters
50
+
51
+ - **`prompt`** — the question or task for Claude Code
52
+ - **`mode`** — `read` (default, read files and search/fetch on web), `none`, or `full` (read+write+bash, disable this mode with `allowFullMode: false` in config)
53
+ - **`model`** — `opus` (default), `sonnet`, `haiku`, or a full model ID
54
+ - **`thinking`** — effort level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`
55
+ - **`isolated`** — when `true`, Claude gets a clean session with no conversation history (default: `false`)
56
+
57
+ ## Configuration
58
+
59
+ Config: `~/.pi/agent/claude-bridge.json` (global) or the project Pi config directory, usually `.pi/claude-bridge.json` (project; merged over global).
60
+
61
+ ```json
62
+ {
63
+ "askClaude": {
64
+ "enabled": true,
65
+ "allowFullMode": true,
66
+ "defaultIsolated": false,
67
+ "description": "Custom tool description override"
68
+ },
69
+ "provider": {
70
+ "plan": "max",
71
+ "longContextExtraUsage": false,
72
+ "strictMcpConfig": true,
73
+ "pathToClaudeCodeExecutable": "/home/you/.nix-profile/bin/claude"
74
+ }
75
+ }
76
+ ```
77
+
78
+ `askClaude`:
79
+ - `enabled` — register the AskClaude tool (default `false`). If it's unset, the startup notice below points this out once.
80
+ - `name` — override the tool's pi-side name (default `"AskClaude"`)
81
+ - `label` — override the TUI label (default `"Ask Claude Code"`)
82
+ - `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."*
83
+ - `defaultMode` — `"read"` (default), `"none"`, or `"full"`
84
+ - `defaultIsolated` — start each call in a fresh session (default `false`)
85
+ - `allowFullMode` — allow `mode: "full"`; set `false` to lock it out
86
+ - `appendSkills` — forward pi's skills block into the system prompt (default `true`)
87
+
88
+ `provider`:
89
+ - `plan` (default `"pro"`) — set to `"max"` if you have a Max (or Team Premium/Enterprise) Anthropic plan. This enables Opus with 1M context.
90
+ - `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.
91
+ - `forceTwoHundredK` — array of model ids to pin to 200K context (bare id, no `[1m]` suffix). Use if pi-ai declares a model at 1M but Claude Code won't serve it on your plan.
92
+ - `strictMcpConfig` — block MCP servers from `~/.claude.json` / `.mcp.json` (default `true`). Cloud MCP (Gmail/Drive via claude.ai OAuth) is always blocked.
93
+ - `autoMemoryEnabled` — enable Claude Code's auto-memory system (default `false`)
94
+ - `loadClaudeSettings` — load Claude Code's user/project/local settings in the provider's Claude Code subprocess (default `true`). Set `false` to skip them: settings-sourced hooks and plugins no longer run on every turn, which can noticeably cut tokens and latency when you have many configured. It also drops settings-sourced `env` and `apiKeyHelper`, so keep the default if you rely on those (e.g. Bedrock/Vertex setup in `settings.json`). OAuth login, pi tools and extensions are unaffected. Applies only to the provider path; AskClaude always loads settings.
95
+ - `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"`.
96
+
97
+
98
+ **Startup notice:** the first session lists `provider.plan` and `askClaude.enabled` if unset, then records `startupNoticeShown` in the global config so it doesn't nag again.
99
+
100
+ **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 — to pin a model to 200K, use `provider.forceTwoHundredK` instead.
101
+
102
+ ## Tests
103
+
104
+ `npm run test:unit` for offline tests. `npm test` adds integration tests that hit APIs; set `CLAUDE_BRIDGE_TESTING_ALT_MODEL` in `.env.test` for the alt-provider smoke test.
105
+
106
+ Integration tests spawn real `pi` and Claude Code subprocesses and need write access to `~/.claude` — a sandbox that blocks it makes `--resume` fail with `No conversation found with session ID`.
107
+
108
+ ## Debugging
109
+
110
+ Set `CLAUDE_BRIDGE_DEBUG=1` to enable debug output:
111
+
112
+ - **Bridge log** at `~/.pi/agent/claude-bridge.log` — provider calls, session sync decisions, tool results, CC stderr. Override location with `CLAUDE_BRIDGE_DEBUG_PATH`.
113
+ - **Per-query CC CLI logs** at `~/.pi/agent/cc-cli-logs/<timestamp>-<tag>-<seq>.log` — the subprocess's own debug stream; tag is `provider` or `askclaude`. Shows CC's view of session loading, API requests, and tool calls.
114
+
115
+ 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.
116
+
117
+ ## Compatibility with other extensions
118
+
119
+ Other extensions can change the system prompt. When the result still contains pi's built-in system prompt text, or the two documentation paths that Anthropic looks for (`docs/custom-provider.md` in the same prompt with `docs/packages.md`), the bridge stops the turn instead of sending it, since Anthropic may otherwise bill these requests as Extra Usage. Fix the source extension before retrying; `CLAUDE_BRIDGE_DEBUG=1` writes the full prompt to `~/.pi/agent/claude-bridge.log` when this happens.
120
+
121
+ ### Using claude bridge with @gotgenes/pi-subagents
122
+
123
+ Requires the following in `~/.pi/agent/subagents.json`:
124
+
125
+ ```json
126
+ {"promptInheritance": {"claude-bridge": "portable"}}
127
+ ```
128
+
129
+ ## Known issues
130
+
131
+ **A session rebuild re-sends the whole conversation.** The bridge rewrites Claude Code's session from pi's history whenever the two diverge — after an abort, `/compact`, tree navigation, an API error, or on returning to a session — and the next request usually misses the prompt cache for everything past the system prompt. Abort-heavy sessions cost noticeably more.
132
+
133
+ **Files Claude Code edits are not carried across a rebuild.** The edit itself survives in the history as a tool call and result — what's lost is the post-edit file snapshot. `@file` expansions *are* carried.
134
+
135
+ **System prompt changes mid-session may not reach the model.** The bridge keeps Claude Code's default prompt recording: project context (AGENTS.md/CLAUDE.md), skills, and extension-written instructions are captured on the first request and reused on resume. This keeps the cached prefix stable, but later changes may not take effect until a rebuild or compaction. Start a new session if updated instructions must take effect immediately.
136
+
137
+ **Exported Anthropic environment variables override the Claude Code child (issue #107).** An exported `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` redirects Claude Code to that gateway and every turn fails with its auth error. Unset them for the pi process.
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@viniciosrab/pi-claude-bridge",
3
+ "version": "0.9.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
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/viniciosrab/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; CLAUDE_BRIDGE_TEST_LOG_DIR=$(mktemp -d); export CLAUDE_BRIDGE_TEST_LOG_DIR; npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs && node tests/lib/check-deadlock-logs.mjs \"$CLAUDE_BRIDGE_TEST_LOG_DIR\"",
31
+ "test:usage": "tests/usage-test.sh",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "type": "module",
35
+ "dependencies": {
36
+ "@anthropic-ai/claude-agent-sdk": "^0.3.280",
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.86.1",
43
+ "@earendil-works/pi-coding-agent": ">=0.86.1",
44
+ "@earendil-works/pi-tui": ">=0.86.1",
45
+ "typebox": "*"
46
+ },
47
+ "devDependencies": {
48
+ "@anthropic-ai/sdk": "^0.124.0",
49
+ "@earendil-works/pi-ai": "^0.87.1",
50
+ "@earendil-works/pi-coding-agent": "^0.87.1",
51
+ "@earendil-works/pi-tui": "^0.87.1",
52
+ "@types/node": "^24.13.2",
53
+ "tsx": "^4.22.4",
54
+ "typebox": "^1.3.7",
55
+ "typescript": "^6.0.3"
56
+ },
57
+ "pi": {
58
+ "extensions": [
59
+ "./src/index.ts"
60
+ ]
61
+ },
62
+ "homepage": "https://github.com/viniciosrab/pi-claude-bridge#readme",
63
+ "bugs": {
64
+ "url": "https://github.com/viniciosrab/pi-claude-bridge/issues"
65
+ }
66
+ }
@@ -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,92 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type } from "typebox";
3
+ import type { Config } from "./config.js";
4
+
5
+ export type AskClaudeMode = "full" | "read" | "none";
6
+
7
+ export interface AskClaudeDefaults {
8
+ mode: AskClaudeMode;
9
+ isolated: boolean;
10
+ allowFull: boolean;
11
+ }
12
+
13
+ const PACKAGE_DEFAULT_MODE: AskClaudeMode = "read";
14
+ const PACKAGE_DEFAULT_ISOLATED = false;
15
+
16
+ export function resolveAskClaudeDefaults(conf: Config["askClaude"]): AskClaudeDefaults {
17
+ const rawAllowFull: unknown = conf?.allowFullMode;
18
+ const allowFull = rawAllowFull == null ? true : rawAllowFull === true;
19
+ const configuredMode: unknown = conf?.defaultMode;
20
+ const mode = configuredMode == null
21
+ ? PACKAGE_DEFAULT_MODE
22
+ : configuredMode === "full" || configuredMode === "read" || configuredMode === "none"
23
+ ? configuredMode
24
+ // Unrecognized value: the most restrictive mode, not the package default.
25
+ : "none";
26
+ return { mode: !allowFull && mode === "full" ? PACKAGE_DEFAULT_MODE : mode, isolated: conf?.defaultIsolated ?? PACKAGE_DEFAULT_ISOLATED, allowFull };
27
+ }
28
+
29
+ export function resolveAskClaudeMode(mode: unknown, defaults: AskClaudeDefaults): AskClaudeMode {
30
+ const resolved = mode === undefined ? defaults.mode : mode;
31
+ if (resolved !== "full" && resolved !== "read" && resolved !== "none") throw new Error(`Invalid AskClaude mode: ${String(resolved)}`);
32
+ if (resolved === "full" && !defaults.allowFull) throw new Error("AskClaude full mode is disabled.");
33
+ return resolved;
34
+ }
35
+
36
+ function modeDescription(defaults: AskClaudeDefaults): string {
37
+ const mark = (mode: AskClaudeMode) => (mode === defaults.mode ? " (default)" : "");
38
+ const parts = [
39
+ `"read"${mark("read")}: questions about the codebase — review, analysis, explain.`,
40
+ `"none"${mark("none")}: general knowledge only (no file access).`,
41
+ ];
42
+ if (defaults.allowFull) {
43
+ parts.push(`"full"${mark("full")}: allows writing and bash execution (careful: runs without feedback to pi).`);
44
+ }
45
+ return parts.join(" ");
46
+ }
47
+
48
+ export function buildAskClaudeParams(defaults: AskClaudeDefaults) {
49
+ const visibility = defaults.isolated
50
+ ? "By default Claude sees only this prompt (isolated session)."
51
+ : "By default Claude sees the full conversation history.";
52
+ const trueDefault = defaults.isolated ? " (default)" : "";
53
+ const falseDefault = defaults.isolated ? "" : " (default)";
54
+ const modeValues: readonly AskClaudeMode[] = defaults.allowFull ? ["read", "full", "none"] : ["read", "none"];
55
+ return Type.Object({
56
+ prompt: Type.String({ description: `The question or task for Claude Code. ${visibility} Don't research up front, let Claude explore.` }),
57
+ mode: Type.Optional(StringEnum(modeValues, { description: modeDescription(defaults) })),
58
+ model: Type.Optional(Type.String({ description: 'Claude model (e.g. "opus", "sonnet", "haiku", or full ID). Defaults to "opus".' })),
59
+ thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, { description: "Thinking effort level. Omit to use Claude Code's default." })),
60
+ isolated: Type.Optional(Type.Boolean({ description: `When true${trueDefault}, Claude sees only this prompt (clean session). When false${falseDefault}, Claude sees the full conversation history.` })),
61
+ });
62
+ }
63
+
64
+ export function askClaudeToolDescription(defaults: AskClaudeDefaults, override?: string | null): string {
65
+ if (override != null) return override;
66
+ const suffix = " Prefer to handle straightforward tasks yourself.";
67
+ if (!defaults.allowFull) {
68
+ const middle = defaults.mode === "none"
69
+ ? 'Defaults to no file access — pass mode "read" to let Claude Code explore the codebase; it can never make changes.'
70
+ : "Read-only — Claude Code can explore the codebase but not make changes.";
71
+ return `Delegate to Claude Code for a second opinion or analysis (code review, architecture questions, debugging theories). ${middle}${suffix}`;
72
+ }
73
+ const middle = defaults.mode === "full"
74
+ ? 'Defaults to full mode — Claude Code writes files and runs bash without feedback to pi; pass mode "read" to keep it to exploration.'
75
+ : defaults.mode === "none"
76
+ ? 'Defaults to no file access — pass mode "read" to let Claude Code explore the codebase, or "full" for a task that requires changes.'
77
+ : "Defaults to read-only mode — use full mode when the user wants to delegate a task that requires changes.";
78
+ return `Delegate to Claude Code for a second opinion or analysis (code review, architecture questions, debugging theories), or to autonomously handle a task. ${middle}${suffix}`;
79
+ }
80
+
81
+ export function askClaudeCallTags(
82
+ args: { mode?: AskClaudeMode; model?: string; thinking?: string; isolated?: boolean },
83
+ defaults: AskClaudeDefaults,
84
+ ): string[] {
85
+ const tags: string[] = [];
86
+ const mode = args.mode ?? defaults.mode;
87
+ if (mode !== PACKAGE_DEFAULT_MODE || (args.mode !== undefined && args.mode !== defaults.mode)) tags.push(`mode=${mode}`);
88
+ if (args.model) tags.push(`model=${args.model}`);
89
+ if (args.thinking) tags.push(`thinking=${args.thinking}`);
90
+ if ((args.isolated ?? defaults.isolated) !== PACKAGE_DEFAULT_ISOLATED) tags.push("isolated");
91
+ return tags;
92
+ }
@@ -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
+ }