@saccolabs/pi-claude-cli 0.4.3 → 0.4.4

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.3",
3
+ "version": "0.4.4",
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/types.ts CHANGED
@@ -128,4 +128,8 @@ export interface TrackedContentBlock {
128
128
  text: string;
129
129
  index: number; // Claude's content_block index (resets each cycle)
130
130
  cycle: number; // Which API call of the episode this block belongs to
131
+ /** Position in output.content, or -1 while not materialized. */
132
+ contentIndex: number;
133
+ /** signature_delta received before any plaintext (encrypted thinking). */
134
+ pendingSignature?: string;
131
135
  }