@saccolabs/pi-claude-cli 0.4.3 → 0.4.5

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/index.ts CHANGED
@@ -22,6 +22,44 @@ process.on("exit", killAllProcesses);
22
22
 
23
23
  const PROVIDER_ID = "pi-claude-cli";
24
24
 
25
+ /**
26
+ * Status key carrying account rate-limit state to the front-end. Neutral
27
+ * (not pidex-specific) because any pi front-end can read it.
28
+ */
29
+ const RATE_LIMIT_STATUS_KEY = "claude-rate-limit";
30
+
31
+ /**
32
+ * The stream runs deep inside streamSimple, which has no ExtensionContext,
33
+ * so the ctx handed to session_start is kept for its `ui.setStatus`.
34
+ */
35
+ let uiContext:
36
+ { ui?: { setStatus?(key: string, text?: string): void } } | undefined;
37
+ /** Last payload pushed — the CLI repeats this event on every turn. */
38
+ let lastRateLimitJson: string | undefined;
39
+
40
+ function publishRateLimit(info: Record<string, unknown>): void {
41
+ const setStatus = uiContext?.ui?.setStatus;
42
+ if (typeof setStatus !== "function") return;
43
+ const payload = JSON.stringify({
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
+ });
51
+ // Push only on change: the event repeats every turn, and a status that
52
+ // rewrites itself constantly is noise for whatever renders it.
53
+ const withoutObservedAt = payload.replace(/,"observedAt":\d+/, "");
54
+ if (withoutObservedAt === lastRateLimitJson) return;
55
+ lastRateLimitJson = withoutObservedAt;
56
+ try {
57
+ setStatus.call(uiContext!.ui, RATE_LIMIT_STATUS_KEY, payload);
58
+ } catch {
59
+ /* never break a turn over a status push */
60
+ }
61
+ }
62
+
25
63
  let mcpConfigPath: string | undefined;
26
64
  let mcpConfigResolved = false;
27
65
 
