@oh-my-pi/pi-coding-agent 16.2.13 → 16.3.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 (166) hide show
  1. package/CHANGELOG.md +108 -7
  2. package/dist/cli.js +6033 -5982
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/settings-schema.d.ts +28 -5
  9. package/dist/types/edit/modes/patch.d.ts +11 -0
  10. package/dist/types/eval/agent-bridge.d.ts +5 -1
  11. package/dist/types/exec/bash-executor.d.ts +1 -0
  12. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  13. package/dist/types/extensibility/skills.d.ts +2 -1
  14. package/dist/types/mnemopi/state.d.ts +12 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  16. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  17. package/dist/types/modes/components/usage-row.d.ts +1 -1
  18. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  20. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  21. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  22. package/dist/types/modes/interactive-mode.d.ts +12 -7
  23. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  25. package/dist/types/modes/session-teardown.d.ts +63 -0
  26. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  27. package/dist/types/modes/theme/theme.d.ts +9 -4
  28. package/dist/types/modes/types.d.ts +2 -3
  29. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  30. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +9 -0
  32. package/dist/types/session/agent-session.d.ts +46 -13
  33. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  34. package/dist/types/session/session-loader.d.ts +5 -0
  35. package/dist/types/task/executor.d.ts +12 -5
  36. package/dist/types/task/parallel.d.ts +1 -0
  37. package/dist/types/task/render.test.d.ts +1 -0
  38. package/dist/types/thinking.d.ts +2 -0
  39. package/dist/types/tools/checkpoint.d.ts +8 -0
  40. package/dist/types/tools/eval-render.d.ts +1 -0
  41. package/dist/types/tools/fetch.d.ts +11 -1
  42. package/dist/types/tools/index.d.ts +3 -1
  43. package/dist/types/tools/irc.d.ts +1 -0
  44. package/dist/types/tools/output-meta.d.ts +7 -0
  45. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  46. package/dist/types/tools/path-utils.d.ts +16 -0
  47. package/dist/types/tools/render-utils.d.ts +4 -1
  48. package/dist/types/utils/git.d.ts +47 -2
  49. package/dist/types/web/search/provider.d.ts +7 -0
  50. package/dist/types/web/search/types.d.ts +1 -1
  51. package/package.json +12 -12
  52. package/src/advisor/config.ts +4 -2
  53. package/src/cli/bench-cli.ts +7 -2
  54. package/src/collab/guest.ts +94 -5
  55. package/src/collab/host.ts +80 -3
  56. package/src/collab/protocol.ts +9 -2
  57. package/src/commit/model-selection.ts +8 -2
  58. package/src/config/keybindings.ts +3 -1
  59. package/src/config/model-discovery.ts +66 -2
  60. package/src/config/model-registry.ts +7 -3
  61. package/src/config/model-resolver.ts +51 -23
  62. package/src/config/settings-schema.ts +44 -14
  63. package/src/edit/hashline/diff.ts +13 -2
  64. package/src/edit/index.ts +58 -8
  65. package/src/edit/modes/patch.ts +53 -18
  66. package/src/edit/streaming.ts +7 -6
  67. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  68. package/src/eval/agent-bridge.ts +15 -5
  69. package/src/exec/bash-executor.ts +7 -12
  70. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  71. package/src/extensibility/skills.ts +29 -6
  72. package/src/goals/guided-setup.ts +4 -3
  73. package/src/internal-urls/docs-index.generated.txt +1 -1
  74. package/src/lsp/client.ts +11 -14
  75. package/src/main.ts +38 -10
  76. package/src/mnemopi/state.ts +43 -7
  77. package/src/modes/acp/acp-agent.ts +1 -1
  78. package/src/modes/components/agent-hub.ts +10 -2
  79. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  80. package/src/modes/components/assistant-message.ts +16 -10
  81. package/src/modes/components/chat-transcript-builder.ts +11 -1
  82. package/src/modes/components/custom-editor.test.ts +20 -0
  83. package/src/modes/components/hook-selector.ts +44 -25
  84. package/src/modes/components/model-selector.ts +1 -1
  85. package/src/modes/components/status-line/component.test.ts +44 -0
  86. package/src/modes/components/status-line/component.ts +9 -1
  87. package/src/modes/components/status-line/segments.ts +3 -3
  88. package/src/modes/components/usage-row.ts +16 -2
  89. package/src/modes/controllers/command-controller.ts +6 -21
  90. package/src/modes/controllers/event-controller.ts +11 -9
  91. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  92. package/src/modes/controllers/input-controller.ts +16 -13
  93. package/src/modes/controllers/selector-controller.ts +3 -8
  94. package/src/modes/controllers/streaming-reveal.ts +75 -53
  95. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  96. package/src/modes/interactive-mode.ts +122 -79
  97. package/src/modes/rpc/rpc-client.ts +21 -0
  98. package/src/modes/rpc/rpc-mode.ts +197 -46
  99. package/src/modes/session-teardown.test.ts +219 -0
  100. package/src/modes/session-teardown.ts +82 -0
  101. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  102. package/src/modes/skill-command.ts +7 -20
  103. package/src/modes/theme/theme.ts +29 -15
  104. package/src/modes/types.ts +2 -3
  105. package/src/modes/utils/copy-targets.ts +12 -0
  106. package/src/modes/utils/ui-helpers.ts +19 -2
  107. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  108. package/src/prompts/advisor/system.md +1 -1
  109. package/src/prompts/agents/tester.md +6 -2
  110. package/src/prompts/skills/autoload.md +8 -0
  111. package/src/prompts/skills/user-invocation.md +11 -0
  112. package/src/prompts/steering/parent-irc.md +5 -0
  113. package/src/prompts/system/irc-incoming.md +2 -0
  114. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  115. package/src/prompts/system/plan-mode-approved.md +7 -10
  116. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  117. package/src/prompts/system/plan-mode-reference.md +3 -4
  118. package/src/prompts/system/rewind-report.md +6 -0
  119. package/src/prompts/system/subagent-system-prompt.md +3 -0
  120. package/src/prompts/tools/irc.md +2 -1
  121. package/src/prompts/tools/job.md +2 -2
  122. package/src/prompts/tools/rewind.md +3 -2
  123. package/src/prompts/tools/task.md +4 -0
  124. package/src/sdk.ts +18 -4
  125. package/src/session/agent-session.ts +660 -114
  126. package/src/session/exit-diagnostics.ts +202 -0
  127. package/src/session/session-context.ts +25 -13
  128. package/src/session/session-loader.ts +58 -25
  129. package/src/session/session-manager.ts +0 -1
  130. package/src/session/session-persistence.ts +30 -4
  131. package/src/session/settings-stream-fn.ts +14 -0
  132. package/src/slash-commands/builtin-registry.ts +35 -3
  133. package/src/system-prompt.ts +15 -1
  134. package/src/task/executor.ts +31 -8
  135. package/src/task/index.ts +31 -9
  136. package/src/task/isolation-runner.ts +18 -2
  137. package/src/task/parallel.ts +7 -2
  138. package/src/task/render.test.ts +121 -0
  139. package/src/task/render.ts +48 -2
  140. package/src/task/worktree.ts +12 -13
  141. package/src/task/yield-assembly.ts +8 -5
  142. package/src/thinking.ts +5 -0
  143. package/src/tools/ask.ts +188 -9
  144. package/src/tools/ast-edit.ts +7 -0
  145. package/src/tools/ast-grep.ts +11 -0
  146. package/src/tools/bash.ts +6 -30
  147. package/src/tools/checkpoint.ts +15 -1
  148. package/src/tools/eval-render.ts +15 -0
  149. package/src/tools/fetch.ts +82 -18
  150. package/src/tools/gh.ts +1 -1
  151. package/src/tools/grep.ts +45 -27
  152. package/src/tools/index.ts +3 -1
  153. package/src/tools/irc.ts +24 -9
  154. package/src/tools/output-meta.ts +50 -0
  155. package/src/tools/output-schema-validator.ts +152 -15
  156. package/src/tools/path-utils.ts +55 -10
  157. package/src/tools/read.ts +1 -1
  158. package/src/tools/render-utils.ts +5 -3
  159. package/src/tools/yield.ts +41 -1
  160. package/src/utils/commit-message-generator.ts +2 -2
  161. package/src/utils/git.ts +271 -29
  162. package/src/utils/thinking-display.ts +13 -0
  163. package/src/web/search/index.ts +9 -28
  164. package/src/web/search/provider.ts +26 -1
  165. package/src/web/search/providers/duckduckgo.ts +1 -1
  166. package/src/web/search/types.ts +5 -1
