@saccolabs/pi-claude-cli 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -17,15 +17,22 @@ export const TOOL_EXECUTION_DENIED_MESSAGE =
17
17
  /** Prefix for MCP (Model Context Protocol) tool names. */
18
18
  export const MCP_PREFIX = "mcp__";
19
19
 
20
+ /**
21
+ * Claude Code 2.x control_response wire shape: `request_id` lives INSIDE
22
+ * `response`, and allow decisions carry `updatedInput` (the tool input,
23
+ * passed through unmodified). The 1.x shape (`request_id` at the top level)
24
+ * is silently ignored by 2.1.x — the CLI keeps waiting for an answer and
25
+ * the episode stalls until the inactivity timer kills it, which surfaced
26
+ * as truncated multi-cycle turns (#3). Verified against claude 2.1.237.
27
+ */
20
28
  interface ControlResponse {
21
29
  type: "control_response";
22
- request_id: string;
23
30
  response: {
24
31
  subtype: "success";
25
- response: {
26
- behavior: "allow" | "deny";
27
- message?: string;
28
- };
32
+ request_id: string;
33
+ response:
34
+ | { behavior: "allow"; updatedInput: Record<string, unknown> }
35
+ | { behavior: "deny"; message: string };
29
36
  };
30
37
  }
31
38
 
