@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.16

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/cli.js +3705 -4015
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/settings-schema.d.ts +32 -1
  9. package/dist/types/eval/agent-bridge.d.ts +19 -0
  10. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  11. package/dist/types/eval/js/shared/types.d.ts +1 -1
  12. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/export/share.d.ts +8 -1
  15. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  16. package/dist/types/main.d.ts +2 -0
  17. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  18. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  19. package/dist/types/modes/components/session-selector.d.ts +25 -0
  20. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  21. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  22. package/dist/types/secrets/obfuscator.d.ts +3 -3
  23. package/dist/types/task/isolation-runner.d.ts +128 -0
  24. package/dist/types/task/worktree.d.ts +14 -1
  25. package/dist/types/thinking.d.ts +15 -0
  26. package/dist/types/tools/eval-render.d.ts +3 -0
  27. package/dist/types/tools/eval.d.ts +11 -17
  28. package/dist/types/tools/todo.d.ts +26 -28
  29. package/dist/types/tui/output-block.d.ts +8 -0
  30. package/dist/types/utils/image-resize.d.ts +2 -0
  31. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  32. package/dist/types/web/search/providers/exa.d.ts +2 -0
  33. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  34. package/package.json +12 -12
  35. package/scripts/build-binary.ts +18 -4
  36. package/src/advisor/__tests__/advisor.test.ts +114 -0
  37. package/src/advisor/runtime.ts +129 -1
  38. package/src/cli/args.ts +4 -5
  39. package/src/cli/flag-tables.ts +3 -3
  40. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  41. package/src/cli/gallery-fixtures/shell.ts +15 -23
  42. package/src/cli/session-picker.ts +17 -3
  43. package/src/commands/launch.ts +3 -3
  44. package/src/config/model-registry.ts +12 -4
  45. package/src/config/settings-schema.ts +37 -1
  46. package/src/edit/renderer.ts +34 -12
  47. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  48. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  49. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  50. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  51. package/src/eval/agent-bridge.ts +301 -71
  52. package/src/eval/jl/prelude.jl +32 -227
  53. package/src/eval/jl/runner.jl +38 -12
  54. package/src/eval/js/shared/helpers.ts +1 -114
  55. package/src/eval/js/shared/prelude.txt +13 -27
  56. package/src/eval/js/shared/runtime.ts +0 -6
  57. package/src/eval/js/shared/types.ts +1 -1
  58. package/src/eval/js/worker-protocol.ts +1 -1
  59. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  60. package/src/eval/py/executor.ts +1 -1
  61. package/src/eval/py/prelude.py +47 -105
  62. package/src/eval/py/runner.py +0 -6
  63. package/src/eval/rb/prelude.rb +21 -189
  64. package/src/eval/rb/runner.rb +116 -9
  65. package/src/exec/bash-executor.ts +44 -0
  66. package/src/export/html/tool-views.generated.js +29 -29
  67. package/src/export/share.ts +51 -28
  68. package/src/internal-urls/docs-index.generated.txt +1 -1
  69. package/src/internal-urls/local-protocol.ts +100 -53
  70. package/src/main.ts +15 -4
  71. package/src/mcp/transports/stdio.ts +20 -4
  72. package/src/modes/acp/acp-event-mapper.ts +7 -2
  73. package/src/modes/components/custom-editor.test.ts +22 -0
  74. package/src/modes/components/custom-editor.ts +10 -1
  75. package/src/modes/components/footer.ts +4 -3
  76. package/src/modes/components/plugin-settings.ts +7 -1
  77. package/src/modes/components/session-selector.ts +143 -29
  78. package/src/modes/components/status-line/component.ts +5 -1
  79. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  80. package/src/modes/components/status-line/segments.ts +1 -1
  81. package/src/modes/components/status-line/types.ts +1 -0
  82. package/src/modes/controllers/command-controller.ts +6 -0
  83. package/src/modes/rpc/rpc-mode.ts +6 -0
  84. package/src/modes/utils/copy-targets.ts +7 -2
  85. package/src/prompts/system/system-prompt.md +6 -6
  86. package/src/prompts/system/workflow-notice.md +3 -3
  87. package/src/prompts/tools/bash.md +18 -2
  88. package/src/prompts/tools/eval.md +19 -19
  89. package/src/prompts/tools/search.md +1 -0
  90. package/src/prompts/tools/todo.md +1 -1
  91. package/src/secrets/obfuscator.ts +3 -9
  92. package/src/session/agent-session.ts +264 -52
  93. package/src/slash-commands/builtin-registry.ts +2 -1
  94. package/src/task/index.ts +61 -207
  95. package/src/task/isolation-runner.ts +354 -0
  96. package/src/task/worktree.ts +46 -9
  97. package/src/thinking.ts +20 -0
  98. package/src/tools/ask.ts +44 -38
  99. package/src/tools/bash.ts +9 -2
  100. package/src/tools/browser/tab-worker.ts +1 -1
  101. package/src/tools/eval-render.ts +34 -27
  102. package/src/tools/eval.ts +100 -103
  103. package/src/tools/index.ts +8 -1
  104. package/src/tools/read.ts +136 -60
  105. package/src/tools/todo.ts +60 -64
  106. package/src/tui/code-cell.ts +1 -1
  107. package/src/tui/output-block.ts +11 -0
  108. package/src/utils/image-resize.ts +30 -0
  109. package/src/utils/shell-snapshot.ts +63 -1
  110. package/src/web/search/providers/exa.ts +85 -1
  111. package/src/web/search/providers/perplexity.ts +18 -6