@@ -36,6 +36,7 @@ import {
36
36
  TUI,
37
37
  visibleWidth,
38
38
  } from "@oh-my-pi/pi-tui";
39
+ import { isInsideTerminalMultiplexer } from "@oh-my-pi/pi-tui/terminal-capabilities";
39
40
  import {
40
41
  APP_NAME,
41
42
  adjustHsv,
@@ -65,6 +66,7 @@ import type {
65
66
  ExtensionWidgetOptions,
66
67
  } from "../extensibility/extensions";
67
68
  import type { CompactOptions } from "../extensibility/extensions/types";
69
+ import type { Skill } from "../extensibility/skills";
68
70
  import { loadSlashCommands } from "../extensibility/slash-commands";
69
71
  import { type GuidedGoalMessage, runGuidedGoalTurn } from "../goals/guided-setup";
70
72
  import type { Goal, GoalModeState } from "../goals/state";
@@ -88,7 +90,12 @@ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compa
88
90
  type: "text",
89
91
  };
90
92
  import type { AgentRegistry } from "../registry/agent-registry";
91
- import type { AgentSession, AgentSessionEvent, ResolvedRoleModel } from "../session/agent-session";
93
+ import {
94
+ type AgentSession,
95
+ type AgentSessionEvent,
96
+ type ResolvedRoleModel,
97
+ SHUTDOWN_CONSOLIDATE_BUDGET_MS,
98
+ } from "../session/agent-session";
92
99
  import type { CompactMode } from "../session/compact-modes";
