@oh-my-pi/pi-coding-agent 16.4.8 → 16.5.0

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 (134) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3502 -3444
  3. package/dist/types/cli/args.d.ts +5 -0
  4. package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +15 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -3
  7. package/dist/types/config/model-roles.d.ts +8 -0
  8. package/dist/types/config/settings-schema.d.ts +46 -14
  9. package/dist/types/extensibility/extensions/types.d.ts +1 -1
  10. package/dist/types/launch/broker.d.ts +2 -0
  11. package/dist/types/launch/client.d.ts +22 -0
  12. package/dist/types/launch/paths.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +8 -0
  14. package/dist/types/launch/protocol.d.ts +170 -0
  15. package/dist/types/launch/terminal-output.d.ts +7 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +1 -1
  17. package/dist/types/modes/components/index.d.ts +1 -0
  18. package/dist/types/modes/components/model-browser.d.ts +17 -2
  19. package/dist/types/modes/components/model-hub.d.ts +0 -10
  20. package/dist/types/modes/components/model-picker.d.ts +44 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  22. package/dist/types/modes/print-mode.d.ts +14 -8
  23. package/dist/types/modes/print-mode.test.d.ts +1 -0
  24. package/dist/types/modes/theme/theme.d.ts +2 -1
  25. package/dist/types/sdk.d.ts +5 -1
  26. package/dist/types/session/agent-session.d.ts +42 -0
  27. package/dist/types/session/session-context.d.ts +9 -0
  28. package/dist/types/session/session-entries.d.ts +6 -0
  29. package/dist/types/thinking.d.ts +2 -2
  30. package/dist/types/tiny/models.d.ts +1 -1
  31. package/dist/types/tools/browser/launch.d.ts +1 -0
  32. package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
  33. package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +6 -0
  35. package/dist/types/tools/builtin-names.d.ts +1 -1
  36. package/dist/types/tools/index.d.ts +1 -0
  37. package/dist/types/tools/launch.d.ts +121 -0
  38. package/dist/types/tools/render-utils.d.ts +2 -0
  39. package/dist/types/tools/terminal-output.d.ts +5 -0
  40. package/dist/types/vibe/runtime.d.ts +2 -2
  41. package/dist/types/web/search/types.d.ts +0 -8
  42. package/package.json +20 -20
  43. package/src/cli/args.ts +11 -0
  44. package/src/cli/flag-tables.ts +9 -0
  45. package/src/cli/gallery-fixtures/shell.ts +82 -1
  46. package/src/cli.ts +10 -0
  47. package/src/commands/launch.ts +17 -0
  48. package/src/config/model-resolver.ts +110 -31
  49. package/src/config/model-roles.ts +14 -0
  50. package/src/config/settings-schema.ts +71 -7
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +2 -2
  53. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  54. package/src/eval/completion-bridge.ts +4 -4
  55. package/src/export/ttsr.ts +0 -3
  56. package/src/extensibility/extensions/model-api.ts +1 -1
  57. package/src/extensibility/extensions/types.ts +1 -1
  58. package/src/internal-urls/docs-index.ts +4 -4
  59. package/src/launch/broker.ts +1017 -0
  60. package/src/launch/client.ts +344 -0
  61. package/src/launch/paths.ts +17 -0
  62. package/src/launch/presence.ts +82 -0
  63. package/src/launch/protocol.ts +386 -0
  64. package/src/launch/terminal-output.ts +46 -0
  65. package/src/main.ts +50 -1
  66. package/src/modes/acp/acp-agent.ts +8 -1
  67. package/src/modes/components/agent-hub.ts +101 -31
  68. package/src/modes/components/compaction-summary-message.ts +8 -2
  69. package/src/modes/components/index.ts +1 -0
  70. package/src/modes/components/model-browser.ts +111 -21
  71. package/src/modes/components/model-hub.ts +23 -116
  72. package/src/modes/components/model-picker.ts +233 -0
  73. package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
  74. package/src/modes/components/status-line/component.test.ts +41 -2
  75. package/src/modes/components/status-line/component.ts +4 -0
  76. package/src/modes/components/status-line/segments.ts +6 -0
  77. package/src/modes/components/status-line/types.ts +3 -0
  78. package/src/modes/controllers/command-controller.ts +9 -1
  79. package/src/modes/controllers/event-controller.ts +31 -32
  80. package/src/modes/controllers/selector-controller.ts +96 -22
  81. package/src/modes/controllers/tan-command-controller.ts +40 -1
  82. package/src/modes/interactive-mode.ts +7 -3
  83. package/src/modes/print-mode.test.ts +71 -0
  84. package/src/modes/print-mode.ts +51 -2
  85. package/src/modes/theme/theme.ts +9 -0
  86. package/src/modes/utils/ui-helpers.ts +25 -4
  87. package/src/prompts/agents/designer.md +1 -1
  88. package/src/prompts/agents/librarian.md +1 -1
  89. package/src/prompts/agents/reviewer.md +1 -1
  90. package/src/prompts/agents/scout.md +1 -1
  91. package/src/prompts/system/plan-yolo-handoff.md +5 -0
  92. package/src/prompts/system/prewalk-checklist.md +7 -0
  93. package/src/prompts/system/prewalk-continue.md +1 -0
  94. package/src/prompts/system/prewalk-plan.md +13 -0
  95. package/src/prompts/system/system-prompt.md +9 -7
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/bash.md +6 -4
  98. package/src/prompts/tools/browser.md +4 -4
  99. package/src/prompts/tools/launch.md +25 -0
  100. package/src/sdk.ts +8 -2
  101. package/src/session/agent-session.ts +550 -87
  102. package/src/session/session-context.test.ts +10 -5
  103. package/src/session/session-context.ts +25 -8
  104. package/src/session/session-entries.ts +6 -0
  105. package/src/slash-commands/builtin-registry.ts +25 -0
  106. package/src/task/agents.ts +2 -2
  107. package/src/thinking.ts +10 -3
  108. package/src/tiny/models.ts +7 -7
  109. package/src/tools/bash-interactive.ts +5 -8
  110. package/src/tools/bash.ts +38 -24
  111. package/src/tools/browser/cmux/cmux-tab.ts +17 -2
  112. package/src/tools/browser/launch.ts +8 -4
  113. package/src/tools/browser/run-cancellation.ts +66 -6
  114. package/src/tools/browser/tab-protocol.ts +6 -0
  115. package/src/tools/browser/tab-supervisor.ts +17 -1
  116. package/src/tools/browser/tab-worker.ts +140 -21
  117. package/src/tools/builtin-names.ts +1 -0
  118. package/src/tools/eval-render.ts +16 -14
  119. package/src/tools/index.ts +5 -0
  120. package/src/tools/inspect-image.ts +2 -2
  121. package/src/tools/launch.ts +643 -0
  122. package/src/tools/render-utils.ts +3 -0
  123. package/src/tools/renderers.ts +2 -0
  124. package/src/tools/terminal-output.ts +141 -0
  125. package/src/tts/speech-enhancer.ts +2 -2
  126. package/src/utils/image-vision-fallback.ts +3 -3
  127. package/src/vibe/runtime.ts +2 -2
  128. package/src/web/search/provider.ts +0 -10
  129. package/src/web/search/providers/public.ts +2 -4
  130. package/src/web/search/types.ts +0 -10
  131. package/dist/types/web/search/providers/bing.d.ts +0 -14
  132. package/dist/types/web/search/providers/yahoo.d.ts +0 -14
  133. package/src/web/search/providers/bing.ts +0 -197
  134. package/src/web/search/providers/yahoo.ts +0 -179