@@ -54,12 +61,12 @@ export function handleControlRequest(
54
61
 
55
62
  const response: ControlResponse = {
56
63
  type: "control_response",
57
- request_id: msg.request_id,
58
64
  response: {
59
65
  subtype: "success",
66
+ request_id: msg.request_id,
60
67
  response: isCustomTool
61
68
  ? { behavior: "deny", message: TOOL_EXECUTION_DENIED_MESSAGE }
62
- : { behavior: "allow" },
69
+ : { behavior: "allow", updatedInput: msg.request?.input ?? {} },
63
70
  },
64
71
  };
65
72
 
@@ -1,4 +1,10 @@
1
- import type { ClaudeApiEvent, TrackedContentBlock } from "./types";
1
+ import type {
2
+ ClaudeApiEvent,
3
+ ClaudeAssistantEnvelope,
4
+ ClaudeResultMessage,
5
+ ClaudeUsage,
6
+ TrackedContentBlock,
7
+ } from "./types";
2
8
  import { calculateCost } from "@earendil-works/pi-ai";
3
9
  import type {
4
10
  AssistantMessage,
@@ -21,6 +27,7 @@ import {
21
27
  interface TrackedToolBlock {
22
28
  type: "tool_use";
23
29
  index: number;
30
+ cycle: number;
24
31
  id: string;
25
32
  name: string; // Already mapped to pi name
26
33
  claudeName: string; // Original Claude name for arg translation
@@ -39,6 +46,19 @@ type TrackedBlock = TrackedContentBlock | TrackedToolBlock;
39
46
  */
40
47
  export interface EventBridge {
41
48
  handleEvent(event: ClaudeApiEvent): void;
49
+ /**
50
+ * Complete-message envelopes (one per finished content block). Used for
51
+ * CLI-side tool visibility: tools pi cannot execute (WebSearch, user MCP
52
+ * servers, ToolSearch, …) run inside the CLI between cycles and would
53
+ * otherwise be invisible — each becomes a one-line marker text block.
54
+ */
55
+ handleAssistantEnvelope(envelope: ClaudeAssistantEnvelope): void;
56
+ /**
57
+ * The final `result` envelope: authoritative cumulative usage for the
58
+ * whole episode, plus a safety net that appends the final answer text if
59
+ * any stream pathology kept it out of the bridged content.
60
+ */
61
+ applyResult(result: ClaudeResultMessage): void;
42
62
  getOutput(): AssistantMessage;
43
63
  }
44
64
 
@@ -97,6 +117,71 @@ export function createEventBridge(
97
117
 
98
118
  let started = false;
99
119
 
120
+ // One subprocess run is a full agentic EPISODE: N API calls ("cycles")
121
+ // with CLI-side tool executions between them. SSE content_block indexes
122
+ // reset every cycle, so blocks are matched per-cycle; usage is summed
123
+ // across cycles (each message_delta carries that cycle's final numbers).
124
+ let cycle = -1;
125
+ const cumulativeUsage: Required<ClaudeUsage> = {
126
+ input_tokens: 0,
127
+ output_tokens: 0,
128
+ cache_read_input_tokens: 0,
129
+ cache_creation_input_tokens: 0,
130
+ };
131
+ let cycleUsage: ClaudeUsage = {};
132
+ /** Tool ids already surfaced as markers (envelope arrives once per block). */
133
+ const markedToolIds = new Set<string>();
134
+
135
+ function recomputeUsage(): void {
136
+ output.usage.input =
137
+ cumulativeUsage.input_tokens + (cycleUsage.input_tokens ?? 0);
138
+ output.usage.output =
139
+ cumulativeUsage.output_tokens + (cycleUsage.output_tokens ?? 0);
140
+ output.usage.cacheRead =
141
+ cumulativeUsage.cache_read_input_tokens +
142
+ (cycleUsage.cache_read_input_tokens ?? 0);
143
+ output.usage.cacheWrite =
144
+ cumulativeUsage.cache_creation_input_tokens +
145
+ (cycleUsage.cache_creation_input_tokens ?? 0);
146
+ output.usage.totalTokens =
147
+ output.usage.input +
148
+ output.usage.output +
149
+ output.usage.cacheRead +
150
+ output.usage.cacheWrite;
151
+ calculateCost(model, output.usage);
152
+ }
153
+
154
+ /**
155
+ * Append a complete text block through proper pi stream events.
156
+ *
157
+ * `blocks` and `output.content` are parallel arrays (SSE handlers index
158
+ * into both with the same idx), so the marker must occupy a slot in both;
159
+ * cycle -1 / index -1 can never match a real SSE event.
160
+ */
161
+ function appendTextBlock(text: string): void {
162
+ if (!started) {
163
+ stream.push({ type: "start", partial: output });
164
+ started = true;
165
+ }
166
+ blocks.push({ type: "text", text, index: -1, cycle: -1 });
167
+ output.content.push({ type: "text" as const, text: "" });
168
+ const contentIndex = output.content.length - 1;
169
+ stream.push({ type: "text_start", contentIndex, partial: output });
170
+ (output.content[contentIndex] as TextContent).text = text;
171
+ stream.push({
172
+ type: "text_delta",
173
+ contentIndex,
174
+ delta: text,
175
+ partial: output,
176
+ });
177
+ stream.push({
178
+ type: "text_end",
179
+ contentIndex,
180
+ content: text,
181
+ partial: output,
182
+ });
183
+ }
184
+
100
185
  function handleEvent(event: ClaudeApiEvent): void {
101
186
  // Emit start event on first message — tells pi to begin incremental rendering
102
187
  if (!started) {
@@ -106,6 +191,15 @@ export function createEventBridge(
106
191
 
107
192
  switch (event.type) {
108
193
  case "message_start":
194
+ // New cycle: bank the finished cycle's usage before resetting.
195
+ cumulativeUsage.input_tokens += cycleUsage.input_tokens ?? 0;
196
+ cumulativeUsage.output_tokens += cycleUsage.output_tokens ?? 0;
197
+ cumulativeUsage.cache_read_input_tokens +=
198
+ cycleUsage.cache_read_input_tokens ?? 0;
199
+ cumulativeUsage.cache_creation_input_tokens +=
200
+ cycleUsage.cache_creation_input_tokens ?? 0;
201
+ cycleUsage = {};
202
+ cycle++;
109
203
  handleMessageStart(event);
110
204
  break;
111
205
  case "content_block_start":
@@ -130,16 +224,8 @@ export function createEventBridge(
130
224
  function handleMessageStart(event: ClaudeApiEvent): void {
131
225
  const usage = event.message?.usage;
132
226
  if (usage) {
133
- output.usage.input = usage.input_tokens ?? 0;
134
- output.usage.output = usage.output_tokens ?? 0;
135
- output.usage.cacheRead = usage.cache_read_input_tokens ?? 0;
136
- output.usage.cacheWrite = usage.cache_creation_input_tokens ?? 0;
137
- output.usage.totalTokens =
138
- output.usage.input +
139
- output.usage.output +
140
- output.usage.cacheRead +
141
- output.usage.cacheWrite;
142
- calculateCost(model, output.usage);
227
+ cycleUsage = { ...usage };
228
+ recomputeUsage();
143
229
  }
144
230
  }
145
231
 
@@ -151,6 +237,7 @@ export function createEventBridge(
151
237
  type: "text",
152
238
  text: "",
153
239
  index: event.index ?? 0,
240
+ cycle,
154
241
  };
155
242
  blocks.push(block);
156
243
  output.content.push({ type: "text" as const, text: "" });
@@ -165,6 +252,7 @@ export function createEventBridge(
165
252
  type: "thinking",
166
253
  text: "",
167
254
  index: event.index ?? 0,
255
+ cycle,
168
256
  };
169
257
  blocks.push(block);
170
258
  output.content.push({
@@ -193,6 +281,7 @@ export function createEventBridge(
193
281
  const block: TrackedToolBlock = {
194
282
  type: "tool_use",
195
283
  index: event.index ?? 0,
284
+ cycle,
196
285
  id,
197
286
  name: piName,
198
287
  claudeName,
@@ -220,7 +309,9 @@ export function createEventBridge(
220
309
  const deltaType = event.delta?.type;
221
310
 
222
311
  if (deltaType === "text_delta" && event.delta!.text != null) {
223
- const idx = blocks.findIndex((b) => b.index === event.index);
312
+ const idx = blocks.findIndex(
313
+ (b) => b.cycle === cycle && b.index === event.index,
314
+ );
224
315
  if (idx === -1) return;
225
316
 
226
317
  const block = blocks[idx];
@@ -240,7 +331,9 @@ export function createEventBridge(
240
331
  deltaType === "thinking_delta" &&
241
332
  event.delta!.thinking != null
242
333
  ) {
243
- const idx = blocks.findIndex((b) => b.index === event.index);
334
+ const idx = blocks.findIndex(
335
+ (b) => b.cycle === cycle && b.index === event.index,
336
+ );
244
337
  if (idx === -1) return;
245
338
 
246
339
  const block = blocks[idx];
@@ -260,7 +353,9 @@ export function createEventBridge(
260
353
  deltaType === "input_json_delta" &&
261
354
  event.delta!.partial_json != null
262
355
  ) {
263
- const idx = blocks.findIndex((b) => b.index === event.index);
356
+ const idx = blocks.findIndex(
357
+ (b) => b.cycle === cycle && b.index === event.index,
358
+ );
264
359
  if (idx === -1) return;
265
360
 
266
361
  const block = blocks[idx];
@@ -287,7 +382,9 @@ export function createEventBridge(
287
382
  event.delta!.signature != null
288
383
  ) {
289
384
  // Accumulate signature on the thinking block
290
- const idx = blocks.findIndex((b) => b.index === event.index);
385
+ const idx = blocks.findIndex(
386
+ (b) => b.cycle === cycle && b.index === event.index,
387
+ );
291
388
  if (idx === -1) return;
292
389
 
293
390
  const block = blocks[idx];
@@ -300,7 +397,9 @@ export function createEventBridge(
300
397
  }
301
398
 
302
399
  function handleContentBlockStop(event: ClaudeApiEvent): void {
303
- const idx = blocks.findIndex((b) => b.index === event.index);
400
+ const idx = blocks.findIndex(
401
+ (b) => b.cycle === cycle && b.index === event.index,
402
+ );
304
403
  if (idx === -1) return;
305
404
 
306
405
  const block = blocks[idx];
@@ -356,20 +455,24 @@ export function createEventBridge(
356
455
 
357
456
  function handleMessageDelta(event: ClaudeApiEvent): void {
358
457
  if (event.delta?.stop_reason) {
458
+ // The LAST cycle's stop reason is the episode's stop reason.
359
459
  output.stopReason = mapStopReason(event.delta.stop_reason);
360
460
  }
361
461
 
362
462
  const usage = event.usage;
363
463
  if (usage) {
364
- if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
464
+ // message_delta carries the cycle's final numbers — overwrite within
465
+ // the cycle, never across cycles (those are banked at message_start).
466
+ if (usage.input_tokens != null)
467
+ cycleUsage.input_tokens = usage.input_tokens;
365
468
  if (usage.output_tokens != null)
366
- output.usage.output = usage.output_tokens;
367
- output.usage.totalTokens =
368
- output.usage.input +
369
- output.usage.output +
370
- output.usage.cacheRead +
371
- output.usage.cacheWrite;
372
- calculateCost(model, output.usage);
469
+ cycleUsage.output_tokens = usage.output_tokens;
470
+ if (usage.cache_read_input_tokens != null)
471
+ cycleUsage.cache_read_input_tokens = usage.cache_read_input_tokens;
472
+ if (usage.cache_creation_input_tokens != null)
473
+ cycleUsage.cache_creation_input_tokens =
474
+ usage.cache_creation_input_tokens;
475
+ recomputeUsage();
373
476
  }
374
477
  }
375
478
 
@@ -378,8 +481,69 @@ export function createEventBridge(
378
481
  // Pushing done here (synchronously) prevents pi from executing tools.
379
482
  }
380
483
 
484
+ function handleAssistantEnvelope(envelope: ClaudeAssistantEnvelope): void {
485
+ // Sub-agent envelopes are the CLI's internal business.
486
+ if (envelope.parent_tool_use_id) return;
487
+ for (const block of envelope.message?.content ?? []) {
488
+ if (block.type !== "tool_use" || !block.name || !block.id) continue;
489
+ // Pi-known tools already streamed through the SSE path as real pi
490
+ // tool calls — markers are only for tools the CLI executes itself.
491
+ if (isPiKnownClaudeTool(block.name)) continue;
492
+ if (markedToolIds.has(block.id)) continue;
493
+ markedToolIds.add(block.id);
494
+
495
+ let argsPreview = "";
496
+ try {
497
+ const json = JSON.stringify(block.input ?? {});
498
+ argsPreview =
499
+ json === "{}"
500
+ ? ""
501
+ : ` ${json.slice(0, 120)}${json.length > 120 ? "…" : ""}`;
502
+ } catch {
503
+ /* unserializable input — marker still names the tool */
504
+ }
505
+ appendTextBlock(`[Claude Code · ${block.name}${argsPreview}]`);
506
+ }
507
+ }
508
+
509
+ function applyResult(result: ClaudeResultMessage): void {
510
+ // Authoritative cumulative usage for the whole episode (verified to
511
+ // equal the per-cycle sums on captured streams; trusted over them).
512
+ const usage = result.usage;
513
+ if (usage) {
514
+ cumulativeUsage.input_tokens =
515
+ usage.input_tokens ?? cumulativeUsage.input_tokens;
516
+ cumulativeUsage.output_tokens =
517
+ usage.output_tokens ?? cumulativeUsage.output_tokens;
518
+ cumulativeUsage.cache_read_input_tokens =
519
+ usage.cache_read_input_tokens ??
520
+ cumulativeUsage.cache_read_input_tokens;
521
+ cumulativeUsage.cache_creation_input_tokens =
522
+ usage.cache_creation_input_tokens ??
523
+ cumulativeUsage.cache_creation_input_tokens;
524
+ cycleUsage = {};
525
+ recomputeUsage();
526
+ }
527
+
528
+ // Safety net: whatever ended the SSE stream early, the result envelope
529
+ // carries the episode's final answer — never let it be lost.
530
+ const finalText = (result.result ?? "").trim();
531
+ if (finalText) {
532
+ const streamedText = output.content
533
+ .filter((c): c is TextContent => c.type === "text")
534
+ .map((c) => c.text)
535
+ .join("\n");
536
+ const tail = finalText.slice(-Math.min(finalText.length, 60));
537
+ if (!streamedText.includes(tail)) {
538
+ appendTextBlock(finalText);
539
+ }
540
+ }
541
+ }
542
+
381
543
  return {
382
544
  handleEvent,
545
+ handleAssistantEnvelope,
546
+ applyResult,
383
547
  getOutput: () => output,
384
548
  };
385
549
  }
package/src/provider.ts CHANGED
@@ -41,7 +41,15 @@ import { handleControlRequest } from "./control-handler.js";
41
41
  import { mapThinkingEffort } from "./thinking-config.js";
42
42
  import { isPiKnownClaudeTool } from "./tool-mapping.js";
43
43
  /** Inactivity timeout: kill subprocess if no stdout for 180 seconds (3 minutes). */
44
- const INACTIVITY_TIMEOUT_MS = 180_000;
44
+ /**
45
+ * Inactivity timeout. CLI-side tool executions (web search, user MCP
46
+ * servers, sub-agents) can be silent on stdout for minutes, so the default
47
+ * is generous and overridable via PI_CLAUDE_CLI_TIMEOUT_MS.
48
+ */
49
+ const INACTIVITY_TIMEOUT_MS =
50
+ Number(process.env.PI_CLAUDE_CLI_TIMEOUT_MS) > 0
51
+ ? Number(process.env.PI_CLAUDE_CLI_TIMEOUT_MS)
52
+ : 300_000;
45
53
 
46
54
  /** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
47
55
  type StreamViaCLiOptions = SimpleStreamOptions & {
@@ -275,6 +283,12 @@ export function streamViaCli(
275
283
  rl.close();
276
284
  return; // Don't process further -- done event already pushed by event bridge
277
285
  }
286
+ } else if (msg.type === "assistant") {
287
+ // Complete-block envelopes: marker text for CLI-side tools that
288
+ // would otherwise be invisible between cycles.
289
+ bridge.handleAssistantEnvelope(msg as any);
290
+ } else if (msg.type === "user") {
291
+ // Tool results the CLI feeds back between cycles — internal.
278
292
  } else if (msg.type === "control_request") {
279
293
  handleControlRequest(msg, proc!.stdin!);
280
294
  } else if (msg.type === "result") {
@@ -296,6 +310,10 @@ export function streamViaCli(
296
310
  : `Claude CLI returned ${r.subtype ?? "non-success result"}`);
297
311
  endStreamWithError(errMsg);
298
312
  }
313
+ if (!isError) {
314
+ // Authoritative episode usage + final-answer safety net.
315
+ bridge.applyResult(r);
316
+ }
299
317
  // For both success and error: clean up the subprocess
300
318
  clearTimeout(inactivityTimer);
301
319
  cleanupProcess(proc!);
package/src/types.ts CHANGED
@@ -13,6 +13,49 @@ export interface ClaudeResultMessage {
13
13
  result?: string;
14
14
  error?: string;
15
15
  session_id?: string;
16
+ /** Cumulative usage across every cycle of the episode (authoritative). */
17
+ usage?: ClaudeUsage;
18
+ num_turns?: number;
19
+ total_cost_usd?: number;
20
+ }
21
+
22
+ /**
23
+ * Complete-message envelope the CLI emits once per finished content block
24
+ * (in addition to the SSE stream_events). `parent_tool_use_id` is null for
25
+ * top-level content and set for sub-agent activity.
26
+ */
27
+ export interface ClaudeAssistantEnvelope {
28
+ type: "assistant";
29
+ parent_tool_use_id?: string | null;
30
+ message: {
31
+ id?: string;
32
+ role?: string;
33
+ stop_reason?: string | null;
34
+ content?: Array<{
35
+ type: string;
36
+ text?: string;
37
+ thinking?: string;
38
+ id?: string;
39
+ name?: string;
40
+ input?: Record<string, unknown>;
41
+ }>;
42
+ usage?: ClaudeUsage;
43
+ };
44
+ }
45
+
46
+ /** Tool results the CLI feeds back between cycles (top-level only). */
47
+ export interface ClaudeUserEnvelope {
48
+ type: "user";
49
+ parent_tool_use_id?: string | null;
50
+ message: {
51
+ role?: string;
52
+ content?: Array<{
53
+ type: string;
54
+ tool_use_id?: string;
55
+ content?: unknown;
56
+ is_error?: boolean;
57
+ }>;
58
+ };
16
59
  }
17
60
 
18
61
  export interface ClaudeSystemMessage {
@@ -36,7 +79,9 @@ export type NdjsonMessage =
36
79
  | ClaudeStreamEventMessage
37
80
  | ClaudeResultMessage
38
81
  | ClaudeSystemMessage
39
- | ClaudeControlRequest;
82
+ | ClaudeControlRequest
83
+ | ClaudeAssistantEnvelope
84
+ | ClaudeUserEnvelope;
40
85
 
41
86
  // Claude API event types (inside stream_event wrapper)
42
87
 
@@ -81,5 +126,6 @@ export interface ClaudeUsage {
81
126
  export interface TrackedContentBlock {
82
127
  type: "text" | "thinking";
83
128
  text: string;
84
- index: number; // Claude's content_block index
129
+ index: number; // Claude's content_block index (resets each cycle)
130
+ cycle: number; // Which API call of the episode this block belongs to
85
131
  }