93
100
  import { HistoryStorage } from "../session/history-storage";
94
101
  import type { SessionContext } from "../session/session-context";
@@ -155,6 +162,7 @@ import { OAuthManualInputManager } from "./oauth-manual-input";
155
162
  import { countRunningSubagentBadgeAgents, getRunningSubagentBadgeRegistry } from "./running-subagent-badge";
156
163
  import type { ObservableSession } from "./session-observer-registry";
157
164
  import { SessionObserverRegistry } from "./session-observer-registry";
165
+ import { createSessionTeardown, type SessionTeardown } from "./session-teardown";
158
166
  import { runProviderSetupWizard } from "./setup-wizard/lazy";
159
167
  import { interruptHint } from "./shared";
160
168
  import { clearMermaidCache } from "./theme/mermaid-cache";
@@ -328,8 +336,10 @@ const MODEL_CYCLE_TRACK_CLEAR_MS = 4000;
328
336
 
329
337
  /**
330
338
  * Build the anchored subagent HUD block: a bold accent "Subagents" header plus
331
- * one hooked row per running agent in the same `Id: description` shape the
339
+ * one tree row per running agent in the same `Id: description` shape the
332
340
  * inline task rows use (muted task preview when no description was given).
341
+ * Layout mirrors the Todos HUD exactly: unindented header, then
342
+ * `renderTreeList` rows (dim connectors) shifted right by one space.
333
343
  * Only detached background spawns are listed: a sync task call blocks the
334
344
  * parent turn and its inline tool block already renders progress live, and
335
345
  * eval `agent()` spawns are rendered by their own eval cell tree.
@@ -341,29 +351,32 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
341
351
  );
342
352
  if (running.length === 0) return [];
343
353
 
344
- const indent = " ";
345
- const hook = theme.tree.hook;
346
354
  const dot = theme.styledSymbol("status.done", "accent");
347
- const lines = ["", indent + theme.bold(theme.fg("accent", "Subagents"))];
348
- running.forEach((session, index) => {
349
- const prefix = `${indent}${index === 0 ? hook : " "} `;
350
- const displayId = formatTaskId(session.id);
351
- let line = `${prefix}${dot} ${theme.fg("accent", theme.bold(displayId))}`;
352
- const description = session.description?.trim() || session.progress?.description?.trim();
353
- if (description) {
354
- const budget = Math.max(TRUNCATE_LENGTHS.SHORT, columns - visibleWidth(prefix) - visibleWidth(displayId) - 6);
355
- line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(description), budget))}`;
356
- } else {
357
- // No spawn description: fall back to a muted task preview, same as
358
- // the inline task rows when a row has no label.
359
- const taskPreview = session.progress?.task?.trim();
360
- if (taskPreview) {
361
- line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(taskPreview), TRUNCATE_LENGTHS.SHORT))}`;
362
- }
363
- }
364
- lines.push(line);
365
- });
366
- return lines;
355
+ const rows = renderTreeList(
356
+ {
357
+ items: running,
358
+ expanded: true,
359
+ renderItem: session => {
360
+ const displayId = formatTaskId(session.id);
361
+ let line = `${dot} ${theme.fg("accent", theme.bold(displayId))}`;
362
+ const description = session.description?.trim() || session.progress?.description?.trim();
363
+ if (description) {
364
+ const budget = Math.max(TRUNCATE_LENGTHS.SHORT, columns - visibleWidth(displayId) - 10);
365
+ line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(description), budget))}`;
366
+ } else {
367
+ // No spawn description: fall back to a muted task preview, same as
368
+ // the inline task rows when a row has no label.
369
+ const taskPreview = session.progress?.task?.trim();
370
+ if (taskPreview) {
371
+ line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(taskPreview), TRUNCATE_LENGTHS.SHORT))}`;
372
+ }
373
+ }
374
+ return line;
375
+ },
376
+ },
377
+ theme,
378
+ );
379
+ return ["", theme.bold(theme.fg("accent", "Subagents")), ...rows.map(line => ` ${line}`)];
367
380
  }
368
381
 
369
382
  export class InteractiveMode implements InteractiveModeContext {
@@ -373,7 +386,6 @@ export class InteractiveMode implements InteractiveModeContext {
373
386
  keybindings: KeybindingsManager;
374
387
  agent: Agent;
375
388
  historyStorage?: HistoryStorage;
376
- titleSystemPrompt?: string;
377
389
 
378
390
  ui: TUI;
379
391
  chatContainer: TranscriptContainer;
@@ -478,13 +490,14 @@ export class InteractiveMode implements InteractiveModeContext {
478
490
  lastStatusSpacer: Spacer | undefined = undefined;
479
491
  lastStatusText: Text | undefined = undefined;
480
492
  fileSlashCommands: Set<string> = new Set();
481
- skillCommands: Map<string, string> = new Map();
493
+ skillCommands: Map<string, Skill> = new Map();
482
494
  oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
483
495
  collabHost?: CollabHost;
484
496
  collabGuest?: CollabGuestLink;
485
497
 
486
498
  #pendingSlashCommands: SlashCommand[] = [];
487
499
  #cleanupUnsubscribe?: () => void;
500
+ #signalTeardown?: SessionTeardown;
488
501
  readonly #version: string;
489
502
  readonly #changelogMarkdown: string | undefined;
490
503
  #planModePreviousTools: string[] | undefined;
@@ -583,7 +596,6 @@ export class InteractiveMode implements InteractiveModeContext {
583
596
  lspServers: LspStartupServerInfo[] | undefined = undefined,
584
597
  mcpManager?: MCPManager,
585
598
  eventBus?: EventBus,
586
- titleSystemPrompt?: string,
587
599
  ) {
588
600
  this.session = session;
589
601
  this.sessionManager = session.sessionManager;
@@ -596,7 +608,6 @@ export class InteractiveMode implements InteractiveModeContext {
596
608
  this.lspServers = lspServers;
597
609
  this.mcpManager = mcpManager;
598
610
  this.#eventBus = eventBus;
599
- this.titleSystemPrompt = titleSystemPrompt;
600
611
  if (eventBus) {
601
612
  this.#eventBusUnsubscribers.push(
602
613
  eventBus.on(LSP_STARTUP_EVENT_CHANNEL, data => {
@@ -645,7 +656,7 @@ export class InteractiveMode implements InteractiveModeContext {
645
656
  this.#syncEditorMaxHeight();
646
657
  this.#resizeHandler = () => {
647
658
  this.#syncEditorMaxHeight();
648
- this.updateEditorTopBorder();
659
+ this.ui.requestRender();
649
660
  };
650
661
  process.stdout.on("resize", this.#resizeHandler);
651
662
  try {
@@ -662,6 +673,12 @@ export class InteractiveMode implements InteractiveModeContext {
662
673
  this.editorContainer.addChild(this.editor);
663
674
  this.statusLine = new StatusLineComponent(session);
664
675
  this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled);
676
+ // Lazy provider — the top border rebuild coalesces to at most one
677
+ // invocation per painted frame instead of firing on every session event
678
+ // (#4145). The TUI throttles renders at ~30fps, so a long-running eval
679
+ // spraying events no longer runs `getTopBorder` synchronously in the
680
+ // hot path where the render never gets to paint the result.
681
+ this.editor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
665
682
 
666
683
  this.hideThinkingBlock = settings.get("hideThinkingBlock");
667
684
  this.proseOnlyThinking = settings.get("proseOnlyThinking");
@@ -685,7 +702,7 @@ export class InteractiveMode implements InteractiveModeContext {
685
702
  if (settings.get("skills.enableSkillCommands")) {
686
703
  for (const skill of this.session.skills) {
687
704
  const commandName = `skill:${skill.name}`;
688
- this.skillCommands.set(commandName, skill.filePath);
705
+ this.skillCommands.set(commandName, skill);
689
706
  skillCommandList.push({ name: commandName, description: skill.description });
690
707
  }
691
708
  }
@@ -768,8 +785,29 @@ export class InteractiveMode implements InteractiveModeContext {
768
785
 
769
786
  this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
770
787
 
771
- // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
772
- this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
788
+ // Route SIGINT/SIGTERM/SIGHUP/uncaughtException through the same teardown
789
+ // the TUI Ctrl+C keypress path performs: persist the in-progress editor
790
+ // draft for `--resume`, then dispose the session (which emits the extension
791
+ // `session_shutdown` event, cancels the owned async job manager, disposes
792
+ // eval kernels, releases owned browser tabs, and closes the session
793
+ // manager). Without this callback a real kernel signal would drop the
794
+ // draft, skip the `session_shutdown` contract from `shared-events.ts`,
795
+ // and orphan background bash/task processes (issue #4080). The registered
796
+ // callback and `shutdown()` share one promise-memoized teardown, so a
797
+ // signal arriving mid-Ctrl+C no-ops instead of racing a second dispose.
798
+ this.#signalTeardown = createSessionTeardown({
799
+ getDraftText: () => this.editor.getText(),
800
+ beginDispose: () => this.session.beginDispose(),
801
+ saveDraft: text => this.sessionManager.saveDraft(text),
802
+ disposeSession: reason =>
803
+ this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS, reason }),
804
+ });
805
+ // Forward the postmortem reason (SIGTERM/SIGHUP/uncaughtException/…) so the
806
+ // persisted `session_exit` diagnostic carries the real trigger. Postmortem
807
+ // runs callbacks in REVERSE registration order — this callback (registered
808
+ // after the AgentSession constructor's `agent-session:<id>` recorder) runs
809
+ // FIRST and its dispose() would otherwise persist the generic "dispose".
810
+ this.#cleanupUnsubscribe = postmortem.register("session-teardown", reason => this.#signalTeardown!(reason));
773
811
 
774
812
  // Wire the report_tool_issue consent gate to the Yes/No dialog popup.
775
813
  // The handler is process-global — subagent tools (which can't reach
@@ -958,13 +996,22 @@ export class InteractiveMode implements InteractiveModeContext {
958
996
  );
959
997
  // Set up theme file watcher
960
998
  this.#eventBusUnsubscribers.push(
961
- onThemeChange(() => {
999
+ onThemeChange(event => {
962
1000
  this.#clearWorkingMessageAccentCache();
963
1001
  clearRenderCache();
964
1002
  clearMermaidCache();
965
1003
  this.ui.invalidate();
966
1004
  this.updateEditorBorderColor();
967
- this.ui.requestRender();
1005
+ if (event.ephemeral || isInsideTerminalMultiplexer()) {
1006
+ // Theme previews and multiplexer panes cannot safely replace native
1007
+ // scrollback: previews must stay non-destructive, and multiplexers
1008
+ // suppress ED3 so a forced replay would duplicate transcript history.
1009
+ this.ui.requestRender();
1010
+ return;
1011
+ }
1012
+ // Rows already committed to native scrollback are immutable; replay them
1013
+ // after a theme swap so a reader scrolled up sees the same palette.
1014
+ this.ui.requestRender(true, { clearScrollback: true });
968
1015
  }),
969
1016
  );
970
1017
 
@@ -975,21 +1022,24 @@ export class InteractiveMode implements InteractiveModeContext {
975
1022
  onTerminalAppearanceChange(mode);
976
1023
  });
977
1024
 
978
- // Set up git branch watcher
1025
+ // A branch change (checkout, worktree switch, `git switch`) invalidates
1026
+ // the status-line git segments; the lazy top-border provider picks up
1027
+ // the fresh branch on the next painted frame.
979
1028
  this.statusLine.watchBranch(() => {
980
- this.updateEditorTopBorder();
981
1029
  this.ui.requestRender();
982
1030
  });
983
-
984
- // Initial top border update
985
- this.updateEditorTopBorder();
986
1031
  }
987
1032
 
988
- /** Reload the title-generation system prompt override for the provided working directory. */
1033
+ /** Reload the title-generation system prompt override for the provided working
1034
+ * directory and stash it on the session so first-input titling
1035
+ * ({@link input-controller}) and replan-driven refresh
1036
+ * ({@link AgentSession.#refreshTitleAfterReplan}) share one source
1037
+ * ({@link discoverTitleSystemPromptFile}; issue #3734). */
989
1038
  async refreshTitleSystemPrompt(cwd?: string): Promise<void> {
990
1039
  const basePath = cwd ?? this.sessionManager.getCwd();
991
1040
  const titleSystemPromptSource = discoverTitleSystemPromptFile(basePath);
992
- this.titleSystemPrompt = await resolvePromptInput(titleSystemPromptSource, "title system prompt");
1041
+ const resolved = await resolvePromptInput(titleSystemPromptSource, "title system prompt");
1042
+ this.session.setTitleSystemPrompt(resolved);
993
1043
  }