@@ -76,7 +76,7 @@ export class EventController {
76
76
  #lastVisibleBlockCount = 0;
77
77
  #renderedCustomMessages = new Set<string>();
78
78
  #lastIntent: string | undefined = undefined;
79
- #backgroundToolCallIds = new Set<string>();
79
+ #backgroundTaskCallIds = new Set<string>();
80
80
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
81
81
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
82
82
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -281,7 +281,7 @@ export class EventController {
281
281
  this.#lastVisibleBlockCount = 0;
282
282
  this.#renderedCustomMessages.clear();
283
283
  this.#lastIntent = undefined;
284
- this.#backgroundToolCallIds.clear();
284
+ this.#backgroundTaskCallIds.clear();
285
285
  this.#readToolCallArgs.clear();
286
286
  this.#readToolCallAssistantComponents.clear();
287
287
  this.#lastAssistantComponent = undefined;
@@ -828,9 +828,9 @@ export class EventController {
828
828
  // The turn ended without running these calls (abort/error/TTSR rewind),
829
829
  // so they will never produce a result. Seal them so they stop animating
830
830
  // and freeze instead of pinning the transcript live region while a retry
831
- // streams fresh blocks below them. Background tools keep updating.
831
+ // streams fresh blocks below them. Background task calls keep updating.
832
832
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
833
- if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
833
+ if (!this.#backgroundTaskCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
834
834
  component.seal();
835
835
  }
836
836
  }
@@ -948,7 +948,7 @@ export class EventController {
948
948
  // While the call is still executing — a mixed blocking+async task
949
949
  // call whose jobs settle before its blocking subset — treat it as a
950
950
  // partial frame: `tool_execution_end` still owns the terminal result.
951
- const isTerminal = isFinalAsyncState && this.#backgroundToolCallIds.has(event.toolCallId);
951
+ const isTerminal = isFinalAsyncState && this.#backgroundTaskCallIds.has(event.toolCallId);
952
952
  component.updateResult(
953
953
  { ...event.partialResult, isError: asyncState === "failed" },
954
954
  !isTerminal,
@@ -956,7 +956,7 @@ export class EventController {
956
956
  );
957
957
  if (isTerminal) {
958
958
  this.ctx.pendingTools.delete(event.toolCallId);
959
- this.#backgroundToolCallIds.delete(event.toolCallId);
959
+ this.#backgroundTaskCallIds.delete(event.toolCallId);
960
960
  }
961
961
  this.ctx.ui.requestRender();
962
962
  }
@@ -977,13 +977,7 @@ export class EventController {
977
977
  component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
978
978
  this.ctx.pendingTools.delete(event.toolCallId);
979
979
  }
980
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
981
- if (asyncState === "running") {
982
- this.#backgroundToolCallIds.add(event.toolCallId);
983
- } else {
984
- this.#backgroundToolCallIds.delete(event.toolCallId);
985
- this.#clearReadToolCall(event.toolCallId);
986
- }
980
+ this.#clearReadToolCall(event.toolCallId);
987
981
  this.ctx.ui.requestRender();
988
982
  } else {
989
983
  let component = this.ctx.pendingTools.get(event.toolCallId);
@@ -996,29 +990,22 @@ export class EventController {
996
990
  component = group;
997
991
  this.ctx.pendingTools.set(event.toolCallId, group);
998
992
  }
999
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
1000
- const isBackgroundRunning = asyncState === "running";
1001
- component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId);
1002
- if (isBackgroundRunning) {
1003
- this.#backgroundToolCallIds.add(event.toolCallId);
1004
- } else {
1005
- this.ctx.pendingTools.delete(event.toolCallId);
1006
- this.#backgroundToolCallIds.delete(event.toolCallId);
1007
- this.#clearReadToolCall(event.toolCallId);
1008
- }
993
+ component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
994
+ this.ctx.pendingTools.delete(event.toolCallId);
995
+ this.#clearReadToolCall(event.toolCallId);
1009
996
  this.ctx.ui.requestRender();
1010
997
  }
1011
998
  } else {
1012
999
  const component = this.ctx.pendingTools.get(event.toolCallId);
1013
1000
  if (component) {
1014
1001
  const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
1015
- const isBackgroundRunning = asyncState === "running";
1016
- component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId);
1017
- if (isBackgroundRunning) {
1018
- this.#backgroundToolCallIds.add(event.toolCallId);
1002
+ const isBackgroundTask = event.toolName === "task" && asyncState === "running";
1003
+ component.updateResult({ ...event.result, isError: event.isError }, isBackgroundTask, event.toolCallId);
1004
+ if (isBackgroundTask) {
1005
+ this.#backgroundTaskCallIds.add(event.toolCallId);
1019
1006
  } else {
1020
1007
  this.ctx.pendingTools.delete(event.toolCallId);
1021
- this.#backgroundToolCallIds.delete(event.toolCallId);
1008
+ this.#backgroundTaskCallIds.delete(event.toolCallId);
1022
1009
  }
1023
1010
  if (component instanceof ToolExecutionComponent && component.isDisplaceableBlock()) {
1024
1011
  if (event.toolName === "job" && component.canBeDisplacedBy("job")) {
@@ -1098,7 +1085,7 @@ export class EventController {
1098
1085
  }
1099
1086
  await this.ctx.flushPendingModelSwitch();
1100
1087
  for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
1101
- if (!this.#backgroundToolCallIds.has(toolCallId)) {
1088
+ if (!this.#backgroundTaskCallIds.has(toolCallId)) {
1102
1089
  // A foreground tool still pending at turn end never delivered a result;
1103
1090
  // seal it so it freezes (and stops animating) rather than lingering in
1104
1091
  // the transcript live region as a streaming preview until the next thaw.
@@ -1112,8 +1099,8 @@ export class EventController {
1112
1099
  this.ctx.pendingTools.delete(toolCallId);
1113
1100
  }
1114
1101
  }
1115
- this.#backgroundToolCallIds = new Set(
1116
- Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
1102
+ this.#backgroundTaskCallIds = new Set(
1103
+ Array.from(this.#backgroundTaskCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
1117
1104
  );
1118
1105
  this.#readToolCallArgs.clear();
1119
1106
  this.#readToolCallAssistantComponents.clear();
@@ -1247,7 +1234,19 @@ export class EventController {
1247
1234
  this.ctx.lastAssistantUsage = undefined;
1248
1235
  this.ctx.rebuildChatFromMessages();
1249
1236
  this.ctx.statusLine.invalidate();
1250
- this.ctx.ui.requestRender();
1237
+ // When history collapses behind the summary divider, the frame
1238
+ // shrinks far below the committed row count; without clearing, the
1239
+ // differential renderer's "duplication, never loss" resync repaints
1240
+ // the whole collapsed transcript (welcome box included) BELOW the
1241
+ // stale pre-compaction scrollback. Compaction is an intentional
1242
+ // transcript replacement then — same as auto-handoff below. With
1243
+ // collapse disabled the rebuilt transcript keeps the full history,
1244
+ // so the resync handles it and scrollback stays.
1245
+ if (settings.get("display.collapseCompacted")) {
1246
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
1247
+ } else {
1248
+ this.ctx.ui.requestRender();
1249
+ }
1251
1250
  } else if (event.errorMessage) {
1252
1251
  this.ctx.showWarning(event.errorMessage);
1253
1252
  } else if (isHandoffAction) {
@@ -67,10 +67,12 @@ import { ExtensionDashboard } from "../components/extensions";
67
67
  import { HistorySearchComponent } from "../components/history-search";
68
68
  import { LoginDialogComponent } from "../components/login-dialog";
69
69
  import { LogoutAccountSelectorComponent } from "../components/logout-account-selector";
70
- import { ModelHubComponent, type ModelHubMode } from "../components/model-hub";
70
+ import { ModelHubComponent } from "../components/model-hub";
71
+ import { ModelPickerComponent } from "../components/model-picker";
71
72
  import { OAuthSelectorComponent } from "../components/oauth-selector";
72
73
  import { PluginSelectorComponent } from "../components/plugin-selector";
73
74
  import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
75
+ import { renderSegmentTrack } from "../components/segment-track";
74
76
  import { SessionSelectorComponent } from "../components/session-selector";
75
77
  import { SettingsSelectorComponent } from "../components/settings-selector";
76
78
  import { ToolExecutionComponent } from "../components/tool-execution";
@@ -451,12 +453,23 @@ export class SelectorController {
451
453
  this.ctx.rebuildChatFromMessages();
452
454
  this.ctx.ui.resetDisplay();
453
455
  break;
456
+ case "display.collapseCompacted":
457
+ // Rebuild swaps between the collapsed tail and the full inline
458
+ // history; full reset retires blocks already committed to native
459
+ // scrollback (mirrors cacheMissMarker).
460
+ this.ctx.rebuildChatFromMessages();
461
+ this.ctx.ui.resetDisplay();
462
+ break;
454
463
  case "tui.tight":
455
464
  setTuiTight(value as boolean);
456
465
  this.ctx.ui.invalidate();
457
466
  this.ctx.ui.requestRender();
458
467
  break;
459
468
 
469
+ case "tui.scrollbackRebuild":
470
+ this.ctx.ui.setScrollbackRebuild(value as boolean);
471
+ break;
472
+
460
473
  case "tui.renderMermaid":
461
474
  setMarkdownMermaidRendering(value as boolean);
462
475
  this.ctx.session.refreshBaseSystemPrompt().catch(err => {
@@ -586,7 +599,85 @@ export class SelectorController {
586
599
  }
587
600
 
588
601
  showModelSelector(options?: { temporaryOnly?: boolean }): void {
589
- this.#showModelHub({ mode: options?.temporaryOnly ? "pick" : "roles" });
602
+ if (options?.temporaryOnly) {
603
+ this.#showModelPicker();
604
+ return;
605
+ }
606
+ this.#showModelHub({});
607
+ }
608
+
609
+ /**
610
+ * Compact session-only model picker (alt+p / `/switch`): a floating
611
+ * bottom-anchored overlay over the transcript. The current model is
612
+ * highlighted and preselected; a leading `@` searches ctrl+p quick roles.
613
+ */
614
+ #showModelPicker(): void {
615
+ const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
616
+ const current = this.ctx.session.model;
617
+ const quickRoleOrder = this.ctx.settings.get("cycleOrder");
618
+ const quickRoleCycle = this.ctx.session.getRoleModelCycle(quickRoleOrder);
619
+ let overlayHandle: OverlayHandle | undefined;
620
+ let closed = false;
621
+ const done = () => {
622
+ if (closed) return;
623
+ closed = true;
624
+ overlayHandle?.hide();
625
+ this.focusActiveEditorArea();
626
+ this.ctx.ui.requestRender();
627
+ };
628
+ const picker = new ModelPickerComponent(
629
+ this.ctx.ui,
630
+ this.ctx.settings,
631
+ this.ctx.session.modelRegistry,
632
+ this.ctx.session.scopedModels,
633
+ {
634
+ onPick: async (model, selector) => {
635
+ try {
636
+ // Session-only: update agent state but don't persist the model to settings.
637
+ await this.ctx.session.setModelTemporary(model);
638
+ this.ctx.statusLine.invalidate();
639
+ this.ctx.updateEditorBorderColor();
640
+ const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
641
+ this.ctx.showStatus(`Session-only model: ${selector}. Use ${roleSelectorHint} or /model for roles.`);
642
+ done();
643
+ } catch (error) {
644
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
645
+ }
646
+ },
647
+ onPickRole: async entry => {
648
+ try {
649
+ await this.ctx.session.applyRoleModel(entry);
650
+ this.ctx.statusLine.invalidate();
651
+ this.ctx.updateEditorBorderColor();
652
+ this.ctx.showModelCycleTrack(
653
+ renderSegmentTrack(
654
+ quickRoleOrder.map(role => ({ label: role })),
655
+ quickRoleOrder.indexOf(entry.role),
656
+ ),
657
+ );
658
+ done();
659
+ } catch (error) {
660
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
661
+ }
662
+ },
663
+ onCancel: done,
664
+ },
665
+ {
666
+ currentContextTokens,
667
+ currentSelector: current ? `${current.provider}/${current.id}` : undefined,
668
+ quickRoles: quickRoleCycle?.models,
669
+ quickRoleOrder,
670
+ currentQuickRole: quickRoleCycle?.models[quickRoleCycle.currentIndex]?.role,
671
+ },
672
+ );
673
+ overlayHandle = this.ctx.ui.showOverlay(picker, {
674
+ anchor: "bottom-center",
675
+ width: "100%",
676
+ maxHeight: "100%",
677
+ margin: 0,
678
+ });
679
+ this.ctx.ui.setFocus(picker);
680
+ this.ctx.ui.requestRender();
590
681
  }
591
682
 
592
683
  /**
@@ -595,13 +686,13 @@ export class SelectorController {
595
686
  * untouched underneath. `initialProviderId` preselects a provider's sidebar
596
687
  * entry — used when reopening the hub after a /login round-trip.
597
688
  */
598
- #showModelHub(hubOptions: { mode: ModelHubMode; initialProviderId?: string }): void {
689
+ #showModelHub(hubOptions: { initialProviderId?: string }): void {
599
690
  const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
600
691
  let overlayHandle: OverlayHandle | undefined;
601
692
  let hub: ModelHubComponent | undefined;
602
693
  let closed = false;
603
694
  const done = () => {
604
- // Re-entrant guard: cancel paths (Esc, pick, login forward) may race;
695
+ // Re-entrant guard: cancel paths (Esc, login forward) may race;
605
696
  // the overlay must hide exactly once.
606
697
  if (closed) return;
607
698
  closed = true;
@@ -686,21 +777,6 @@ export class SelectorController {
686
777
  this.ctx.showError(error instanceof Error ? error.message : String(error));
687
778
  }
688
779
  },
689
- onPick: async (model, selector) => {
690
- try {
691
- // Session-only: update agent state but don't persist the model to settings.
692
- await this.ctx.session.setModelTemporary(model);
693
- this.ctx.statusLine.invalidate();
694
- this.ctx.updateEditorBorderColor();
695
- const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
696
- this.ctx.showStatus(
697
- `Session-only model: ${selector ?? model.id}. Use ${roleSelectorHint} or /model for roles.`,
698
- );
699
- done();
700
- } catch (error) {
701
- this.ctx.showError(error instanceof Error ? error.message : String(error));
702
- }
703
- },
704
780
  onLoginRequest: providerId => {
705
781
  done();
706
782
  void this.#loginThenReopenModelHub(providerId);
@@ -718,8 +794,6 @@ export class SelectorController {
718
794
  onCancel: () => done(),
719
795
  },
720
796
  {
721
- mode: hubOptions.mode,
722
- currentContextTokens,
723
797
  initialProviderId: hubOptions.initialProviderId,
724
798
  },
725
799
  );
@@ -738,7 +812,7 @@ export class SelectorController {
738
812
  async #loginThenReopenModelHub(providerId: string): Promise<void> {
739
813
  const succeeded = await this.#handleOAuthLogin(providerId);
740
814
  if (succeeded) {
741
- this.#showModelHub({ mode: "roles", initialProviderId: providerId });
815
+ this.#showModelHub({ initialProviderId: providerId });
742
816
  }
743
817
  }
744
818
 
@@ -3,12 +3,14 @@ import * as path from "node:path";
3
3
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
4
  import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
5
5
  import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" };
6
+ import tanContextSwitchPrompt from "../../prompts/system/tan-context-switch.md" with { type: "text" };
6
7
  import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
7
8
  import * as sdk from "../../sdk";
8
9
  import type { AgentSession } from "../../session/agent-session";
9
10
  import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE } from "../../session/messages";
10
11
  import { SessionManager } from "../../session/session-manager";
11
12
  import { createMCPProxyTools, createSubagentSettings } from "../../task/executor";
13
+ import { USER_TODO_EDIT_CUSTOM_TYPE } from "../../tools/todo";
12
14
  import type { InteractiveModeContext } from "../types";
13
15
 
14
16
  const TAN_LABEL_PREVIEW_LENGTH = 80;
@@ -66,6 +68,11 @@ export class TanCommandController {
66
68
  }
67
69
 
68
70
  const parentSessionId = session.sessionId;
71
+ // Providers route on `promptCacheKey ?? sessionId`, so the parent's live
72
+ // requests may cache under a pinned key that differs from its session id
73
+ // (the parent being itself a fork/tan). Mirror exactly what the parent
74
+ // populated the cache under — same rule as advisor and handoff calls.
75
+ const parentPromptCacheKey = session.agent.promptCacheKey ?? parentSessionId;
69
76
  const thinkingLevel = session.configuredThinkingLevel();
70
77
  const systemPrompt = [...session.systemPrompt];
71
78
  const toolNames = session.getActiveToolNames();
@@ -111,7 +118,7 @@ export class TanCommandController {
111
118
  systemPrompt,
112
119
  toolNames,
113
120
  providerSessionId: `${parentSessionId}:tan:${Snowflake.next()}`,
114
- providerPromptCacheKey: parentSessionId,
121
+ providerPromptCacheKey: parentPromptCacheKey,
115
122
  modelRegistry,
116
123
  authStorage: modelRegistry.authStorage,
117
124
  settings,
@@ -127,19 +134,51 @@ export class TanCommandController {
127
134
  disableExtensionDiscovery: true,
128
135
  });
129
136
  clone = created.session;
137
+ clone.sessionManager?.appendSessionInit?.({
138
+ systemPrompt: clone.systemPrompt ? clone.systemPrompt.join("\n\n") : systemPrompt.join("\n\n"),
139
+ task: trimmedWork,
140
+ tools: clone.getActiveToolNames ? clone.getActiveToolNames() : toolNames,
141
+ });
130
142
  const abortClone = () => {
131
143
  void clone?.abort();
132
144
  };
133
145
  signal.addEventListener("abort", abortClone, { once: true });
146
+ // The fork inherits the parent's todo list via session entries;
147
+ // its reminders would drag the tan back onto the parent's task.
148
+ // Clear runtime state and persist an empty edit so reloads agree.
149
+ clone.setTodoPhases([]);
150
+ cloneManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: [] });
151
+ const injectContextSwitch = () => {
152
+ clone?.agent.appendMessage({
153
+ role: "developer",
154
+ content: tanContextSwitchPrompt,
155
+ attribution: "agent",
156
+ timestamp: Date.now(),
157
+ });
158
+ };
159
+ // Compaction summarizes the fork notice away with the rest of the
160
+ // history, after which the clone re-adopts the parent's task as its
161
+ // own (the summary blends both). Re-inject after every successful
162
+ // compaction so the fork boundary survives summarization.
163
+ const unsubscribeCompaction = clone.subscribe(event => {
164
+ if (event.type === "auto_compaction_end" && event.result && !event.aborted) {
165
+ injectContextSwitch();
166
+ }
167
+ });
134
168
  try {
135
169
  if (signal.aborted) {
136
170
  abortClone();
137
171
  throw new Error("Aborted before execution");
138
172
  }
173
+ // Inject a context-switch developer message so the clone knows
174
+ // it is a tangential fork — its parent owns the prior conversation;
175
+ // this agent must focus exclusively on the user's request.
176
+ injectContextSwitch();
139
177
  await clone.prompt(trimmedWork, { attribution: "user" });
140
178
  await clone.waitForIdle();
141
179
  return extractAssistantText(clone.getLastAssistantMessage()) || "(no output)";
142
180
  } finally {
181
+ unsubscribeCompaction();
143
182
  signal.removeEventListener("abort", abortClone);
144
183
  }
145
184
  } finally {
@@ -665,6 +665,7 @@ export class InteractiveMode implements InteractiveModeContext {
665
665
  setMarkdownMermaidRendering(settings.get("tui.renderMermaid"));
666
666
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
667
667
  this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
668
+ this.ui.setScrollbackRebuild(settings.get("tui.scrollbackRebuild"));
668
669
  // OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's
669
670
  // capability (`TERMINAL.textSizing` defaults on for Kitty) so it stays off
670
671
  // unless the user opts in, and never emits raw escapes on other terminals.
@@ -1614,9 +1615,12 @@ export class InteractiveMode implements InteractiveModeContext {
1614
1615
  }
1615
1616
  }
1616
1617
  this.chatContainer.clear();
1617
- // Live display uses the compacted transcript tail; export/resume callers
1618
- // can still request the full inline compaction history.
1619
- const context = this.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true });
1618
+ // Live display collapses to the compacted transcript tail unless the
1619
+ // user opted into the full inline history; export/resume callers choose
1620
+ // their own mode.
1621
+ const context = this.viewSession.buildTranscriptSessionContext({
1622
+ collapseCompactedHistory: settings.get("display.collapseCompacted"),
1623
+ });
1620
1624
  this.renderSessionContext(context);
1621
1625
  for (const child of liveComponents) {
1622
1626
  this.chatContainer.addChild(child);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Contract: `--mode json` output stays linear in conversation size. Streaming
3
+ * `message_update` events must not re-serialize the in-progress message, and
4
+ * printed messages must not carry provider-opaque replay payloads (encrypted
5
+ * reasoning history), which previously produced multi-GB transcripts.
6
+ */
7
+ import { describe, expect, it } from "bun:test";
8
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
9
+ import type { AgentSessionEvent } from "../session/agent-session";
10
+ import { printableEvent } from "./print-mode";
11
+
12
+ const assistant: AssistantMessage = {
13
+ role: "assistant",
14
+ content: [{ type: "text", text: "hello" }],
15
+ api: "openai-responses",
16
+ provider: "openai",
17
+ model: "gpt-test",
18
+ usage: {} as AssistantMessage["usage"],
19
+ stopReason: "stop",
20
+ timestamp: 1,
21
+ providerPayload: { type: "openaiResponsesHistory", provider: "openai", dt: true, items: [{ big: "blob" }] },
22
+ };
23
+
24
+ describe("printableEvent", () => {
25
+ it("emits only the incremental delta for message_update", () => {
26
+ const event: AgentSessionEvent = {
27
+ type: "message_update",
28
+ message: assistant,
29
+ assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "hel", partial: assistant },
30
+ };
31
+ const printed = JSON.parse(JSON.stringify(printableEvent(event)));
32
+ expect(printed).toEqual({
33
+ type: "message_update",
34
+ assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "hel" },
35
+ });
36
+ });
37
+
38
+ it("drops the done-variant message snapshot from message_update", () => {
39
+ const event: AgentSessionEvent = {
40
+ type: "message_update",
41
+ message: assistant,
42
+ assistantMessageEvent: { type: "done", reason: "stop", message: assistant },
43
+ };
44
+ const printed = JSON.parse(JSON.stringify(printableEvent(event)));
45
+ expect(printed).toEqual({ type: "message_update", assistantMessageEvent: { type: "done", reason: "stop" } });
46
+ });
47
+
48
+ it("strips providerPayload from message_end but keeps the message content", () => {
49
+ const event: AgentSessionEvent = { type: "message_end", message: assistant };
50
+ const printed = JSON.parse(JSON.stringify(printableEvent(event))) as {
51
+ message: Record<string, unknown>;
52
+ };
53
+ expect(printed.message.providerPayload).toBeUndefined();
54
+ expect(printed.message.content).toEqual([{ type: "text", text: "hello" }]);
55
+ expect(printed.message.model).toBe("gpt-test");
56
+ });
57
+
58
+ it("strips providerPayload from every message in agent_end", () => {
59
+ const event: AgentSessionEvent = { type: "agent_end", messages: [assistant, assistant] };
60
+ const printed = JSON.parse(JSON.stringify(printableEvent(event))) as {
61
+ messages: Array<Record<string, unknown>>;
62
+ };
63
+ expect(printed.messages).toHaveLength(2);
64
+ for (const message of printed.messages) expect(message.providerPayload).toBeUndefined();
65
+ });
66
+
67
+ it("passes unrelated events through untouched", () => {
68
+ const event: AgentSessionEvent = { type: "notice", level: "info", message: "hi" };
69
+ expect(printableEvent(event)).toBe(event);
70
+ });
71
+ });
@@ -5,9 +5,10 @@
5
5
  * - `omp -p "prompt"` - text output
6
6
  * - `omp --mode json "prompt"` - JSON event stream
7
7
  */
8
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
9
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
9
10
  import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
10
- import type { AgentSession } from "../session/agent-session";
11
+ import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
11
12
  import { isSilentAbort } from "../session/messages";
12
13
  import { flushTelemetryExport } from "../telemetry-export";
13
14
  import { initializeExtensions } from "./runtime-init";
@@ -28,6 +29,54 @@ export interface PrintModeOptions {
28
29
  printThoughts?: boolean;
29
30
  }
30
31
 
32
+ /** Drop the provider-opaque replay payload (e.g. encrypted reasoning items) before printing. */
33
+ function stripProviderPayload<T extends AgentMessage>(message: T): T {
34
+ if (!("providerPayload" in message) || message.providerPayload === undefined) return message;
35
+ const { providerPayload: _providerPayload, ...rest } = message;
36
+ return rest as T;
37
+ }
38
+
39
+ /**
40
+ * Shape an event for `--mode json` output.
41
+ *
42
+ * Removes two classes of bloat so transcripts grow linearly with conversation
43
+ * size instead of quadratically (a single long turn used to re-serialize its
44
+ * whole in-progress message on every streamed delta, producing multi-GB logs):
45
+ * - `message_update` snapshots (`message`, `assistantMessageEvent.partial`,
46
+ * and the `done`/`error` payloads) are dropped; only the incremental delta
47
+ * is printed. The authoritative message follows in `message_end`.
48
+ * - `providerPayload` is transport-native replay state, opaque and useless
49
+ * outside this process.
50
+ */
51
+ export function printableEvent(event: AgentSessionEvent): unknown {
52
+ switch (event.type) {
53
+ case "message_update": {
54
+ const streamEvent = event.assistantMessageEvent;
55
+ if (streamEvent.type === "done" || streamEvent.type === "error") {
56
+ return {
57
+ type: "message_update",
58
+ assistantMessageEvent: { type: streamEvent.type, reason: streamEvent.reason },
59
+ };
60
+ }
61
+ const { partial: _partial, ...rest } = streamEvent;
62
+ return { type: "message_update", assistantMessageEvent: rest };
63
+ }
64
+ case "message_start":
65
+ case "message_end":
66
+ return { ...event, message: stripProviderPayload(event.message) };
67
+ case "turn_end":
68
+ return {
69
+ ...event,
70
+ message: stripProviderPayload(event.message),
71
+ toolResults: event.toolResults.map(stripProviderPayload),
72
+ };
73
+ case "agent_end":
74
+ return { ...event, messages: event.messages.map(stripProviderPayload) };
75
+ default:
76
+ return event;
77
+ }
78
+ }
79
+
31
80
  /**
32
81
  * Run in print (single-shot) mode.
33
82
  * Sends prompts to the agent and outputs the result.
@@ -58,7 +107,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
58
107
  session.subscribe(event => {
59
108
  // In JSON mode, output all events
60
109
  if (mode === "json") {
61
- process.stdout.write(`${JSON.stringify(event)}\n`);
110
+ process.stdout.write(`${JSON.stringify(printableEvent(event))}\n`);
62
111
  }
63
112
  });
64
113
 
@@ -92,6 +92,7 @@ export type SymbolKey =
92
92
  // Icons
93
93
  | "icon.model"
94
94
  | "icon.plan"
95
+ | "icon.prewalk"
95
96
  | "icon.goal"
96
97
  | "icon.pause"
97
98
  | "icon.loop"
@@ -225,6 +226,7 @@ export type SymbolKey =
225
226
  | "tool.debug"
226
227
  | "tool.mcp"
227
228
  | "tool.job"
229
+ | "tool.launch"
228
230
  | "tool.task"
229
231
  | "tool.todo"
230
232
  | "tool.memory"
@@ -300,6 +302,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
300
302
  // Icons
301
303
  "icon.model": "⬢",
302
304
  "icon.plan": "🗺",
305
+ "icon.prewalk": "🏃",
303
306
  "icon.goal": "🎯",
304
307
  "icon.pause": "⏸",
305
308
  "icon.loop": "↻",
@@ -433,6 +436,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
433
436
  "tool.debug": "🐞",
434
437
  "tool.mcp": "🔌",
435
438
  "tool.job": "⚙",
439
+ "tool.launch": "🚀",
436
440
  "tool.task": "⇶",
437
441
  "tool.todo": "☑",
438
442
  "tool.memory": "🧠",
@@ -560,6 +564,7 @@ const NERD_SYMBOLS: SymbolMap = {
560
564
  "icon.model": "\uec19",
561
565
  // pick:  | alt:  
562
566
  "icon.plan": "\uf2d2",
567
+ "icon.prewalk": "\uf29d",
563
568
  // pick: (nf-fa-bullseye) | alt: (nf-md-target) ◎ ⌖
564
569
  "icon.goal": "\uf140",
565
570
  // pick: (nf-fa-pause) | alt: ⏸ ||
@@ -742,6 +747,7 @@ const NERD_SYMBOLS: SymbolMap = {
742
747
  "tool.debug": "\uEAD8",
743
748
  "tool.mcp": "\uEB2D",
744
749
  "tool.job": "\uEBA2",
750
+ "tool.launch": "\uF135",
745
751
  "tool.task": "\uf4a0",
746
752
  "tool.todo": "\uEAB3",
747
753
  "tool.memory": "\uEACE",
@@ -816,6 +822,7 @@ const ASCII_SYMBOLS: SymbolMap = {
816
822
  // Icons
817
823
  "icon.model": "[M]",
818
824
  "icon.plan": "plan",
825
+ "icon.prewalk": "prewalk",
819
826
  "icon.goal": "goal",
820
827
  "icon.pause": "||",
821
828
  "icon.loop": "loop",
@@ -946,6 +953,7 @@ const ASCII_SYMBOLS: SymbolMap = {
946
953
  "tool.debug": "dbg",
947
954
  "tool.mcp": "<>",
948
955
  "tool.job": "job",
956
+ "tool.launch": "run",
949
957
  "tool.task": ">>>",
950
958
  "tool.todo": "[x]",
951
959
  "tool.memory": "mem",
@@ -1815,6 +1823,7 @@ export class Theme {
1815
1823
  return {
1816
1824
  model: this.#symbols["icon.model"],
1817
1825
  plan: this.#symbols["icon.plan"],
1826
+ prewalk: this.#symbols["icon.prewalk"],
1818
1827
  goal: this.#symbols["icon.goal"],
1819
1828
  pause: this.#symbols["icon.pause"],
1820
1829
  loop: this.#symbols["icon.loop"],