@saccolabs/pi-claude-cli 0.4.7 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/index.ts +10 -12
- package/package.json +1 -1
- package/src/event-bridge.ts +10 -7
- package/src/process-manager.ts +31 -0
- package/src/provider.ts +118 -82
- package/src/rate-limit.ts +64 -0
- package/src/session-map.ts +67 -0
- package/src/tool-mapping.ts +11 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ A [pi](https://github.com/earendil-works/pi) extension that routes LLM calls thr
|
|
|
12
12
|
|
|
13
13
|
## How it works
|
|
14
14
|
|
|
15
|
-
The extension registers as a custom pi provider exposing all Claude models.
|
|
15
|
+
The extension registers as a custom pi provider exposing all Claude models. It runs in **observer mode**: the Claude Code CLI is a first-class agent that owns its loop, its tools and its session — pi is the system of record and observes the stream. One CLI session per pi session, resumed with `--resume` on every follow-up turn, so token use matches using the CLI directly. Built-in tools (Read, Bash, …) execute natively inside the CLI and surface to pi as `[Claude Code · Name]` activity markers. Custom pi tools are advertised via a schema-only MCP server and **handed off**: the provider interrupts the turn cleanly, pi executes the tool (all pi hooks fire), and the next turn resumes with the result.
|
|
16
16
|
|
|
17
17
|
## Requirements
|
|
18
18
|
|
|
@@ -44,7 +44,10 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
|
|
|
44
44
|
- Maps tool names and arguments bidirectionally between Claude and pi
|
|
45
45
|
- Exposes custom pi tools to Claude via MCP (schema-only, no execution)
|
|
46
46
|
- Break-early pattern prevents Claude CLI from auto-executing tools
|
|
47
|
-
-
|
|
47
|
+
- One CLI session per pi session (sidecar-mapped), resumed on every follow-up turn — native caching, no history replay
|
|
48
|
+
- Native tool execution: the CLI runs its own tools; guards are injected as Claude Code PreToolUse hooks via `PI_CLAUDE_CLI_SETTINGS`
|
|
49
|
+
- Reports account rate-limit state (window, reset, overage) to the front-end
|
|
50
|
+
on the `claude-rate-limit` status key — never mixed into turn content
|
|
48
51
|
- Configurable thinking effort across the full ladder (low to max) for all models, with elevated mapping for Opus
|
|
49
52
|
- Cross-platform subprocess management (Windows, macOS, Linux)
|
|
50
53
|
- Inactivity timeout and process registry for cleanup
|
|
@@ -55,6 +58,11 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
|
|
|
55
58
|
the two-ledger session model, error recovery, and the CLI compatibility
|
|
56
59
|
notes (including the 2.x control-protocol shape).
|
|
57
60
|
|
|
61
|
+
Two of its sections are **contracts a front-end can depend on**, so read
|
|
62
|
+
them before changing what this extension emits: the
|
|
63
|
+
`[Claude Code · Tool {args}]` marker string, and the `claude-rate-limit`
|
|
64
|
+
status key.
|
|
65
|
+
|
|
58
66
|
## What your Claude environment contributes
|
|
59
67
|
|
|
60
68
|
Each turn runs a real `claude -p` subprocess in your workspace, so your
|
package/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./src/process-manager.js";
|
|
17
17
|
import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
|
|
18
18
|
import { rewriteOverflowMessage } from "./src/overflow.js";
|
|
19
|
+
import { buildRateLimitPayload, rateLimitIdentity } from "./src/rate-limit.js";
|
|
19
20
|
|
|
20
21
|
// Kill all active Claude subprocesses on process exit to prevent orphans
|
|
21
22
|
process.on("exit", killAllProcesses);
|
|
@@ -40,21 +41,18 @@ let lastRateLimitJson: string | undefined;
|
|
|
40
41
|
function publishRateLimit(info: Record<string, unknown>): void {
|
|
41
42
|
const setStatus = uiContext?.ui?.setStatus;
|
|
42
43
|
if (typeof setStatus !== "function") return;
|
|
43
|
-
const payload =
|
|
44
|
-
status: info.status,
|
|
45
|
-
resetsAt: info.resetsAt,
|
|
46
|
-
rateLimitType: info.rateLimitType,
|
|
47
|
-
overageStatus: info.overageStatus,
|
|
48
|
-
isUsingOverage: info.isUsingOverage === true,
|
|
49
|
-
observedAt: Math.floor(Date.now() / 1000),
|
|
50
|
-
});
|
|
44
|
+
const payload = buildRateLimitPayload(info);
|
|
51
45
|
// Push only on change: the event repeats every turn, and a status that
|
|
52
46
|
// rewrites itself constantly is noise for whatever renders it.
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
lastRateLimitJson =
|
|
47
|
+
const identity = rateLimitIdentity(payload);
|
|
48
|
+
if (identity === lastRateLimitJson) return;
|
|
49
|
+
lastRateLimitJson = identity;
|
|
56
50
|
try {
|
|
57
|
-
setStatus.call(
|
|
51
|
+
setStatus.call(
|
|
52
|
+
uiContext!.ui,
|
|
53
|
+
RATE_LIMIT_STATUS_KEY,
|
|
54
|
+
JSON.stringify(payload),
|
|
55
|
+
);
|
|
58
56
|
} catch {
|
|
59
57
|
/* never break a turn over a status push */
|
|
60
58
|
}
|
package/package.json
CHANGED
package/src/event-bridge.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
import {
|
|
18
18
|
mapClaudeToolNameToPi,
|
|
19
19
|
translateClaudeArgsToPi,
|
|
20
|
-
|
|
20
|
+
isHandoffClaudeTool,
|
|
21
21
|
} from "./tool-mapping.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -267,9 +267,11 @@ export function createEventBridge(
|
|
|
267
267
|
} else if (blockType === "tool_use") {
|
|
268
268
|
const claudeName = event.content_block!.name!;
|
|
269
269
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
270
|
+
// Observer mode: the CLI executes its own tools (built-ins, WebSearch,
|
|
271
|
+
// user MCP, Task). Those surface as marker text via the envelope path,
|
|
272
|
+
// never as pi toolCall blocks. Only HANDOFF tools — custom pi tools
|
|
273
|
+
// behind the schema-only MCP server — become toolCalls for pi's loop.
|
|
274
|
+
if (!isHandoffClaudeTool(claudeName)) {
|
|
273
275
|
return;
|
|
274
276
|
}
|
|
275
277
|
|
|
@@ -501,9 +503,10 @@ export function createEventBridge(
|
|
|
501
503
|
if (envelope.parent_tool_use_id) return;
|
|
502
504
|
for (const block of envelope.message?.content ?? []) {
|
|
503
505
|
if (block.type !== "tool_use" || !block.name || !block.id) continue;
|
|
504
|
-
//
|
|
505
|
-
// tool calls — markers are
|
|
506
|
-
|
|
506
|
+
// Handoff tools already streamed through the SSE path as real pi
|
|
507
|
+
// tool calls — markers are for everything the CLI executes itself,
|
|
508
|
+
// which in observer mode includes the built-in file tools.
|
|
509
|
+
if (isHandoffClaudeTool(block.name)) continue;
|
|
507
510
|
if (markedToolIds.has(block.id)) continue;
|
|
508
511
|
markedToolIds.add(block.id);
|
|
509
512
|
|
package/src/process-manager.ts
CHANGED
|
@@ -95,6 +95,14 @@ export function spawnClaude(
|
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Host-supplied Claude Code settings (hooks, permissions). This is how a
|
|
99
|
+
// host injects PreToolUse guards — e.g. pidex's worktree-paths guard —
|
|
100
|
+
// without pi intercepting the CLI's native tool execution.
|
|
101
|
+
const settingsPath = process.env.PI_CLAUDE_CLI_SETTINGS;
|
|
102
|
+
if (settingsPath) {
|
|
103
|
+
args.push("--settings", settingsPath);
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
if (options?.effort) {
|
|
99
107
|
args.push("--effort", options.effort);
|
|
100
108
|
}
|
|
@@ -148,6 +156,29 @@ export function writeUserMessage(
|
|
|
148
156
|
proc.stdin!.write(JSON.stringify(message) + "\n");
|
|
149
157
|
}
|
|
150
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Ask the CLI to end the current turn cleanly — the same interrupt a human
|
|
161
|
+
* Esc produces. Unlike SIGKILL this lets the CLI persist the turn, so the
|
|
162
|
+
* session stays resumable without transcript corruption. The turn then ends
|
|
163
|
+
* with a `result` of subtype `error_during_execution`, which callers must
|
|
164
|
+
* treat as expected.
|
|
165
|
+
*/
|
|
166
|
+
export function sendInterrupt(proc: ChildProcess): void {
|
|
167
|
+
// `exitCode != null` (loose): undefined means "has not exited" on mocks and
|
|
168
|
+
// some stream wrappers, and must count as alive.
|
|
169
|
+
if (proc.killed || proc.exitCode != null || !proc.stdin) return;
|
|
170
|
+
const request = {
|
|
171
|
+
type: "control_request",
|
|
172
|
+
request_id: `int-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`,
|
|
173
|
+
request: { subtype: "interrupt" },
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
proc.stdin.write(JSON.stringify(request) + "\n");
|
|
177
|
+
} catch {
|
|
178
|
+
// stdin already closed — the force-kill fallback will handle it.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
/**
|
|
152
183
|
* Force-kill a subprocess immediately via SIGKILL.
|
|
153
184
|
* No-ops if the process is already dead (killed or exited).
|
package/src/provider.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provider orchestration
|
|
2
|
+
* Provider orchestration — observer mode (docs/SPEC-observer-mode.md).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
4
|
+
* The Claude CLI owns its loop, its tools, and its session. streamViaCli:
|
|
5
|
+
* 1. Resolves the pi session's CLI session (session-map) — resume, or
|
|
6
|
+
* create/import a fresh one from pi's full history
|
|
7
|
+
* 2. Spawns `claude -p`, writes the turn's prompt to stdin as NDJSON
|
|
8
|
+
* 3. Streams events to pi: prose/thinking verbatim; the CLI's own tool
|
|
9
|
+
* executions as `[Claude Code · Name]` markers; HANDOFF tools (custom pi
|
|
10
|
+
* tools) as real pi toolCall blocks
|
|
11
|
+
* 4. On a handoff tool at message_stop: sends a clean `interrupt` (never a
|
|
12
|
+
* kill — a SIGKILL mid-turn corrupts the CLI transcript and poisons every
|
|
13
|
+
* later resume) and ends the stream stopReason=toolUse so pi executes
|
|
14
|
+
* 5. Hardened lifecycle: inactivity timeout, exit handler, streamEnded
|
|
15
|
+
* guard, abort = interrupt + delayed SIGKILL backstop, process registry
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { createInterface } from "node:readline";
|
|
@@ -35,12 +36,19 @@ import {
|
|
|
35
36
|
forceKillProcess,
|
|
36
37
|
registerProcess,
|
|
37
38
|
cleanupSystemPromptFile,
|
|
39
|
+
sendInterrupt,
|
|
38
40
|
} from "./process-manager.js";
|
|
39
41
|
import { parseLine } from "./stream-parser.js";
|
|
40
42
|
import { createEventBridge } from "./event-bridge.js";
|
|
41
43
|
import { handleControlRequest } from "./control-handler.js";
|
|
42
44
|
import { mapThinkingEffort } from "./thinking-config.js";
|
|
43
|
-
import {
|
|
45
|
+
import { isHandoffClaudeTool } from "./tool-mapping.js";
|
|
46
|
+
import {
|
|
47
|
+
getCliSession,
|
|
48
|
+
setCliSession,
|
|
49
|
+
clearCliSession,
|
|
50
|
+
} from "./session-map.js";
|
|
51
|
+
import { randomUUID } from "node:crypto";
|
|
44
52
|
/** Inactivity timeout: kill subprocess if no stdout for 180 seconds (3 minutes). */
|
|
45
53
|
/**
|
|
46
54
|
* Inactivity timeout. CLI-side tool executions (web search, user MCP
|
|
@@ -60,17 +68,32 @@ type StreamViaCLiOptions = SimpleStreamOptions & {
|
|
|
60
68
|
onRateLimit?: (info: Record<string, unknown>) => void;
|
|
61
69
|
};
|
|
62
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The mapped CLI session is stale when pi's history moved on without it:
|
|
73
|
+
* an assistant turn from another provider (model switch) after — or with no —
|
|
74
|
+
* pi-claude-cli turn means the CLI never saw that exchange. Resuming would
|
|
75
|
+
* answer from a conversation missing turns, so reimport instead.
|
|
76
|
+
*/
|
|
77
|
+
function cliSessionIsStale(messages: any[]): boolean {
|
|
78
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
79
|
+
const m = messages[i];
|
|
80
|
+
if (m?.role !== "assistant") continue;
|
|
81
|
+
return !(m?.provider === "pi-claude-cli" || m?.api === "pi-claude-cli");
|
|
82
|
+
}
|
|
83
|
+
return false; // no assistant turns at all: nothing to be behind
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
/**
|
|
64
87
|
* Stream a response from Claude CLI as an AssistantMessageEventStream.
|
|
65
88
|
*
|
|
66
|
-
* Orchestrates the full subprocess lifecycle:
|
|
67
|
-
* bridge events, handle result,
|
|
68
|
-
*
|
|
69
|
-
*
|
|
89
|
+
* Orchestrates the full subprocess lifecycle: resolve/resume the CLI session,
|
|
90
|
+
* spawn, write prompt, parse NDJSON, bridge events, handle result, clean up.
|
|
91
|
+
* The CLI executes its own tools; only handoff (custom pi) tools end the turn
|
|
92
|
+
* early, via a clean interrupt at message_stop.
|
|
70
93
|
*
|
|
71
|
-
* Hardened with: inactivity timeout
|
|
72
|
-
* surfacing, streamEnded guard against double errors, abort via
|
|
73
|
-
* process registry integration for teardown cleanup.
|
|
94
|
+
* Hardened with: inactivity timeout, subprocess exit handler with stderr
|
|
95
|
+
* surfacing, streamEnded guard against double errors, abort via interrupt with
|
|
96
|
+
* a SIGKILL backstop, and process registry integration for teardown cleanup.
|
|
74
97
|
*
|
|
75
98
|
* @param model - The model to use (from pi's model catalog)
|
|
76
99
|
* @param context - The conversation context with messages and system prompt
|
|
@@ -86,11 +109,9 @@ export function streamViaCli(
|
|
|
86
109
|
|
|
87
110
|
/**
|
|
88
111
|
* One subprocess attempt. Returns "resume-miss" (without touching the
|
|
89
|
-
* stream) when
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* The driver below retries once with a full-history replay, which also
|
|
93
|
-
* re-registers the CLI cache under the current session id.
|
|
112
|
+
* stream) when the sidecar pointed --resume at a CLI session that no longer
|
|
113
|
+
* exists on disk. The driver below retries once with a fresh session
|
|
114
|
+
* imported from pi's full history, re-recording the mapping.
|
|
94
115
|
*/
|
|
95
116
|
async function runOnce(
|
|
96
117
|
forceFullReplay: boolean,
|
|
@@ -98,30 +119,37 @@ export function streamViaCli(
|
|
|
98
119
|
let proc: ReturnType<typeof spawnClaude> | undefined;
|
|
99
120
|
let abortHandler: (() => void) | undefined;
|
|
100
121
|
let resumeMiss = false;
|
|
122
|
+
// Track HANDOFF tool_use blocks (custom pi tools) for the interrupt
|
|
123
|
+
// decision at message_stop. Built-ins run natively and never interrupt.
|
|
124
|
+
let sawHandoffTool = false;
|
|
125
|
+
// Set once we have asked the CLI to end the turn (handoff or abort):
|
|
126
|
+
// stream content is frozen and only the result envelope is awaited.
|
|
127
|
+
let selfInterrupted = false;
|
|
128
|
+
// Set on pi-initiated abort so the turn ends quietly, not as an error.
|
|
129
|
+
let aborted = false;
|
|
101
130
|
|
|
102
131
|
try {
|
|
103
132
|
const cwd = options?.cwd ?? process.cwd();
|
|
104
133
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// assistant message.
|
|
113
|
-
const hasPriorCliTurn = (context.messages as any[]).some(
|
|
114
|
-
(m) =>
|
|
115
|
-
m?.role === "assistant" &&
|
|
116
|
-
(m?.provider === "pi-claude-cli" || m?.api === "pi-claude-cli"),
|
|
117
|
-
);
|
|
134
|
+
// One CLI session per pi session, resumed across turns and restarts.
|
|
135
|
+
// Resume only when a mapping exists AND the CLI session is not behind
|
|
136
|
+
// pi's history (a foreign-provider turn after our last one means the
|
|
137
|
+
// CLI never saw that exchange). Anything else — first turn, fork,
|
|
138
|
+
// model switch, lost sidecar, resume miss — is one reimport.
|
|
139
|
+
const piSessionId = options?.sessionId;
|
|
140
|
+
const mappedCliId = piSessionId ? getCliSession(piSessionId) : undefined;
|
|
118
141
|
const resumeSessionId =
|
|
119
|
-
!forceFullReplay &&
|
|
120
|
-
|
|
142
|
+
!forceFullReplay &&
|
|
143
|
+
mappedCliId &&
|
|
144
|
+
!cliSessionIsStale(context.messages as any[])
|
|
145
|
+
? mappedCliId
|
|
121
146
|
: undefined;
|
|
147
|
+
// Fresh sessions get a provider-minted id, never pi's: the CLI refuses
|
|
148
|
+
// a --session-id it has already seen, and forks reuse pi ids.
|
|
149
|
+
const newCliId = resumeSessionId ? undefined : randomUUID();
|
|
122
150
|
|
|
123
|
-
//
|
|
124
|
-
//
|
|
151
|
+
// Resume sends only the delta since the last assistant turn (new user
|
|
152
|
+
// text, handoff tool results). Create/import sends the full history.
|
|
125
153
|
const prompt = resumeSessionId
|
|
126
154
|
? buildResumePrompt(context)
|
|
127
155
|
: buildPrompt(context);
|
|
@@ -149,9 +177,12 @@ export function streamViaCli(
|
|
|
149
177
|
effort,
|
|
150
178
|
mcpConfigPath: options?.mcpConfigPath,
|
|
151
179
|
resumeSessionId,
|
|
152
|
-
newSessionId:
|
|
180
|
+
newSessionId: newCliId,
|
|
153
181
|
systemPromptMode,
|
|
154
182
|
});
|
|
183
|
+
// Record the mapping as soon as the session exists on disk. On a turn
|
|
184
|
+
// that later errors, the mapping is cleared so the next turn reimports.
|
|
185
|
+
if (piSessionId && newCliId) setCliSession(piSessionId, newCliId);
|
|
155
186
|
const getStderr = captureStderr(proc);
|
|
156
187
|
|
|
157
188
|
// Register in global process registry for teardown cleanup
|
|
@@ -208,12 +239,16 @@ export function streamViaCli(
|
|
|
208
239
|
}, INACTIVITY_TIMEOUT_MS);
|
|
209
240
|
}
|
|
210
241
|
|
|
211
|
-
//
|
|
242
|
+
// Abort = the CLI's own interrupt (keeps the session resumable), with a
|
|
243
|
+
// SIGKILL backstop in case the CLI is wedged and never emits a result.
|
|
212
244
|
if (options?.signal) {
|
|
213
245
|
abortHandler = () => {
|
|
214
|
-
if (proc)
|
|
215
|
-
|
|
216
|
-
|
|
246
|
+
if (!proc) return;
|
|
247
|
+
aborted = true;
|
|
248
|
+
selfInterrupted = true;
|
|
249
|
+
sendInterrupt(proc);
|
|
250
|
+
const backstop = setTimeout(() => forceKillProcess(proc!), 2000);
|
|
251
|
+
proc.once("close", () => clearTimeout(backstop));
|
|
217
252
|
};
|
|
218
253
|
|
|
219
254
|
if (options.signal.aborted) {
|
|
@@ -223,8 +258,6 @@ export function streamViaCli(
|
|
|
223
258
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
224
259
|
}
|
|
225
260
|
|
|
226
|
-
// Track tool_use blocks for break-early decision at message_stop
|
|
227
|
-
let sawBuiltInOrCustomTool = false;
|
|
228
261
|
// Guard against buffered readline lines firing after rl.close()
|
|
229
262
|
let broken = false;
|
|
230
263
|
|
|
@@ -237,7 +270,7 @@ export function streamViaCli(
|
|
|
237
270
|
|
|
238
271
|
// Handle process error -- use endStreamWithError for guard
|
|
239
272
|
proc.on("error", (err: Error) => {
|
|
240
|
-
if (broken) return; //
|
|
273
|
+
if (broken) return; // resume-miss retry owns the stream
|
|
241
274
|
const stderr = getStderr();
|
|
242
275
|
endStreamWithError(stderr || err.message);
|
|
243
276
|
});
|
|
@@ -245,7 +278,7 @@ export function streamViaCli(
|
|
|
245
278
|
// Handle subprocess close -- surface crashes with stderr and exit code
|
|
246
279
|
proc.on("close", (code: number | null, _signal: string | null) => {
|
|
247
280
|
clearTimeout(inactivityTimer);
|
|
248
|
-
if (broken) return; //
|
|
281
|
+
if (broken) return; // resume-miss retry owns the stream
|
|
249
282
|
if (code !== 0 && code !== null) {
|
|
250
283
|
const stderr = getStderr();
|
|
251
284
|
const message = stderr
|
|
@@ -262,7 +295,7 @@ export function streamViaCli(
|
|
|
262
295
|
// NOTE: Using 'line' event instead of `for await` because the async
|
|
263
296
|
// iterator batches lines, breaking real-time streaming to pi.
|
|
264
297
|
rl.on("line", (line: string) => {
|
|
265
|
-
if (broken) return; // Guard: ignore buffered lines after
|
|
298
|
+
if (broken) return; // Guard: ignore buffered lines after a resume miss
|
|
266
299
|
|
|
267
300
|
// Reset inactivity timer on each line of output
|
|
268
301
|
resetInactivityTimer();
|
|
@@ -274,37 +307,38 @@ export function streamViaCli(
|
|
|
274
307
|
// Only forward top-level events to pi's event bridge.
|
|
275
308
|
// Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
|
|
276
309
|
const isTopLevel = !(msg as any).parent_tool_use_id;
|
|
277
|
-
if (isTopLevel) {
|
|
310
|
+
if (isTopLevel && !selfInterrupted) {
|
|
278
311
|
bridge.handleEvent(msg.event);
|
|
279
312
|
}
|
|
280
313
|
|
|
281
|
-
// Track tool_use blocks
|
|
314
|
+
// Track handoff tool_use blocks (top-level only). Built-ins and
|
|
315
|
+
// CLI-internal tools execute natively and must not interrupt.
|
|
282
316
|
if (
|
|
283
317
|
isTopLevel &&
|
|
284
318
|
msg.event.type === "content_block_start" &&
|
|
285
319
|
msg.event.content_block?.type === "tool_use"
|
|
286
320
|
) {
|
|
287
321
|
const toolName = msg.event.content_block.name;
|
|
288
|
-
if (toolName &&
|
|
289
|
-
|
|
290
|
-
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
|
|
291
|
-
sawBuiltInOrCustomTool = true;
|
|
322
|
+
if (toolName && isHandoffClaudeTool(toolName)) {
|
|
323
|
+
sawHandoffTool = true;
|
|
292
324
|
}
|
|
293
325
|
}
|
|
294
326
|
|
|
295
|
-
//
|
|
296
|
-
//
|
|
327
|
+
// Handoff at message_stop: ask the CLI to end the turn CLEANLY so
|
|
328
|
+
// the session file stays truthful and resumable, then wait for the
|
|
329
|
+
// result envelope. Never SIGKILL here — a kill truncates the
|
|
330
|
+
// transcript before the assistant turn is written, and every later
|
|
331
|
+
// --resume then splices in synthetic "No response requested."
|
|
332
|
+
// filler that the model eventually imitates.
|
|
297
333
|
if (
|
|
298
334
|
isTopLevel &&
|
|
299
335
|
msg.event.type === "message_stop" &&
|
|
300
|
-
|
|
336
|
+
sawHandoffTool &&
|
|
337
|
+
!selfInterrupted
|
|
301
338
|
) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
forceKillProcess(proc!);
|
|
306
|
-
rl.close();
|
|
307
|
-
return; // Don't process further -- done event already pushed by event bridge
|
|
339
|
+
selfInterrupted = true;
|
|
340
|
+
sendInterrupt(proc!);
|
|
341
|
+
return;
|
|
308
342
|
}
|
|
309
343
|
} else if (msg.type === "rate_limit_event") {
|
|
310
344
|
// Account-level state, not turn content: hand it to the host so a
|
|
@@ -319,25 +353,22 @@ export function streamViaCli(
|
|
|
319
353
|
}
|
|
320
354
|
}
|
|
321
355
|
} else if (msg.type === "assistant") {
|
|
322
|
-
// Complete-block envelopes: marker text for CLI
|
|
323
|
-
//
|
|
324
|
-
|
|
356
|
+
// Complete-block envelopes: marker text for the CLI's own tool
|
|
357
|
+
// executions (built-ins, WebSearch, user MCP, …), which in observer
|
|
358
|
+
// mode is every tool except handoffs.
|
|
359
|
+
if (!selfInterrupted) bridge.handleAssistantEnvelope(msg as any);
|
|
325
360
|
} else if (msg.type === "user") {
|
|
326
361
|
// Tool results the CLI feeds back between cycles — internal.
|
|
327
362
|
} else if (msg.type === "control_request") {
|
|
328
363
|
handleControlRequest(msg, proc!.stdin!);
|
|
329
364
|
} else if (msg.type === "result") {
|
|
330
|
-
// Surface every non-success result as an error so silent failures
|
|
331
|
-
// (e.g. subtype "error_during_execution" from --resume against an
|
|
332
|
-
// unknown session id, or any future error variant) don't get
|
|
333
|
-
// swallowed into an empty assistant message.
|
|
334
365
|
const r: any = msg as any;
|
|
335
366
|
const isError =
|
|
336
367
|
r.subtype !== "success" ||
|
|
337
368
|
r.is_error === true ||
|
|
338
369
|
typeof r.error === "string" ||
|
|
339
370
|
(Array.isArray(r.errors) && r.errors.length > 0);
|
|
340
|
-
if (isError) {
|
|
371
|
+
if (isError && !selfInterrupted && !aborted) {
|
|
341
372
|
const errMsg =
|
|
342
373
|
r.error ??
|
|
343
374
|
(Array.isArray(r.errors) && r.errors.length > 0
|
|
@@ -347,20 +378,25 @@ export function streamViaCli(
|
|
|
347
378
|
resumeSessionId &&
|
|
348
379
|
/No conversation found with session ID/i.test(errMsg)
|
|
349
380
|
) {
|
|
350
|
-
// Recoverable: the
|
|
351
|
-
//
|
|
352
|
-
|
|
381
|
+
// Recoverable: the sidecar pointed at a CLI session that no
|
|
382
|
+
// longer exists. Clear it; the driver reimports once.
|
|
383
|
+
if (piSessionId) clearCliSession(piSessionId);
|
|
353
384
|
resumeMiss = true;
|
|
354
385
|
broken = true;
|
|
355
386
|
} else {
|
|
387
|
+
// A failed turn may leave the CLI session ending on a user
|
|
388
|
+
// entry; resuming that would splice filler. Reimport next turn.
|
|
389
|
+
if (piSessionId) clearCliSession(piSessionId);
|
|
356
390
|
endStreamWithError(errMsg);
|
|
357
391
|
}
|
|
358
392
|
}
|
|
359
|
-
if (!isError) {
|
|
360
|
-
// Authoritative episode usage
|
|
393
|
+
if (!isError || selfInterrupted || aborted) {
|
|
394
|
+
// Authoritative episode usage. After a self-interrupt the result
|
|
395
|
+
// is `error_during_execution` BY DESIGN — the turn content (the
|
|
396
|
+
// handoff toolCall) is already accumulated; usage still applies.
|
|
361
397
|
bridge.applyResult(r);
|
|
362
398
|
}
|
|
363
|
-
// For
|
|
399
|
+
// For success, handoff and error alike: clean up the subprocess
|
|
364
400
|
clearTimeout(inactivityTimer);
|
|
365
401
|
cleanupProcess(proc!);
|
|
366
402
|
rl.close();
|
|
@@ -418,7 +454,7 @@ export function streamViaCli(
|
|
|
418
454
|
const outcome = await runOnce(false);
|
|
419
455
|
if (outcome === "resume-miss") {
|
|
420
456
|
console.error(
|
|
421
|
-
"[pi-claude-cli] CLI session missing for --resume —
|
|
457
|
+
"[pi-claude-cli] CLI session missing for --resume — importing pi history into a fresh CLI session",
|
|
422
458
|
);
|
|
423
459
|
await runOnce(true);
|
|
424
460
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account rate-limit state, shaped for the host's status channel.
|
|
3
|
+
*
|
|
4
|
+
* The CLI emits a `rate_limit_event` per turn describing ONE window: the first
|
|
5
|
+
* one whose warning threshold has been crossed, walking
|
|
6
|
+
* `5h -> 7d -> 7d_oi -> overage`. So the reported window is the binding
|
|
7
|
+
* constraint, not an arbitrary pick — and there is no way to see all four at
|
|
8
|
+
* once from this stream. A front-end that wants "which limit will stop me, how
|
|
9
|
+
* close am I, and when does it reset" has everything it needs; one that wants
|
|
10
|
+
* a full dashboard does not, and should not pretend otherwise.
|
|
11
|
+
*
|
|
12
|
+
* Kept pure and separate from `index.ts` so the payload contract is testable
|
|
13
|
+
* without a pi runtime.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What the host receives under the `claude-rate-limit` status key. */
|
|
17
|
+
export interface RateLimitPayload {
|
|
18
|
+
status: unknown;
|
|
19
|
+
resetsAt: unknown;
|
|
20
|
+
rateLimitType: unknown;
|
|
21
|
+
overageStatus: unknown;
|
|
22
|
+
isUsingOverage: boolean;
|
|
23
|
+
/** Fraction of the window consumed: 1.01 means 101%, i.e. over. */
|
|
24
|
+
utilization: number | null;
|
|
25
|
+
/** Which warning step tripped, when one has. */
|
|
26
|
+
surpassedThreshold: number | null;
|
|
27
|
+
observedAt: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function num(value: unknown): number | null {
|
|
31
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildRateLimitPayload(
|
|
35
|
+
info: Record<string, unknown>,
|
|
36
|
+
nowSeconds: number = Math.floor(Date.now() / 1000),
|
|
37
|
+
): RateLimitPayload {
|
|
38
|
+
return {
|
|
39
|
+
status: info.status,
|
|
40
|
+
resetsAt: info.resetsAt,
|
|
41
|
+
rateLimitType: info.rateLimitType,
|
|
42
|
+
overageStatus: info.overageStatus,
|
|
43
|
+
isUsingOverage: info.isUsingOverage === true,
|
|
44
|
+
// The CLI has always sent these two; dropping them left front-ends able to
|
|
45
|
+
// say WHICH limit was in play and when it resets, but never how close it
|
|
46
|
+
// was — so a user could not watch themselves approach a wall, only hit it.
|
|
47
|
+
utilization: num(info.utilization),
|
|
48
|
+
surpassedThreshold: num(info.surpassedThreshold),
|
|
49
|
+
observedAt: nowSeconds,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Identity of a payload ignoring `observedAt`, for change detection.
|
|
55
|
+
*
|
|
56
|
+
* The event repeats every turn with a fresh timestamp; pushing that verbatim
|
|
57
|
+
* would rewrite the host's status constantly and make anything rendering it
|
|
58
|
+
* flicker. Comparing everything EXCEPT the timestamp is what makes the push
|
|
59
|
+
* "on change" rather than "on turn".
|
|
60
|
+
*/
|
|
61
|
+
export function rateLimitIdentity(payload: RateLimitPayload): string {
|
|
62
|
+
const { observedAt: _observedAt, ...rest } = payload;
|
|
63
|
+
return JSON.stringify(rest);
|
|
64
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi session → Claude CLI session mapping.
|
|
3
|
+
*
|
|
4
|
+
* Observer mode keeps ONE CLI session per pi session and resumes it across
|
|
5
|
+
* turns and pi restarts. pi stays the system of record: this sidecar is
|
|
6
|
+
* derived state, and losing it only costs one full-history reimport.
|
|
7
|
+
*
|
|
8
|
+
* A flat JSON file rather than per-session files: entries are tiny, writes
|
|
9
|
+
* are rare (one per created CLI session), and a single pi process owns a
|
|
10
|
+
* given pi session at a time.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
function stateDir(): string {
|
|
18
|
+
return (
|
|
19
|
+
process.env.PI_CLAUDE_CLI_STATE_DIR ||
|
|
20
|
+
join(homedir(), ".pi", "agent", "pi-claude-cli")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mapPath(): string {
|
|
25
|
+
return join(stateDir(), "session-map.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readMap(): Record<string, string> {
|
|
29
|
+
try {
|
|
30
|
+
const parsed: unknown = JSON.parse(readFileSync(mapPath(), "utf-8"));
|
|
31
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
32
|
+
return parsed as Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Missing or corrupt — both mean "no mappings", which is always safe.
|
|
36
|
+
}
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeMap(map: Record<string, string>): void {
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
43
|
+
writeFileSync(mapPath(), JSON.stringify(map, null, 2), "utf-8");
|
|
44
|
+
} catch {
|
|
45
|
+
// Best effort: an unwritable sidecar degrades to reimport-per-restart.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** CLI session id for a pi session, if one was created and recorded. */
|
|
50
|
+
export function getCliSession(piSessionId: string): string | undefined {
|
|
51
|
+
return readMap()[piSessionId];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setCliSession(piSessionId: string, cliSessionId: string): void {
|
|
55
|
+
const map = readMap();
|
|
56
|
+
map[piSessionId] = cliSessionId;
|
|
57
|
+
writeMap(map);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Forget a mapping so the next turn reimports from pi history. */
|
|
61
|
+
export function clearCliSession(piSessionId: string): void {
|
|
62
|
+
const map = readMap();
|
|
63
|
+
if (piSessionId in map) {
|
|
64
|
+
delete map[piSessionId];
|
|
65
|
+
writeMap(map);
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/tool-mapping.ts
CHANGED
|
@@ -49,14 +49,23 @@ export function isCustomToolName(piName: string): boolean {
|
|
|
49
49
|
/**
|
|
50
50
|
* Check if a Claude tool name maps to a pi-known tool.
|
|
51
51
|
* Returns true for built-in tools (Read, Write, etc.) and custom MCP tools (mcp__custom-tools__*).
|
|
52
|
-
* Returns false for internal Claude Code tools (ToolSearch, Task, Agent, etc.)
|
|
53
|
-
* Used by event bridge to filter out internal tool calls.
|
|
52
|
+
* Returns false for internal Claude Code tools (ToolSearch, Task, Agent, etc.).
|
|
54
53
|
*/
|
|
55
54
|
export function isPiKnownClaudeTool(claudeName: string): boolean {
|
|
56
55
|
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) return true;
|
|
57
56
|
return claudeName.toLowerCase() in CLAUDE_TO_PI_NAME;
|
|
58
57
|
}
|
|
59
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Handoff tools are the ONLY tools pi executes in observer mode: custom pi
|
|
61
|
+
* tools exposed through the schema-only MCP server. Built-ins run natively in
|
|
62
|
+
* the CLI. This is the gate for interrupt-at-message_stop and for emitting pi
|
|
63
|
+
* toolCall blocks — see docs/SPEC-observer-mode.md.
|
|
64
|
+
*/
|
|
65
|
+
export function isHandoffClaudeTool(claudeName: string): boolean {
|
|
66
|
+
return claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX);
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
// Derived lookup maps
|
|
61
70
|
|
|
62
71
|
/** Lowercase Claude name -> pi name */
|