994
1044
 
995
1045
  /** Reload slash commands and autocomplete for the provided working directory. */
@@ -1059,7 +1109,7 @@ export class InteractiveMode implements InteractiveModeContext {
1059
1109
  await this.session.refreshSshTool({ activateIfAvailable: true });
1060
1110
  setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
1061
1111
  this.statusLine.invalidate();
1062
- this.updateEditorTopBorder();
1112
+ this.ui.requestRender();
1063
1113
  }
1064
1114
 
1065
1115
  async getUserInput(): Promise<SubmittedUserInput> {
@@ -1200,7 +1250,6 @@ export class InteractiveMode implements InteractiveModeContext {
1200
1250
  this.loopLimit = undefined;
1201
1251
  this.#cancelLoopAutoSubmit();
1202
1252
  this.statusLine.setLoopModeStatus(undefined);
1203
- this.updateEditorTopBorder();
1204
1253
  this.ui.requestRender();
1205
1254
  if (wasEnabled) {
1206
1255
  this.showStatus(message);
@@ -1231,7 +1280,6 @@ export class InteractiveMode implements InteractiveModeContext {
1231
1280
  this.loopPrompt = undefined;
1232
1281
  this.loopLimit = createLoopLimitRuntime(parsed.limit);
1233
1282
  this.statusLine.setLoopModeStatus({ enabled: true });
1234
- this.updateEditorTopBorder();
1235
1283
  this.ui.requestRender();
1236
1284
  const limitSuffix = parsed.limit ? ` Limited to ${describeLoopLimit(parsed.limit)}.` : "";
1237
1285
  const remainingSuffix = this.loopLimit ? ` ${describeLoopLimitRuntime(this.loopLimit)}.` : "";
@@ -1452,7 +1500,6 @@ export class InteractiveMode implements InteractiveModeContext {
1452
1500
  const base = this.editor.borderColor;
1453
1501
  this.editor.borderColor = (str: string) => `\x1b[2m${base(str)}\x1b[22m`;
1454
1502
  }
1455
- this.updateEditorTopBorder();
1456
1503
  this.ui.requestRender();
1457
1504
  }
1458
1505
 
@@ -1469,13 +1516,7 @@ export class InteractiveMode implements InteractiveModeContext {
1469
1516
  }
1470
1517
  const count = countRunningSubagentBadgeAgents(registry);
1471
1518
  this.statusLine.setSubagentCount(count);
1472
- this.updateEditorTopBorder();
1473
- }
1474
-
1475
- updateEditorTopBorder(): void {
1476
- const availableWidth = this.editor.getTopBorderAvailableWidth(this.ui.terminal.columns);
1477
- const topBorder = this.statusLine.getTopBorder(availableWidth);
1478
- this.editor.setTopBorder(topBorder);
1519
+ this.ui.requestRender();
1479
1520
  }
1480
1521
 
1481
1522
  rebuildChatFromMessages(): void {
@@ -1806,7 +1847,6 @@ export class InteractiveMode implements InteractiveModeContext {
1806
1847
  }
1807
1848
  : undefined;
1808
1849
  this.statusLine.setPlanModeStatus(status);
1809
- this.updateEditorTopBorder();
1810
1850
  this.ui.requestRender();
1811
1851
  }
1812
1852
 
@@ -1816,7 +1856,6 @@ export class InteractiveMode implements InteractiveModeContext {
1816
1856
  ? { enabled: this.goalModeEnabled, paused: this.goalModePaused }
1817
1857
  : undefined;
1818
1858
  this.statusLine.setGoalModeStatus(status);
1819
- this.updateEditorTopBorder();
1820
1859
  this.ui.requestRender();
1821
1860
  }
1822
1861
 
@@ -2579,11 +2618,10 @@ export class InteractiveMode implements InteractiveModeContext {
2579
2618
  this.session.clearPlanInternalAbortPending();
2580
2619
  }
2581
2620
 
2582
- // Tool restoration runs on every path the plan mode tools must be
2583
- // retired regardless of whether the synthetic prompt fires.
2584
- if (previousTools.length > 0) {
2585
- await this.session.setActiveToolsByName(previousTools);
2586
- }
2621
+ // Restore the execution tool set, but force-enable `read`: approved-plan
2622
+ // prompts now require loading the durable local:// plan file before work.
2623
+ const executionTools = previousTools.includes("read") ? previousTools : [...previousTools, "read"];
2624
+ await this.session.setActiveToolsByName(executionTools);
2587
2625
  this.session.setPlanReferencePath(options.planFilePath);
2588
2626
 
2589
2627
  // Resolve the deferred plan-approval model transition. On the compact path
@@ -2631,7 +2669,6 @@ export class InteractiveMode implements InteractiveModeContext {
2631
2669
  // plan-approved prompt is the source of the reference injection.
2632
2670
  this.session.markPlanReferenceSent();
2633
2671
  const planModePrompt = prompt.render(planModeApprovedPrompt, {
2634
- planContent,
2635
2672
  planFilePath: options.planFilePath,
2636
2673
  contextPreserved: options.preserveContext === true,
2637
2674
  });
@@ -3105,10 +3142,13 @@ export class InteractiveMode implements InteractiveModeContext {
3105
3142
 
3106
3143
  if (choice === "Approve and execute" || choice === "Approve and compact context" || choice === keepContextLabel) {
3107
3144
  try {
3108
- // Prefer in-overlay edits (already in memory) over a disk re-read; the
3109
- // `onPlanEdited` write is fire-and-forget, so reading the file here could
3110
- // race ahead of it.
3145
+ // Prefer in-overlay edits (already in memory) over a disk re-read. The
3146
+ // overlay mirrors edits as they happen, and approval awaits one final
3147
+ // write so the durable plan file and synthetic prompt carry the same text.
3111
3148
  const latestPlanContent = editedContent ?? (await this.#readPlanFile(planFilePath));
3149
+ if (editedContent !== undefined) {
3150
+ await Bun.write(this.#resolvePlanFilePath(planFilePath), editedContent);
3151
+ }
3112
3152
  if (!latestPlanContent) {
3113
3153
  this.showError(`Plan file not found at ${planFilePath}`);
3114
3154
  return;
@@ -3277,24 +3317,28 @@ export class InteractiveMode implements InteractiveModeContext {
3277
3317
  if (this.#isShuttingDown) return;
3278
3318
  this.#isShuttingDown = true;
3279
3319
 
3280
- // Snapshot the editor before any teardown empties it. Persisting the draft
3281
- // here covers Ctrl+D shutdown with non-empty text; for /exit the editor is
3282
- // already cleared so saveDraft("") just removes any stale sidecar.
3283
- const draftText = this.editor.getText();
3284
-
3285
- // Flush pending session writes before shutdown
3286
- await this.sessionManager.flush();
3287
- try {
3288
- await this.sessionManager.saveDraft(draftText);
3289
- } catch (err) {
3290
- logger.warn("Failed to save session draft", { error: String(err) });
3291
- }
3292
3320
  this.#btwController.dispose();
3293
3321
  this.#omfgController.dispose();
3294
3322
  this.#focusController.dispose();
3295
3323
 
3296
- // Emit shutdown event to hooks
3297
- await this.session.dispose();
3324
+ // Surface an explicit "Closing session…" line so the user sees a reason
3325
+ // for the pause while `session.dispose()` flushes memory consolidate and
3326
+ // other cleanups (issue #3641). The await on the next line yields the
3327
+ // event loop, giving requestRender() a tick to paint the status before
3328
+ // dispose blocks.
3329
+ this.showStatus("Closing session…");
3330
+
3331
+ // Persist the draft and dispose the session through the shared teardown
3332
+ // so a signal that arrives mid-shutdown cannot fire a second dispose.
3333
+ // The teardown is a promise-memoized singleton; whichever path calls it
3334
+ // first runs the work, the other awaits the same settled promise.
3335
+ // The teardown is registered lazily in `init()` — a `/exit` reached
3336
+ // before `init()` completed falls back to a direct dispose.
3337
+ if (this.#signalTeardown) {
3338
+ await this.#signalTeardown();
3339
+ } else {
3340
+ await this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
3341
+ }
3298
3342
 
3299
3343
  // Do not force a final render during teardown: disposed session/UI state can
3300
3344
  // collapse to an empty frame, clearing the viewport and leaving the parent
@@ -3348,6 +3392,7 @@ export class InteractiveMode implements InteractiveModeContext {
3348
3392
  this.ui.requestRender();
3349
3393
  };
3350
3394
  nextEditor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor));
3395
+ nextEditor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
3351
3396
  nextEditor.setMaxHeight(this.#computeEditorMaxHeight());
3352
3397
  if (this.historyStorage) {
3353
3398
  nextEditor.setHistoryStorage(this.historyStorage);
@@ -3367,7 +3412,6 @@ export class InteractiveMode implements InteractiveModeContext {
3367
3412
  });
3368
3413
 
3369
3414
  this.updateEditorBorderColor();
3370
- this.updateEditorTopBorder();
3371
3415
  this.ui.requestRender();
3372
3416
  }
3373
3417
 
@@ -3767,7 +3811,6 @@ export class InteractiveMode implements InteractiveModeContext {
3767
3811
  } else {
3768
3812
  this.#cleanupMicAnimation();
3769
3813
  }
3770
- this.updateEditorTopBorder();
3771
3814
  this.ui.requestRender();
3772
3815
  },
3773
3816
  });
@@ -225,12 +225,21 @@ export class RpcClient {
225
225
 
226
226
  /**
227
227
  * Start the RPC agent process.
228
+ *
229
+ * Safe to call again after {@link stop} on the same instance: a fresh
230
+ * {@link AbortController} is minted for each start, and any failure after
231
+ * the child spawn kills the child and clears internal state so callers may
232
+ * retry without leaking processes.
228
233
  */
229
234
  async start(): Promise<void> {
230
235
  if (this.#process) {
231
236
  throw new Error("Client already started");
232
237
  }
233
238
 
239
+ // Mint a fresh controller so a previous stop()'s abort does not
240
+ // short-circuit the new stdout reader (issue #4079).
241
+ this.#abortController = new AbortController();
242
+
234
243
  const cliPath = this.options.cliPath ?? "dist/cli.js";
235
244
  const args = ["--mode", "rpc"];
236
245
 
@@ -304,6 +313,18 @@ export class RpcClient {
304
313
  if (this.#customTools.length > 0) {
305
314
  await this.setCustomTools(this.#customTools);
306
315
  }
316
+ } catch (err) {
317
+ // Startup failed after we spawned the child. Kill it and clear
318
+ // state so the caller (or a retry via start() again) does not
319
+ // leak the abandoned process (issue #4079).
320
+ try {
321
+ this.#process?.kill();
322
+ } catch {
323
+ // best-effort cleanup
324
+ }
325
+ this.#abortController.abort();
326
+ this.#process = null;
327
+ throw err;
307
328
  } finally {
308
329
  clearTimeout(readyTimeout);
309
330
  }