@@ -91,7 +129,9 @@ export default function (pi: ExtensionAPI) {
91
129
 
92
130
  // Ensure all registered tools are active so pi can execute them.
93
131
  // Some tools (find, grep, ls) are registered but not activated by default.
94
- pi.on("session_start", async () => {
132
+ pi.on("session_start", async (_event: unknown, ctx: unknown) => {
133
+ uiContext = ctx as typeof uiContext;
134
+ lastRateLimitJson = undefined;
95
135
  const allTools = pi.getAllTools();
96
136
  if (Array.isArray(allTools)) {
97
137
  pi.setActiveTools(allTools.map((t: any) => t.name));
@@ -107,6 +147,7 @@ export default function (pi: ExtensionAPI) {
107
147
  return streamViaCli(model, context, {
108
148
  ...options,
109
149
  mcpConfigPath: configPath,
150
+ onRateLimit: publishRateLimit,
110
151
  });
111
152
  };
112
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -28,6 +28,8 @@ interface TrackedToolBlock {
28
28
  type: "tool_use";
29
29
  index: number;
30
30
  cycle: number;
31
+ /** Position in output.content, or -1 when not materialized. */
32
+ contentIndex: number;
31
33
  id: string;
32
34
  name: string; // Already mapped to pi name
33
35
  claudeName: string; // Original Claude name for arg translation
@@ -152,20 +154,18 @@ export function createEventBridge(
152
154
  }
153
155
 
154
156
  /**
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.
157
+ * Append a complete text block through proper pi stream events. Tracked
158
+ * blocks carry their own contentIndex, so appended blocks (markers, the
159
+ * final-answer safety net) need no relationship to any SSE index.
160
160
  */
161
161
  function appendTextBlock(text: string): void {
162
162
  if (!started) {
163
163
  stream.push({ type: "start", partial: output });
164
164
  started = true;
165
165
  }
166
- blocks.push({ type: "text", text, index: -1, cycle: -1 });
166
+ const contentIndex = output.content.length;
167
+ blocks.push({ type: "text", text, index: -1, cycle: -1, contentIndex });
167
168
  output.content.push({ type: "text" as const, text: "" });
168
- const contentIndex = output.content.length - 1;
169
169
  stream.push({ type: "text_start", contentIndex, partial: output });
170
170
  (output.content[contentIndex] as TextContent).text = text;
171
171
  stream.push({
@@ -238,34 +238,32 @@ export function createEventBridge(
238
238
  text: "",
239
239
  index: event.index ?? 0,
240
240
  cycle,
241
+ contentIndex: output.content.length,
241
242
  };
242
243
  blocks.push(block);
243
244
  output.content.push({ type: "text" as const, text: "" });
244
245
 
245
246
  stream.push({
246
247
  type: "text_start",
247
- contentIndex: output.content.length - 1,
248
+ contentIndex: block.contentIndex,
248
249
  partial: output,
249
250
  });
250
251
  } else if (blockType === "thinking") {
252
+ // Deliberately NOT materialized yet. Several Claude models (verified:
253
+ // fable-5, opus-5, sonnet-5 at effort medium; haiku-4-5 is the
254
+ // exception) stream ENCRYPTED thinking: a multi-kilobyte
255
+ // signature_delta with no thinking_delta at all. Materializing on
256
+ // start produced a thinking block with no text, which front-ends
257
+ // faithfully rendered as an empty "thought". The block is created on
258
+ // the first plaintext delta and dropped at block_stop if none arrives.
251
259
  const block: TrackedContentBlock = {
252
260
  type: "thinking",
253
261
  text: "",
254
262
  index: event.index ?? 0,
255
263
  cycle,
264
+ contentIndex: -1,
256
265
  };
257
266
  blocks.push(block);
258
- output.content.push({
259
- type: "thinking" as const,
260
- thinking: "",
261
- thinkingSignature: "",
262
- });
263
-
264
- stream.push({
265
- type: "thinking_start",
266
- contentIndex: output.content.length - 1,
267
- partial: output,
268
- });
269
267
  } else if (blockType === "tool_use") {
270
268
  const claudeName = event.content_block!.name!;
271
269
 
@@ -282,6 +280,7 @@ export function createEventBridge(
282
280
  type: "tool_use",
283
281
  index: event.index ?? 0,
284
282
  cycle,
283
+ contentIndex: output.content.length,
285
284
  id,
286
285
  name: piName,
287
286
  claudeName,
@@ -298,101 +297,112 @@ export function createEventBridge(
298
297
 
299
298
  stream.push({
300
299
  type: "toolcall_start",
301
- contentIndex: output.content.length - 1,
300
+ contentIndex: block.contentIndex,
302
301
  partial: output,
303
302
  });
304
303
  }
305
304
  // Unknown block types silently ignored
306
305
  }
307
306
 
307
+ /** Locate the tracked block for this cycle's SSE index. */
308
+ function trackedFor(event: ClaudeApiEvent): TrackedBlock | undefined {
309
+ const idx = blocks.findIndex(
310
+ (b) => b.cycle === cycle && b.index === event.index,
311
+ );
312
+ return idx === -1 ? undefined : blocks[idx];
313
+ }
314
+
315
+ /**
316
+ * Create the pi content block for a thinking block that has now proven it
317
+ * carries plaintext. Encrypted thinking never reaches this path, so it
318
+ * never becomes an empty "thought" downstream.
319
+ */
320
+ function materializeThinking(block: TrackedContentBlock): void {
321
+ block.contentIndex = output.content.length;
322
+ output.content.push({
323
+ type: "thinking" as const,
324
+ thinking: "",
325
+ thinkingSignature: block.pendingSignature ?? "",
326
+ });
327
+ block.pendingSignature = undefined;
328
+ stream.push({
329
+ type: "thinking_start",
330
+ contentIndex: block.contentIndex,
331
+ partial: output,
332
+ });
333
+ }
334
+
308
335
  function handleContentBlockDelta(event: ClaudeApiEvent): void {
309
336
  const deltaType = event.delta?.type;
337
+ const block = trackedFor(event);
338
+ if (!block) return;
310
339
 
311
340
  if (deltaType === "text_delta" && event.delta!.text != null) {
312
- const idx = blocks.findIndex(
313
- (b) => b.cycle === cycle && b.index === event.index,
314
- );
315
- if (idx === -1) return;
316
-
317
- const block = blocks[idx];
318
- if (block.type === "text") {
319
- block.text += event.delta!.text;
320
- const contentBlock = output.content[idx] as TextContent;
321
- contentBlock.text = block.text;
322
-
323
- stream.push({
324
- type: "text_delta",
325
- contentIndex: idx,
326
- delta: event.delta!.text,
327
- partial: output,
328
- });
329
- }
341
+ if (block.type !== "text") return;
342
+ block.text += event.delta!.text;
343
+ (output.content[block.contentIndex] as TextContent).text = block.text;
344
+ stream.push({
345
+ type: "text_delta",
346
+ contentIndex: block.contentIndex,
347
+ delta: event.delta!.text,
348
+ partial: output,
349
+ });
330
350
  } else if (
331
351
  deltaType === "thinking_delta" &&
332
352
  event.delta!.thinking != null
333
353
  ) {
334
- const idx = blocks.findIndex(
335
- (b) => b.cycle === cycle && b.index === event.index,
336
- );
337
- if (idx === -1) return;
338
-
339
- const block = blocks[idx];
340
- if (block.type === "thinking") {
341
- block.text += event.delta!.thinking;
342
- const contentBlock = output.content[idx] as ThinkingContent;
343
- contentBlock.thinking = block.text;
344
-
345
- stream.push({
346
- type: "thinking_delta",
347
- contentIndex: idx,
348
- delta: event.delta!.thinking,
349
- partial: output,
350
- });
354
+ if (block.type !== "thinking") return;
355
+ // Empty thinking_delta events accompany encrypted thinking (verified
356
+ // on sonnet-5): they carry no plaintext, so they must not bring a
357
+ // thinking block into existence.
358
+ if (block.contentIndex === -1) {
359
+ if (event.delta!.thinking.length === 0) return;
360
+ materializeThinking(block);
351
361
  }
362
+ block.text += event.delta!.thinking;
363
+ (output.content[block.contentIndex] as ThinkingContent).thinking =
364
+ block.text;
365
+ stream.push({
366
+ type: "thinking_delta",
367
+ contentIndex: block.contentIndex,
368
+ delta: event.delta!.thinking,
369
+ partial: output,
370
+ });
352
371
  } else if (
353
372
  deltaType === "input_json_delta" &&
354
373
  event.delta!.partial_json != null
355
374
  ) {
356
- const idx = blocks.findIndex(
357
- (b) => b.cycle === cycle && b.index === event.index,
358
- );
359
- if (idx === -1) return;
360
-
361
- const block = blocks[idx];
362
- if (block.type === "tool_use") {
363
- block.partialJson += event.delta!.partial_json;
364
-
365
- // Try to parse accumulated JSON -- on success update args, on failure keep previous
366
- try {
367
- block.arguments = JSON.parse(block.partialJson);
368
- (output.content[idx] as any).arguments = block.arguments;
369
- } catch {
370
- // Partial JSON not yet parseable -- keep previous arguments
371
- }
372
-
373
- stream.push({
374
- type: "toolcall_delta",
375
- contentIndex: idx,
376
- delta: event.delta!.partial_json,
377
- partial: output,
378
- });
375
+ if (block.type !== "tool_use") return;
376
+ block.partialJson += event.delta!.partial_json;
377
+ try {
378
+ block.arguments = JSON.parse(block.partialJson);
379
+ (output.content[block.contentIndex] as any).arguments = block.arguments;
380
+ } catch {
381
+ // Partial JSON not yet parseable -- keep previous arguments
379
382
  }
383
+ stream.push({
384
+ type: "toolcall_delta",
385
+ contentIndex: block.contentIndex,
386
+ delta: event.delta!.partial_json,
387
+ partial: output,
388
+ });
380
389
  } else if (
381
390
  deltaType === "signature_delta" &&
382
391
  event.delta!.signature != null
383
392
  ) {
384
- // Accumulate signature on the thinking block
385
- const idx = blocks.findIndex(
386
- (b) => b.cycle === cycle && b.index === event.index,
387
- );
388
- if (idx === -1) return;
389
-
390
- const block = blocks[idx];
391
- if (block.type === "thinking") {
392
- const contentBlock = output.content[idx] as ThinkingContent;
393
- contentBlock.thinkingSignature =
394
- (contentBlock.thinkingSignature || "") + event.delta!.signature;
393
+ if (block.type !== "thinking") return;
394
+ if (block.contentIndex === -1) {
395
+ // Signature before (or without) any plaintext: hold it in case
396
+ // plaintext follows. If it never does, the block is dropped.
397
+ block.pendingSignature =
398
+ (block.pendingSignature ?? "") + event.delta!.signature;
399
+ return;
395
400
  }
401
+ const contentBlock = output.content[
402
+ block.contentIndex
403
+ ] as ThinkingContent;
404
+ contentBlock.thinkingSignature =
405
+ (contentBlock.thinkingSignature || "") + event.delta!.signature;
396
406
  }
397
407
  }
398
408
 
@@ -401,22 +411,29 @@ export function createEventBridge(
401
411
  (b) => b.cycle === cycle && b.index === event.index,
402
412
  );
403
413
  if (idx === -1) return;
404
-
405
414
  const block = blocks[idx];
415
+
416
+ // Encrypted thinking: signature only, no plaintext. Drop it rather than
417
+ // emit a content block with nothing to show.
418
+ if (block.type === "thinking" && block.contentIndex === -1) {
419
+ blocks.splice(idx, 1);
420
+ return;
421
+ }
422
+
406
423
  // Clean up the tracking index from the block (no longer needed)
407
424
  delete (block as any).index;
408
425
 
409
426
  if (block.type === "text") {
410
427
  stream.push({
411
428
  type: "text_end",
412
- contentIndex: idx,
429
+ contentIndex: block.contentIndex,
413
430
  content: block.text,
414
431
  partial: output,
415
432
  });
416
433
  } else if (block.type === "thinking") {
417
434
  stream.push({
418
435
  type: "thinking_end",
419
- contentIndex: idx,
436
+ contentIndex: block.contentIndex,
420
437
  content: block.text,
421
438
  partial: output,
422
439
  });
@@ -430,9 +447,7 @@ export function createEventBridge(
430
447
  finalArgs = block.partialJson;
431
448
  }
432
449
 
433
- // Update output.content with final arguments
434
- const contentBlock = output.content[idx] as ToolCall;
435
- (contentBlock as any).arguments = finalArgs;
450
+ (output.content[block.contentIndex] as any).arguments = finalArgs;
436
451
 
437
452
  // ToolCall.arguments is typed as Record<string, any> in pi-ai, but we
438
453
  // intentionally emit a raw string when JSON parse fails completely.
@@ -446,7 +461,7 @@ export function createEventBridge(
446
461
 
447
462
  stream.push({
448
463
  type: "toolcall_end",
449
- contentIndex: idx,
464
+ contentIndex: block.contentIndex,
450
465
  toolCall,
451
466
  partial: output,
452
467
  });
@@ -502,6 +517,17 @@ export function createEventBridge(
502
517
  } catch {
503
518
  /* unserializable input — marker still names the tool */
504
519
  }
520
+ // WIRE CONTRACT — front-ends parse this string.
521
+ //
522
+ // [Claude Code · <ToolName>] (no arguments)
523
+ // [Claude Code · <ToolName> <argsJson>] (preview, may be truncated)
524
+ //
525
+ // pidex matches /^\[Claude Code · ([^\s\]]+)(?:\s+([\s\S]*))?\]$/ to
526
+ // render these as activity rows instead of prose; anything it cannot
527
+ // match falls back to being shown as raw markdown, which is what this
528
+ // marker existed to avoid. Change the shape only together with the
529
+ // consumers, and keep the argument preview opaque — it is truncated
530
+ // here, so it is frequently invalid JSON and must never be parsed.
505
531
  appendTextBlock(`[Claude Code · ${block.name}${argsPreview}]`);
506
532
  }
507
533
  }
package/src/provider.ts CHANGED
@@ -55,6 +55,8 @@ const INACTIVITY_TIMEOUT_MS =
55
55
  type StreamViaCLiOptions = SimpleStreamOptions & {
56
56
  cwd?: string;
57
57
  mcpConfigPath?: string;
58
+ /** Called with account rate-limit state as the CLI reports it. */
59
+ onRateLimit?: (info: Record<string, unknown>) => void;
58
60
  };
59
61
 
60
62
  /**
@@ -296,6 +298,18 @@ export function streamViaCli(
296
298
  rl.close();
297
299
  return; // Don't process further -- done event already pushed by event bridge
298
300
  }
301
+ } else if (msg.type === "rate_limit_event") {
302
+ // Account-level state, not turn content: hand it to the host so a
303
+ // front-end can surface the window and its reset. Never touches
304
+ // the assistant message.
305
+ const info = (msg as any).rate_limit_info;
306
+ if (info && typeof info === "object") {
307
+ try {
308
+ options?.onRateLimit?.(info);
309
+ } catch {
310
+ /* a status push must never break a turn */
311
+ }
312
+ }
299
313
  } else if (msg.type === "assistant") {
300
314
  // Complete-block envelopes: marker text for CLI-side tools that
301
315
  // would otherwise be invisible between cycles.
package/src/types.ts CHANGED
@@ -43,6 +43,24 @@ export interface ClaudeAssistantEnvelope {
43
43
  };
44
44
  }
45
45
 
46
+ /**
47
+ * Emitted when the API reports rate-limit state for the account. Carries the
48
+ * window and its reset, not a utilization percentage — the percentages the
49
+ * Claude Code TUI shows come from `anthropic-ratelimit-unified-*` response
50
+ * headers, which the CLI consumes in-process and does not forward here.
51
+ */
52
+ export interface ClaudeRateLimitEvent {
53
+ type: "rate_limit_event";
54
+ rate_limit_info?: {
55
+ status?: string;
56
+ resetsAt?: number;
57
+ rateLimitType?: string;
58
+ overageStatus?: string;
59
+ overageDisabledReason?: string;
60
+ isUsingOverage?: boolean;
61
+ };
62
+ }
63
+
46
64
  /** Tool results the CLI feeds back between cycles (top-level only). */
47
65
  export interface ClaudeUserEnvelope {
48
66
  type: "user";
@@ -81,7 +99,8 @@ export type NdjsonMessage =
81
99
  | ClaudeSystemMessage
82
100
  | ClaudeControlRequest
83
101
  | ClaudeAssistantEnvelope
84
- | ClaudeUserEnvelope;
102
+ | ClaudeUserEnvelope
103
+ | ClaudeRateLimitEvent;
85
104
 
86
105
  // Claude API event types (inside stream_event wrapper)
87
106
 
@@ -128,4 +147,8 @@ export interface TrackedContentBlock {
128
147
  text: string;
129
148
  index: number; // Claude's content_block index (resets each cycle)
130
149
  cycle: number; // Which API call of the episode this block belongs to
150
+ /** Position in output.content, or -1 while not materialized. */
151
+ contentIndex: number;
152
+ /** signature_delta received before any plaintext (encrypted thinking). */
153
+ pendingSignature?: string;
131
154
  }