@@ -5,6 +5,7 @@ import {
5
5
  Input,
6
6
  matchesKey,
7
7
  padding,
8
+ parseSgrMouse,
8
9
  replaceTabs,
9
10
  ScrollView,
10
11
  Spacer,
@@ -161,6 +162,12 @@ export function mergeSessionRanking(
161
162
  class SessionList implements Component {
162
163
  #filteredSessions: SessionInfo[] = [];
163
164
  #selectedIndex: number = 0;
165
+ // Maps a 0-based line within this list's own render to a filtered-session
166
+ // index, or undefined for chrome rows (search line, blanks, scrollbar gap).
167
+ // Rebuilt every render so the picker's mouse hit-testing tracks the live
168
+ // scroll window. Only consulted while the picker holds the alternate screen
169
+ // (where the overlay enables mouse tracking and paints from screen row 0).
170
+ #hitRows: (number | undefined)[] = [];
164
171
  readonly #searchInput: Input;
165
172
  onSelect?: (session: SessionInfo) => void;
166
173
  onCancel?: () => void;
@@ -257,12 +264,32 @@ class SessionList implements Component {
257
264
  }
258
265
  }
259
266
 
267
+ /** Resolve a list-local rendered-line index to a filtered-session index. */
268
+ hitTestSession(line: number): number | undefined {
269
+ return this.#hitRows[line];
270
+ }
271
+
272
+ /** Wheel notch: move the selection one step (clamped, no wrap). */
273
+ handleWheel(delta: -1 | 1): void {
274
+ if (this.#filteredSessions.length === 0) return;
275
+ this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
276
+ }
277
+
278
+ /** Mouse click: select the session under the pointer and resume it. */
279
+ selectAndConfirm(index: number): void {
280
+ const session = this.#filteredSessions[index];
281
+ if (!session) return;
282
+ this.#selectedIndex = index;
283
+ this.onSelect?.(session);
284
+ }
285
+
260
286
  invalidate(): void {
261
287
  // No cached state to invalidate currently
262
288
  }
263
289
 
264
290
  render(width: number): readonly string[] {
265
291
  const lines: string[] = [];
292
+ this.#hitRows = [];
266
293
 
267
294
  // Render search input
268
295
  lines.push(...this.#searchInput.render(width));
@@ -311,9 +338,11 @@ class SessionList implements Component {
311
338
  // Each session block is built into sessionLines, then wrapped by ScrollView
312
339
  // so the right-edge scrollbar is proportional at the physical-line level.
313
340
  const sessionLines: string[] = [];
341
+ const sessionRowIndex: number[] = [];
314
342
  const overflow = this.#filteredSessions.length > maxVisible;
315
343
  const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
316
344
  for (let i = startIndex; i < endIndex; i++) {
345
+ const blockStart = sessionLines.length;
317
346
  const session = this.#filteredSessions[i];
318
347
  const isSelected = i === this.#selectedIndex;
319
348
 
@@ -363,6 +392,7 @@ class SessionList implements Component {
363
392
 
364
393
  sessionLines.push(metadataLine);
365
394
  sessionLines.push(""); // Blank line between sessions
395
+ for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i;
366
396
  }
367
397
 
368
398
  // Wrap the rendered window in a ScrollView for a proportional right-edge bar.
@@ -375,16 +405,10 @@ class SessionList implements Component {
375
405
  theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
376
406
  });
377
407
  sv.setScrollOffset(Math.round(startIndex * linesPerItem));
378
- lines.push(...sv.render(width));
379
-
380
- // Add keybinding hint
381
- lines.push("");
382
- lines.push(
383
- theme.fg(
384
- "muted",
385
- ` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`,
386
- ),
387
- );
408
+ const sessionRegionStart = lines.length;
409
+ const svLines = sv.render(width);
410
+ for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k];
411
+ lines.push(...svLines);
388
412
 
389
413
  return lines;
390
414
  }
@@ -462,6 +486,13 @@ export interface SessionSelectorOptions {
462
486
  * Omitted only in tests; defaults to a conservative 24 rows.
463
487
  */
464
488
  getTerminalRows?: () => number;
489
+ /**
490
+ * Fill the whole viewport and pin the footer (hint + bottom border) to the
491
+ * last rows, so the footer stops drifting as the list window changes height.
492
+ * Set by the standalone `--resume` picker (fullscreen alternate screen); the
493
+ * in-editor selector leaves it off and renders compactly.
494
+ */
495
+ fillHeight?: boolean;
465
496
  }
466
497
 
467
498
  /**
@@ -470,6 +501,13 @@ export interface SessionSelectorOptions {
470
501
  export class SessionSelectorComponent extends Container {
471
502
  #sessionList: SessionList;
472
503
  #confirmationDialog: HookSelectorComponent | null = null;
504
+ // Hosts whichever of `#sessionList` / `#confirmationDialog` is live this
505
+ // frame. The delete dialog REPLACES the list in this slot rather than being
506
+ // appended below the picker chrome, so the picker is always
507
+ // `chrome + max(list, dialog) + chrome` and never overflows the viewport
508
+ // (issue #3283: an overflowing dialog frame committed the header into
509
+ // scrollback, stranding it above the viewport once the dialog closed).
510
+ #contentSlot: Container;
473
511
  #messageContainer: Container;
474
512
  #headerText: Text;
475
513
  #onDelete?: (session: SessionInfo) => Promise<boolean>;
@@ -479,6 +517,18 @@ export class SessionSelectorComponent extends Container {
479
517
  #globalSessions: SessionInfo[] | null = null;
480
518
  #scope: "folder" | "all" = "folder";
481
519
  #toggling = false;
520
+ // 0-based line where the session list begins within this component's own
521
+ // render, captured each frame. The fullscreen picker overlay paints from
522
+ // screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
523
+ // list. Only meaningful while the picker holds the alternate screen.
524
+ #listLineOffset = 0;
525
+ // 0-based line where the pinned footer begins; clicks at or below it never
526
+ // hit-test the list, so a footer click on a cramped (trimmed) frame can't
527
+ // resume a session scrolled off-screen.
528
+ #footerStart = 0;
529
+ readonly #getTerminalRows: () => number;
530
+ readonly #fillHeight: boolean;
531
+ readonly #bottomBorder = new DynamicBorder();
482
532
 
483
533
  constructor(
484
534
  sessions: SessionInfo[],
@@ -494,6 +544,8 @@ export class SessionSelectorComponent extends Container {
494
544
  this.#loadAllSessions = options.loadAllSessions;
495
545
  this.#folderSessions = sessions;
496
546
  this.#globalSessions = options.allSessions ?? null;
547
+ this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
548
+ this.#fillHeight = options.fillHeight ?? false;
497
549
  // Add header
498
550
  this.addChild(new Spacer(1));
499
551
  this.#headerText = new Text(this.#headerLabel(), 1, 0);
@@ -517,11 +569,9 @@ export class SessionSelectorComponent extends Container {
517
569
  void this.#toggleScope();
518
570
  };
519
571
  }
520
- this.addChild(this.#sessionList);
521
-
522
- // Add bottom border
523
- this.addChild(new Spacer(1));
524
- this.addChild(new DynamicBorder());
572
+ this.#contentSlot = new Container();
573
+ this.#contentSlot.addChild(this.#sessionList);
574
+ this.addChild(this.#contentSlot);
525
575
  }
526
576
 
527
577
  #headerLabel(): string {
@@ -582,6 +632,15 @@ export class SessionSelectorComponent extends Container {
582
632
 
583
633
  #showDeleteConfirmation(session: SessionInfo): void {
584
634
  const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
635
+ const closeDialog = () => {
636
+ this.#confirmationDialog = null;
637
+ // Restore the SessionList into the content slot so the picker is back
638
+ // to its normal layout on the very next render — the same frame the
639
+ // dialog disappears.
640
+ this.#contentSlot.clear();
641
+ this.#contentSlot.addChild(this.#sessionList);
642
+ this.#onRequestRender?.();
643
+ };
585
644
  this.#confirmationDialog = new HookSelectorComponent(
586
645
  `Delete session?\n${displayName}`,
587
646
  ["Yes", "No"],
@@ -597,25 +656,61 @@ export class SessionSelectorComponent extends Container {
597
656
  this.#showError(err instanceof Error ? err.message : String(err));
598
657
  }
599
658
  }
600
- // Close confirmation dialog
601
- this.removeChild(this.#confirmationDialog!);
602
- this.#confirmationDialog = null;
603
- // Request rerender
604
- this.#onRequestRender?.();
605
- },
606
- () => {
607
- // Cancel - close confirmation dialog
608
- this.removeChild(this.#confirmationDialog!);
609
- this.#confirmationDialog = null;
610
- // Request rerender
611
- this.#onRequestRender?.();
659
+ closeDialog();
612
660
  },
661
+ closeDialog,
613
662
  );
614
- // Show confirmation dialog
615
- this.addChild(this.#confirmationDialog);
663
+ // Swap the SessionList out of the content slot and mount the dialog in its
664
+ // place: the dialog competes only with the SessionList's rendered budget,
665
+ // never the SessionList AND the picker chrome, so the picker frame stays
666
+ // inside the terminal viewport and the TUI never commits the header into
667
+ // scrollback (issue #3283).
668
+ this.#contentSlot.clear();
669
+ this.#contentSlot.addChild(this.#confirmationDialog);
670
+ this.#onRequestRender?.();
671
+ }
672
+
673
+ /**
674
+ * Concatenate the children's renders (like {@link Container}) while recording
675
+ * the line where the session list begins, so the fullscreen picker can hit-
676
+ * test mouse rows against the live list window. SessionList rebuilds its lines
677
+ * every frame, so Container's reference-memoization never applied here.
678
+ *
679
+ * In fill-height mode the body is padded (or, on a cramped terminal, trimmed)
680
+ * to leave exactly enough room for the footer at the screen bottom, so the
681
+ * footer is always visible and never drifts as the list window resizes. The
682
+ * in-editor selector just appends the footer directly.
683
+ */
684
+ render(width: number): readonly string[] {
685
+ const lines: string[] = [];
686
+ for (const child of this.children) {
687
+ const childLines = child.render(width);
688
+ if (child === this.#contentSlot) this.#listLineOffset = lines.length;
689
+ for (const line of childLines) lines.push(line);
690
+ }
691
+ const footer = this.#footerLines(width);
692
+ if (this.#fillHeight) {
693
+ const target = Math.max(0, this.#getTerminalRows() - footer.length);
694
+ if (lines.length > target) lines.length = target;
695
+ else for (let i = lines.length; i < target; i++) lines.push("");
696
+ }
697
+ this.#footerStart = lines.length;
698
+ for (const line of footer) lines.push(line);
699
+ return lines;
700
+ }
701
+
702
+ /** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
703
+ #footerLines(width: number): string[] {
704
+ const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
705
+ const hint = theme.fg("muted", ` [Del delete · Enter select · Tab ${scopeHint} · Esc cancel]`);
706
+ return ["", hint, "", ...this.#bottomBorder.render(width)];
616
707
  }
617
708
 
618
709
  handleInput(keyData: string): void {
710
+ if (keyData.startsWith("\x1b[<")) {
711
+ this.#handleMouse(keyData);
712
+ return;
713
+ }
619
714
  if (this.#confirmationDialog) {
620
715
  this.#confirmationDialog.handleInput(keyData);
621
716
  } else {
@@ -623,6 +718,25 @@ export class SessionSelectorComponent extends Container {
623
718
  }
624
719
  }
625
720
 
721
+ /**
722
+ * SGR mouse reports, delivered only while the picker holds the alternate
723
+ * screen (the fullscreen overlay enables tracking and paints from screen row
724
+ * 0). Wheel scrolls the list; a left click resumes the session under the
725
+ * pointer. Mouse is inert while the delete-confirmation dialog is open.
726
+ */
727
+ #handleMouse(data: string): void {
728
+ if (this.#confirmationDialog) return;
729
+ const event = parseSgrMouse(data);
730
+ if (!event) return;
731
+ if (event.wheel !== null) {
732
+ this.#sessionList.handleWheel(event.wheel);
733
+ return;
734
+ }
735
+ if (!event.leftClick || event.row >= this.#footerStart) return;
736
+ const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
737
+ if (index !== undefined) this.#sessionList.selectAndConfirm(index);
738
+ }
739
+
626
740
  getSessionList(): SessionList {
627
741
  return this.#sessionList;
628
742
  }
@@ -727,10 +727,12 @@ export class StatusLineComponent implements Component {
727
727
 
728
728
  let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
729
729
  let contextPercent: number | null = 0;
730
+ let contextTokens = 0;
730
731
  if (includeContext) {
731
732
  const breakdown = this.getCachedContextBreakdown();
733
+ contextTokens = breakdown.usedTokens;
732
734
  contextWindow = breakdown.contextWindow || contextWindow;
733
- contextPercent = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : 0;
735
+ contextPercent = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null;
734
736
  }
735
737
 
736
738
  // Collab guest: context comes from the host's state frames — the local
@@ -738,6 +740,7 @@ export class StatusLineComponent implements Component {
738
740
  const collabState = this.#collabStatus?.stateOverride;
739
741
  if (collabState?.contextUsage) {
740
742
  contextWindow = collabState.contextUsage.contextWindow || contextWindow;
743
+ contextTokens = collabState.contextUsage.tokens ?? contextTokens;
741
744
  contextPercent = collabState.contextUsage.percent ?? contextPercent;
742
745
  }
743
746
 
@@ -756,6 +759,7 @@ export class StatusLineComponent implements Component {
756
759
  collab: this.#collabStatus,
757
760
  usageStats,
758
761
  contextPercent,
762
+ contextTokens,
759
763
  contextWindow,
760
764
  autoCompactEnabled: this.#autoCompactEnabled,
761
765
  subagentCount: this.#subagentCount,
@@ -56,10 +56,18 @@ export function getContextUsageLevel(contextPercent: number, contextWindow: numb
56
56
  }
57
57
 
58
58
  /**
59
- * Format context usage as `<percent>%/<window>` (e.g. `5.1%/1M`), matching the
60
- * status line's context gauge so subagent and footer renderers stay in sync.
59
+ * Format context usage as `<percent>%/<window>` when the model window is known.
60
+ * Unknown windows render as `<tokens>/?`, because `0.0%/0` suggests a real
61
+ * empty context instead of missing provider metadata.
61
62
  */
62
- export function formatContextUsage(contextPercent: number | null | undefined, contextWindow: number): string {
63
+ export function formatContextUsage(
64
+ contextPercent: number | null | undefined,
65
+ contextWindow: number,
66
+ usedTokens?: number,
67
+ ): string {
68
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
69
+ return `${formatNumber(usedTokens ?? 0)}/?`;
70
+ }
63
71
  const pct = contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`;
64
72
  return `${pct}/${formatNumber(contextWindow)}`;
65
73
  }
@@ -375,7 +375,7 @@ const contextPctSegment: StatusLineSegment = {
375
375
  const window = ctx.contextWindow;
376
376
 
377
377
  const autoIcon = ctx.autoCompactEnabled && theme.icon.auto ? ` ${theme.icon.auto}` : "";
378
- const text = `${formatContextUsage(pct, window)}${autoIcon}`;
378
+ const text = `${formatContextUsage(pct, window, ctx.contextTokens)}${autoIcon}`;
379
379
 
380
380
  const color = getContextUsageThemeColor(getContextUsageLevel(pct ?? 0, window));
381
381
  const content = withIcon(theme.icon.context, theme.fg(color, text));
@@ -73,6 +73,7 @@ export interface SegmentContext {
73
73
  };
74
74
  /** Context usage percent, or null when unknown (e.g. right after compaction). */
75
75
  contextPercent: number | null;
76
+ contextTokens: number;
76
77
  contextWindow: number;
77
78
  autoCompactEnabled: boolean;
78
79
  subagentCount: number;
@@ -217,6 +217,7 @@ export class CommandController {
217
217
  try {
218
218
  const result = await shareSession(this.ctx.session.sessionManager, {
219
219
  serverUrl: this.ctx.settings.get("share.serverUrl"),
220
+ store: this.ctx.settings.get("share.store"),
220
221
  state: this.ctx.session.state,
221
222
  obfuscator: this.ctx.settings.get("share.redactSecrets") ? this.ctx.session.obfuscator : undefined,
222
223
  });
@@ -1171,6 +1172,11 @@ export class CommandController {
1171
1172
  }
1172
1173
 
1173
1174
  async handleHandoffCommand(customInstructions?: string): Promise<void> {
1175
+ if (this.ctx.session.isStreaming) {
1176
+ this.ctx.showWarning("Wait for the current response to finish or abort it before handing off.");
1177
+ return;
1178
+ }
1179
+
1174
1180
  const entries = this.ctx.sessionManager.getEntries();
1175
1181
  const messageCount = entries.filter(e => e.type === "message").length;
1176
1182
 
@@ -1008,6 +1008,12 @@ export async function runRpcMode(
1008
1008
  }
1009
1009
 
1010
1010
  case "handoff": {
1011
+ // Resetting the agent mid-stream lets the live turn keep emitting into a
1012
+ // session that handoff has already torn down. Refuse while a prompt is in
1013
+ // flight (mirrors the TUI /handoff guard).
1014
+ if (session.isStreaming) {
1015
+ return error(id, "handoff", "Cannot hand off while a response is in progress");
1016
+ }
1011
1017
  const result = await session.handoff(command.customInstructions);
1012
1018
  return success(id, "handoff", result ? { savedPath: result.savedPath } : null);
1013
1019
  }
@@ -127,8 +127,13 @@ export function extractQuoteBlocks(text: string): QuoteBlock[] {
127
127
 
128
128
  function extractEvalCode(args: unknown): { code: string; language: string } | undefined {
129
129
  if (!args || typeof args !== "object") return undefined;
130
- const cells = (args as { cells?: unknown }).cells;
131
- if (!Array.isArray(cells)) return undefined;
130
+ const argsObj = args as { cells?: unknown; code?: unknown };
131
+ const cells = Array.isArray(argsObj.cells)
132
+ ? argsObj.cells
133
+ : typeof argsObj.code === "string"
134
+ ? [argsObj]
135
+ : undefined;
136
+ if (!cells) return undefined;
132
137
 
133
138
  const codeBlocks: string[] = [];
134
139
  let language = "python";
@@ -102,16 +102,16 @@ Use tools whenever they improve correctness, completeness, or grounding.
102
102
  {{#has tools "inspect_image"}}- Image tasks: prefer `{{toolRefs.inspect_image}}` over `{{toolRefs.read}}` to spare session context.{{/has}}
103
103
 
104
104
  # Specialized Tools
105
- Dedicated tools add gitignore semantics, structured output, and line anchors a raw shell command lacks — reach for them first when they fit:
105
+ You MUST use the specialized tool over its shell equivalent:
106
106
  {{#has tools "read"}}- File or directory reads → `{{toolRefs.read}}` (a directory path lists entries).{{/has}}
107
107
  {{#has tools "edit"}}- Surgical edits → `{{toolRefs.edit}}`.{{/has}}
108
108
  {{#has tools "write"}}- Create or overwrite → `{{toolRefs.write}}`.{{/has}}
109
109
  {{#has tools "lsp"}}- Code intelligence → `{{toolRefs.lsp}}`.{{/has}}
110
- {{#has tools "search"}}- Regex search → `{{toolRefs.search}}`.{{/has}}
111
- {{#has tools "find"}}- Globbing → `{{toolRefs.find}}`.{{/has}}
112
- {{#has tools "eval"}}- Quick compute → `{{toolRefs.eval}}`; you SHOULD go step by step.{{/has}}
113
- {{#has tools "bash"}}- Use `{{toolRefs.bash}}` for terminal work—builds, tests, git, package managers—and pipelines that COMPUTE a fact: `wc -l`, `sort | uniq -c`, `comm`, `diff a b`, checksums.
114
- - Litmus: produces a count, frequency, set difference, or checksum no tool returnsbash. Otherwise a dedicated tool usually fits.{{/has}}
110
+ {{#has tools "search"}}- Regex search → `{{toolRefs.search}}`, not `grep`, `rg`, or `awk`.{{/has}}
111
+ {{#has tools "find"}}- Globbing → `{{toolRefs.find}}`, not `ls **/*.ext` or `fd`.{{/has}}
112
+ {{#has tools "eval"}}- Default for any compute: `{{toolRefs.eval}}` cells. Bash is the EXCEPTION — only single binary calls or short fact-computing pipelines (`wc -l`, `sort | uniq -c`, `diff`, checksums). The moment a command grows a loop, conditional, heredoc, `-e`/`-c` script, `$(…)` nesting, or >2 pipe stages, it's a program → `{{toolRefs.eval}}`. NEVER write multiline or inline-script bash.{{/has}}
113
+ {{#has tools "bash"}}- `{{toolRefs.bash}}`: real binaries and short fact pipelines only. Commands shadowing the specialized tools above are blocked.{{/has}}
114
+ {{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum bash.{{#has tools "eval"}} Needs control flow, state, or fights shell quoting `{{toolRefs.eval}}`.{{/has}} Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
115
115
 
116
116
  {{#has tools "report_tool_issue"}}
117
117
  <critical>
@@ -11,16 +11,16 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
11
11
  </when>
12
12
 
13
13
  <helpers>
14
- State persists across cells, so scout in one cell and fan out in the next. Every cell has:
14
+ State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
15
15
 
16
- - `agent(prompt, *, agent_type="task", model=None, label=None, schema=None)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent_type` picks a discovered agent ("explore", "reviewer", "oracle", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep.
16
+ - `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", "oracle", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
17
17
  - `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
18
18
  - `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
19
19
  - `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
20
20
  - `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it.
21
21
  - `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget.
22
22
 
23
- Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across cells and turns for multi-phase work, reading each result before you decide the next phase.
23
+ Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across calls and turns for multi-phase work, reading each result before you decide the next phase.
24
24
  </helpers>
25
25
 
26
26
  <structure>
@@ -1,5 +1,19 @@
1
1
  Runs bash in a shell session — terminal ops: git, bun, cargo, python.
2
2
 
3
+ # When to use bash — and when not to
4
+
5
+ Bash invokes **real binaries** with simple args. It is NOT a scripting surface.
6
+
7
+ Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`).
8
+
9
+ Anything below → `eval` cell, not bash:
10
+ - Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language
11
+ - Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
12
+ - `$(…)` command substitution nested inside another command
13
+ - Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
14
+ - Multiline commands, `&&`-chains mixing control flow
15
+ - Quote/JSON escaping that fights the shell
16
+
3
17
  <instruction>
4
18
  - `cwd` sets the working dir, not `cd dir && …`
5
19
  - `env: { NAME: "…" }` for multiline / quote-heavy / untrusted values; reference `$NAME`
@@ -14,8 +28,10 @@ Runs bash in a shell session — terminal ops: git, bun, cargo, python.
14
28
  </instruction>
15
29
 
16
30
  <critical>
17
- - NEVER trim or silence output: no `| head -n N`, `| tail -n N`, `| less`, `2>&1`, `2>/dev/null`. stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
18
- - Pipelines that COMPUTE a new fact are correct bash: `wc -l`, `sort | uniq -c`, `comm`, `cut`, `diff a b`, `shasum`. Litmus: produces a count, frequency table, set difference, or checksum no tool returns → bash. Merely moves or trims bytes a tool can fetch → use the tool.
31
+ - Bash invokes real binaries with simple args; it is NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.
32
+ - NEVER shell out to search content or files: `grep/rg` `search`.
33
+ - NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `find` tool (globbing). This is non-negotiable, even for a single quick listing.
34
+ - Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
19
35
  </critical>
20
36
 
21
37
  <output>
@@ -1,21 +1,23 @@
1
- Run code in a persistent kernel using a list of cells.
1
+ Run one step of code in a persistent kernel.
2
2
 
3
3
  <instruction>
4
- Cells run in array order. State persists per language across cells, tool calls, and `task` subagents — stage helpers/datasets/clients once, subagents reuse directly, no re-import/serialize.
4
+ **One eval call = one cell = one logical step.** State persists per language across separate eval calls, tool calls, and `task` subagents — define helpers/datasets/clients in one call, then later calls reuse them directly.
5
5
 
6
- Cell fields:
6
+ Work incrementally: imports in one call, define in the next, test, then use — each its own eval call. Re-run setup ONLY after `reset`, a kernel crash, or a `NameError`/`ReferenceError` proving the state is gone. Parallelize work *within* a cell with the `parallel(thunks)` helper, not by batching steps.
7
+
8
+ Fields:
7
9
 
8
10
  - `language` — {{#if py}}`"py"` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}`"rb"` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}`"jl"` persistent Julia kernel{{/if}}.
9
11
  - `code` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
10
12
  - `title` (optional) — short transcript label (e.g. `"imports"`).
11
- - `timeout` (optional) — per-cell seconds. Raise only for heavy compute or long non-agent tool calls.
12
- - `reset` (optional) — wipe this cell's language kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}}
13
+ - `timeout` (optional) — seconds. Raise only for heavy compute or long non-agent tool calls.
14
+ - `reset` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}}
13
15
 
14
- Work incrementally — one logical step per cell (imports, define, test, use), many small cells per call; workflow notes in the assistant message or `title`, never in cell code.
15
16
  {{#if py}}Live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}}
16
- {{#if rb}}Ruby: synchronous; helper options are keyword args (e.g. `tree(".", max_depth: 2)`); the last expression auto-displays unless it is `nil`, an assignment, or a definition (like IRB).{{/if}}
17
- {{#if jl}}Julia: synchronous; helper options are standard keyword args (e.g. `tree(max_depth=2)`); the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
18
- Errors name the failing cell ("Cell 3 failed") resubmit the fixed cell + any remaining.
17
+ {{#if js}}JS runs under **Bun**: Bun globals/APIs are available (`Bun.file`, `Bun.write`, `Bun.$`, `fetch`, `Buffer`); top-level `await`/`return` work directly.{{/if}}
18
+ {{#if rb}}Ruby: synchronous; helper options are keyword args (e.g. `output("id", limit: 2)`); the last expression auto-displays unless it is `nil`, an assignment, or a definition (like IRB).{{/if}}
19
+ {{#if jl}}Julia: synchronous; helper options are standard keyword args (e.g. `output("id", limit=2)`); the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
20
+ On error, fix and re-run only the failing step — prior calls' state survives.
19
21
  </instruction>
20
22
 
21
23
  <prelude>
@@ -29,12 +31,6 @@ read(path, offset?=1, limit?=None) → str
29
31
  File as text; offset/limit 1-indexed lines. Accepts `local://…`.
30
32
  write(path, content) → str
31
33
  Write file (creates parents) → resolved path. `local://…` persists across turns/subagents.
32
- append(path, content) → str
33
- Append → resolved path. Accepts `local://…`.
34
- tree(path?=".", max_depth?=3, show_hidden?=False) → str
35
- Directory tree.
36
- diff(a, b) → str
37
- Unified diff of two files.
38
34
  env(key?=None, value?=None) → str | None | dict
39
35
  No args → full env dict; one → value of `key`; two → set `key=value`, return value.
40
36
  output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
@@ -43,9 +39,9 @@ tool.<name>(args) → unknown
43
39
  Invoke any session tool; `args` = its parameter object.
44
40
  completion(prompt, model?="default", system?=None, schema?=None) → str | dict
45
41
  Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → structured output, parsed object.
46
- {{#if spawns}}agent(prompt, agent_type?="task", model?=None, label?=None, schema?=None, return_handle?=False) → str | dict
47
- Run a subagent → final output. `agent_type`/`agentType` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `return_handle`/`returnHandle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
48
- {{#if js}} JS: options are ONE trailing object — agent(prompt, { agentType, schema, returnHandle }).
42
+ {{#if spawns}}agent(prompt, agent?="task", model?=None, label?=None, schema?=None, handle?=False) → str | dict
43
+ Run a subagent → final output. `agent` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
44
+ {{#if js}} JS: options are ONE trailing object — agent(prompt, { agent, schema, handle }).
49
45
  {{/if}}
50
46
  {{/if}}
51
47
  parallel(thunks) → list
@@ -63,10 +59,14 @@ budget → per-turn token budget
63
59
  {{#if spawns}}
64
60
  <dag>
65
61
  Pipe handles through stage helpers to build a dependency graph — acyclic waves:
66
- - **Name nodes.** Capture each `agent(…, {{#if py}}return_handle=True{{/if}}{{#if js}}{ returnHandle: true }{{/if}}{{#if jl}}return_handle=true{{/if}})` result; carries `handle` (`agent://<id>`) + `output`.
62
+ - **Name nodes.** Capture each `agent(…, {{#if py}}handle=True{{/if}}{{#if js}}{ handle: true }{{/if}}{{#if jl}}handle=true{{/if}})` result; carries `handle` (`agent://<id>`) + `output`.
67
63
  - **Wire edges by reference.** Put an upstream node's `handle`/`output` in the dependent stage's prompt — large transcript never re-inlined. Bulk: `write("local://<name>.md", …)`, pass the URI.
68
64
  - **`pipeline(items, *stages)` = staged waves**, barrier between stages (every item clears stage N before any enters N+1). **`parallel(thunks)` = one wave** of independent nodes.
69
65
  - **Isolate failure.** A raising node re-raises the lowest-index error, aborts its wave; wrap risky nodes in try/except so a failure degrades only its dependent subtree, independent branches finish.
70
66
  - **Acyclic only.** A node never waits on its own descendant.
71
67
  </dag>
72
68
  {{/if}}
69
+
70
+ <critical>
71
+ Prior top-level names (`data`, `sessions`, helpers, imports) survive into the next eval call — reuse them; NEVER re-import, re-require, or re-declare a helper. Re-read a file only if it may have changed since the last read. Re-run setup only after `reset`, a crash, or a `NameError`/`ReferenceError`.
72
+ </critical>
@@ -17,5 +17,6 @@ Searches files using regex.
17
17
  </output>
18
18
 
19
19
  <critical>
20
+ - MUST use built-in `search` for any content search. NEVER shell out to `grep`, `rg`, `ripgrep`, `ag`, `ack`, `git grep`, `awk`, `sed`-for-search, or any CLI search via Bash — not even for one match or a quick check.
20
21
  - Open-ended search needing multiple rounds? MUST use the Task tool with the explore subagent, NOT chained `search` calls.
21
22
  </critical>
@@ -1,6 +1,6 @@
1
1
  **Tasks referenced by verbatim content string, NEVER an auto-generated ID — no "task-1"/"task-N" exists. Pass the content text in the `task` field.**
2
2
 
3
- Manages a phased task list. Pass `ops`: flat array of operations. Next pending task auto-promotes to `in_progress` on each completion. `pending` is a status, not an `op` — leave not-yet-started tasks implicit in `init`/`append`.
3
+ Next pending task auto-promotes to `in_progress` on each completion.
4
4
 
5
5
  ## Operations
6
6
 
@@ -259,9 +259,9 @@ export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages:
259
259
  }
260
260
 
261
261
  /**
262
- * Restore placeholders in assistant content: visible text, thinking text, and
263
- * tool-call arguments/intent/rawBlock. Signatures and redacted-thinking bytes
264
- * are opaque provider-replay data and pass through byte-identical.
262
+ * Restore placeholders in assistant content: visible text and tool-call
263
+ * arguments/intent/rawBlock. Thinking and signatures are opaque
264
+ * provider-replay/hidden-reasoning data and pass through byte-identical.
265
265
  */
266
266
  export function deobfuscateAssistantContent(
267
267
  obfuscator: SecretObfuscator,
@@ -276,12 +276,6 @@ export function deobfuscateAssistantContent(
276
276
  changed = true;
277
277
  return { ...block, text };
278
278
  }
279
- if (block.type === "thinking") {
280
- const thinking = obfuscator.deobfuscate(block.thinking);
281
- if (thinking === block.thinking) return block;
282
- changed = true;
283
- return { ...block, thinking };
284
- }
285
279
  if (block.type === "toolCall") {
286
280
  const args = deobfuscateToolArguments(obfuscator, block.arguments);
287
281
  const intent = block.intent === undefined ? undefined : obfuscator.deobfuscate(block.intent);