@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.6

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 (143) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/cli.js +3616 -3676
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/exec/bash-executor.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  17. package/dist/types/launch/spawn-options.d.ts +10 -0
  18. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  19. package/dist/types/modes/components/status-line/component.d.ts +10 -3
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  21. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  22. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  23. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  24. package/dist/types/modes/components/usage-row.d.ts +1 -1
  25. package/dist/types/modes/interactive-mode.d.ts +2 -0
  26. package/dist/types/modes/print-mode.d.ts +4 -0
  27. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  28. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  29. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  30. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  31. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  32. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  33. package/dist/types/modes/types.d.ts +2 -0
  34. package/dist/types/sdk.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +42 -6
  36. package/dist/types/session/messages.d.ts +6 -0
  37. package/dist/types/session/session-paths.d.ts +21 -4
  38. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  39. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  40. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  42. package/dist/types/tools/gh.d.ts +5 -3
  43. package/dist/types/tools/hub/index.d.ts +1 -1
  44. package/dist/types/tools/hub/jobs.d.ts +1 -0
  45. package/dist/types/tools/hub/types.d.ts +2 -0
  46. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  47. package/dist/types/tools/xdev.d.ts +5 -3
  48. package/dist/types/utils/git.d.ts +33 -0
  49. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  50. package/dist/types/vibe/runtime.d.ts +23 -0
  51. package/dist/types/web/search/providers/codex.d.ts +1 -1
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +150 -0
  54. package/src/advisor/advise-tool.ts +4 -0
  55. package/src/advisor/runtime.ts +38 -14
  56. package/src/async/job-manager.ts +3 -0
  57. package/src/cli/bench-cli.ts +8 -2
  58. package/src/cli/dry-balance-cli.ts +1 -0
  59. package/src/config/model-registry.ts +89 -8
  60. package/src/config/model-resolver.ts +78 -14
  61. package/src/config/models-config-schema.ts +1 -0
  62. package/src/config/settings.ts +3 -1
  63. package/src/dap/client.ts +168 -1
  64. package/src/dap/config.ts +51 -1
  65. package/src/dap/session.ts +575 -234
  66. package/src/dap/types.ts +6 -5
  67. package/src/discovery/agents.ts +2 -2
  68. package/src/exec/bash-executor.ts +68 -8
  69. package/src/extensibility/extensions/load-errors.ts +13 -0
  70. package/src/extensibility/extensions/runner.ts +6 -4
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  72. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  73. package/src/launch/broker.ts +34 -31
  74. package/src/launch/client.ts +7 -1
  75. package/src/launch/spawn-options.test.ts +31 -0
  76. package/src/launch/spawn-options.ts +17 -0
  77. package/src/lsp/types.ts +5 -1
  78. package/src/main.ts +25 -4
  79. package/src/mcp/transports/stdio.test.ts +9 -1
  80. package/src/modes/components/ask-dialog.ts +137 -73
  81. package/src/modes/components/chat-transcript-builder.ts +10 -1
  82. package/src/modes/components/status-line/component.ts +57 -3
  83. package/src/modes/components/status-line/segments.ts +20 -3
  84. package/src/modes/components/status-line/types.ts +3 -1
  85. package/src/modes/components/tool-execution.ts +5 -0
  86. package/src/modes/components/transcript-container.ts +23 -122
  87. package/src/modes/components/tree-selector.ts +10 -4
  88. package/src/modes/components/usage-row.ts +17 -1
  89. package/src/modes/controllers/command-controller.ts +41 -1
  90. package/src/modes/controllers/event-controller.ts +7 -3
  91. package/src/modes/controllers/input-controller.ts +1 -1
  92. package/src/modes/controllers/selector-controller.ts +29 -8
  93. package/src/modes/interactive-mode.ts +102 -60
  94. package/src/modes/noninteractive-dispose.test.ts +14 -1
  95. package/src/modes/print-mode.ts +81 -9
  96. package/src/modes/rpc/rpc-client.ts +76 -35
  97. package/src/modes/rpc/rpc-frame.ts +156 -0
  98. package/src/modes/rpc/rpc-input.ts +38 -0
  99. package/src/modes/rpc/rpc-mode.ts +11 -4
  100. package/src/modes/setup-wizard/index.ts +2 -0
  101. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  102. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  103. package/src/modes/types.ts +2 -0
  104. package/src/modes/utils/ui-helpers.ts +9 -3
  105. package/src/prompts/system/workflow-notice.md +89 -74
  106. package/src/prompts/tools/browser.md +3 -2
  107. package/src/prompts/tools/debug.md +2 -7
  108. package/src/prompts/tools/github.md +6 -1
  109. package/src/prompts/tools/hub.md +4 -2
  110. package/src/prompts/tools/task-async-contract.md +1 -0
  111. package/src/prompts/tools/task.md +9 -2
  112. package/src/sdk.ts +38 -6
  113. package/src/session/agent-session.ts +484 -188
  114. package/src/session/messages.test.ts +91 -0
  115. package/src/session/messages.ts +248 -110
  116. package/src/session/session-manager.ts +34 -3
  117. package/src/session/session-paths.ts +38 -9
  118. package/src/slash-commands/builtin-registry.ts +1 -0
  119. package/src/task/executor.ts +104 -33
  120. package/src/task/index.ts +46 -9
  121. package/src/task/worktree.ts +10 -0
  122. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  123. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  124. package/src/tools/browser/cmux/socket-client.ts +139 -3
  125. package/src/tools/browser/run-cancellation.ts +4 -0
  126. package/src/tools/browser/tab-protocol.ts +2 -0
  127. package/src/tools/browser/tab-supervisor.ts +21 -11
  128. package/src/tools/browser/tab-worker.ts +199 -33
  129. package/src/tools/debug.ts +3 -0
  130. package/src/tools/gh.ts +40 -2
  131. package/src/tools/hub/index.ts +4 -1
  132. package/src/tools/hub/jobs.ts +42 -1
  133. package/src/tools/hub/types.ts +2 -0
  134. package/src/tools/tool-timeouts.ts +1 -1
  135. package/src/tools/xdev.ts +11 -4
  136. package/src/tools/yield.ts +4 -1
  137. package/src/utils/git.ts +237 -0
  138. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  139. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  140. package/src/vibe/runtime.ts +50 -0
  141. package/src/web/search/index.ts +9 -5
  142. package/src/web/search/providers/codex.ts +195 -99
  143. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -8,7 +8,6 @@ import {
8
8
  type Agent,
9
9
  AgentBusyError,
10
10
  type AgentMessage,
11
- type AgentToolResult,
12
11
  EventLoopKeepalive,
13
12
  ThinkingLevel,
14
13
  } from "@oh-my-pi/pi-agent-core";
@@ -89,12 +88,7 @@ import {
89
88
  MCP_CONNECTION_STATUS_EVENT_CHANNEL,
90
89
  type McpConnectionStatusEvent,
91
90
  } from "../mcp/startup-events";
92
- import {
93
- humanizePlanTitle,
94
- type PlanApprovalDetails,
95
- resolveApprovedPlan,
96
- resolvePlanTitle,
97
- } from "../plan-mode/approved-plan";
91
+ import { humanizePlanTitle, type PlanApprovalDetails, resolvePlanTitle } from "../plan-mode/approved-plan";
98
92
  import { resolvePlanModelTransition } from "../plan-mode/model-transition";
99
93
  import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
100
94
  import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
@@ -129,7 +123,6 @@ import {
129
123
  setActiveTodoDescriptionsProvider,
130
124
  todoMatchesAnyDescription,
131
125
  } from "../tools/todo";
132
- import { ToolError } from "../tools/tool-errors";
133
126
  import { vocalizer } from "../tts/vocalizer";
134
127
  import { renderTreeList } from "../tui/tree-list";
135
128
  import { copyToClipboard } from "../utils/clipboard";
@@ -138,7 +131,7 @@ import { getEditorCommand, openInEditor } from "../utils/external-editor";
138
131
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
139
132
  import { messageHasDisplayableThinking } from "../utils/thinking-display";
140
133
  import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
141
- import { VibeSessionRegistry } from "../vibe/runtime";
134
+ import { aggregateVibeWorkerTokensPerSecond, VibeSessionRegistry } from "../vibe/runtime";
142
135
  import type { AssistantMessageComponent } from "./components/assistant-message";
143
136
  import type { BashExecutionComponent } from "./components/bash-execution";
144
137
  import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
@@ -208,6 +201,8 @@ import type {
208
201
  } from "./types";
209
202
  import { UiHelpers } from "./utils/ui-helpers";
210
203
 
204
+ const STILL_CLOSING_DELAY_MS = 3_000;
205
+
211
206
  const HINT_SHIMMER_PALETTE: ShimmerPalette = {
212
207
  low: "dim",
213
208
  mid: "muted",
@@ -451,6 +446,7 @@ export class InteractiveMode implements InteractiveModeContext {
451
446
  vibeModeEnabled = false;
452
447
  planModePlanFilePath: string | undefined = undefined;
453
448
  loopModeEnabled = false;
449
+ loopModePaused = false;
454
450
  loopPrompt: string | undefined = undefined;
455
451
  loopLimit: LoopLimitRuntime | undefined = undefined;
456
452
  #loopAutoSubmitTimer: NodeJS.Timeout | undefined;
@@ -696,6 +692,7 @@ export class InteractiveMode implements InteractiveModeContext {
696
692
  this.errorBannerContainer = new AnchoredLiveContainer();
697
693
  this.modelCycleContainer = new AnchoredLiveContainer();
698
694
  this.editor = new CustomEditor(getEditorTheme());
695
+ this.ui.enableScopedInputRender(this.editor);
699
696
  this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
700
697
  this.editor.setImeSafeCursorLayout(settings.get("tui.imeSafeCursor"));
701
698
  this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
@@ -726,6 +723,13 @@ export class InteractiveMode implements InteractiveModeContext {
726
723
  this.editorContainer.addChild(this.editor);
727
724
  this.statusLine = new StatusLineComponent(session);
728
725
  this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled);
726
+ // Vibe worker tok/s aggregator — keeps the status-line render layer off
727
+ // the heavy vibe/task dependency graph. The director is often idle while
728
+ // workers stream, so without this the tok/s badge would show a stale
729
+ // value while parallel work is actively generating tokens.
730
+ this.statusLine.setVibeWorkerTokenRateProvider(() =>
731
+ aggregateVibeWorkerTokensPerSecond(this.session.getAgentId() ?? MAIN_AGENT_ID),
732
+ );
729
733
  // Lazy provider — the top border rebuild coalesces to at most one
730
734
  // invocation per painted frame instead of firing on every session event
731
735
  // (#4145). The TUI throttles renders at ~30fps, so a long-running eval
@@ -1349,6 +1353,7 @@ export class InteractiveMode implements InteractiveModeContext {
1349
1353
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
1350
1354
  return;
1351
1355
  }
1356
+ this.#syncLoopModeStatus();
1352
1357
 
1353
1358
  if (action === "compact") {
1354
1359
  await this.handleCompactCommand();
@@ -1358,19 +1363,36 @@ export class InteractiveMode implements InteractiveModeContext {
1358
1363
  this.#submitLoopPromptWhenReady(prompt);
1359
1364
  }
1360
1365
 
1366
+ #syncLoopModeStatus(): void {
1367
+ const state: "waiting" | "running" | "paused" = this.loopModePaused
1368
+ ? "paused"
1369
+ : this.loopPrompt
1370
+ ? "running"
1371
+ : "waiting";
1372
+ this.statusLine.setLoopModeStatus(this.loopModeEnabled ? { state, limit: this.loopLimit } : undefined);
1373
+ this.ui.requestRender();
1374
+ }
1375
+
1361
1376
  disableLoopMode(message = "Loop mode disabled."): void {
1362
1377
  const wasEnabled = this.loopModeEnabled;
1363
1378
  this.loopModeEnabled = false;
1379
+ this.loopModePaused = false;
1364
1380
  this.loopPrompt = undefined;
1365
1381
  this.loopLimit = undefined;
1366
1382
  this.#cancelLoopAutoSubmit();
1367
- this.statusLine.setLoopModeStatus(undefined);
1368
- this.ui.requestRender();
1383
+ this.#syncLoopModeStatus();
1369
1384
  if (wasEnabled) {
1370
1385
  this.showStatus(message);
1371
1386
  }
1372
1387
  }
1373
1388
 
1389
+ setLoopPrompt(prompt: string): void {
1390
+ if (!this.loopModeEnabled) return;
1391
+ this.loopPrompt = prompt;
1392
+ this.loopModePaused = false;
1393
+ this.#syncLoopModeStatus();
1394
+ }
1395
+
1374
1396
  /**
1375
1397
  * Pause the loop without exiting it: drops the captured prompt and any
1376
1398
  * pending auto-resubmit. Loop mode stays enabled — the next prompt the
@@ -1378,7 +1400,9 @@ export class InteractiveMode implements InteractiveModeContext {
1378
1400
  */
1379
1401
  pauseLoop(): void {
1380
1402
  this.loopPrompt = undefined;
1403
+ this.loopModePaused = true;
1381
1404
  this.#cancelLoopAutoSubmit();
1405
+ this.#syncLoopModeStatus();
1382
1406
  }
1383
1407
 
1384
1408
  async handleLoopCommand(args = ""): Promise<string | undefined> {
@@ -1392,10 +1416,10 @@ export class InteractiveMode implements InteractiveModeContext {
1392
1416
  return undefined;
1393
1417
  }
1394
1418
  this.loopModeEnabled = true;
1419
+ this.loopModePaused = false;
1395
1420
  this.loopPrompt = undefined;
1396
1421
  this.loopLimit = createLoopLimitRuntime(parsed.limit);
1397
- this.statusLine.setLoopModeStatus({ enabled: true });
1398
- this.ui.requestRender();
1422
+ this.#syncLoopModeStatus();
1399
1423
  const limitSuffix = parsed.limit ? ` Limited to ${describeLoopLimit(parsed.limit)}.` : "";
1400
1424
  const remainingSuffix = this.loopLimit ? ` ${describeLoopLimitRuntime(this.loopLimit)}.` : "";
1401
1425
  const tail = parsed.prompt ? "Repeating it after each turn." : "Your next prompt will repeat after each turn.";
@@ -2301,7 +2325,7 @@ export class InteractiveMode implements InteractiveModeContext {
2301
2325
  }
2302
2326
  if (sessionContext.mode === "plan") {
2303
2327
  const planFilePath = sessionContext.modeData?.planFilePath as string | undefined;
2304
- await this.#enterPlanMode({ planFilePath });
2328
+ await this.#enterPlanMode({ planFilePath, preserveRestoredModel: true });
2305
2329
  } else if (sessionContext.mode === "plan_paused") {
2306
2330
  this.planModePaused = true;
2307
2331
  this.#planModeHasEntered = true;
@@ -2309,7 +2333,11 @@ export class InteractiveMode implements InteractiveModeContext {
2309
2333
  }
2310
2334
  }
2311
2335
 
2312
- async #enterPlanMode(options?: { planFilePath?: string; workflow?: "parallel" | "iterative" }): Promise<void> {
2336
+ async #enterPlanMode(options?: {
2337
+ planFilePath?: string;
2338
+ workflow?: "parallel" | "iterative";
2339
+ preserveRestoredModel?: boolean;
2340
+ }): Promise<void> {
2313
2341
  if (this.planModeEnabled) {
2314
2342
  return;
2315
2343
  }
@@ -2356,44 +2384,22 @@ export class InteractiveMode implements InteractiveModeContext {
2356
2384
  workflow: options?.workflow ?? "parallel",
2357
2385
  reentry: this.#planModeHasEntered,
2358
2386
  });
2359
- this.session.setPlanProposalHandler?.(title => this.#handlePlanProposal(title));
2387
+ this.session.setPlanProposalHandler?.(title => this.session.preparePlanForReview(title));
2360
2388
  if (this.session.isStreaming) {
2361
2389
  await this.session.sendPlanModeContext({ deliverAs: "steer" });
2362
2390
  }
2363
2391
  this.#planModeHasEntered = true;
2364
- await this.#applyPlanModeModel();
2392
+ // Session loading already restored the model recorded in the journal.
2393
+ // Reapplying today's plan role here would replace a CLI/session-specific
2394
+ // selection with current config during --resume or an in-process switch.
2395
+ if (!options?.preserveRestoredModel) {
2396
+ await this.#applyPlanModeModel();
2397
+ }
2365
2398
  this.#updatePlanModeStatus();
2366
2399
  this.sessionManager.appendModeChange("plan", { planFilePath });
2367
2400
  this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
2368
2401
  }
2369
2402
 
2370
- /** Plan-proposal handler registered while plan mode is active. The agent
2371
- * submits the finalized plan by writing the chosen `<slug>`/title to
2372
- * `xd://propose`; this handler validates the plan file exists, normalizes
2373
- * the title, and shapes the payload that `event-controller` forwards to
2374
- * `handlePlanApproval`. */
2375
- async #handlePlanProposal(title: string): Promise<AgentToolResult<unknown>> {
2376
- const state = this.session.getPlanModeState?.();
2377
- if (!state?.enabled) {
2378
- throw new ToolError("Plan mode is not active.");
2379
- }
2380
- const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
2381
- suppliedTitle: title,
2382
- statePlanFilePath: state.planFilePath,
2383
- readPlan: url => this.#readPlanFile(url),
2384
- listPlanFiles: () => this.#listLocalPlanFiles(),
2385
- });
2386
- const details: PlanApprovalDetails = {
2387
- planFilePath,
2388
- title: resolvedTitle,
2389
- planExists: true,
2390
- };
2391
- return {
2392
- content: [{ type: "text" as const, text: "Plan ready for approval." }],
2393
- details,
2394
- };
2395
- }
2396
-
2397
2403
  async #restorePlanPreviousModel(prev: { model: Model; thinkingLevel?: ConfiguredThinkingLevel }): Promise<void> {
2398
2404
  if (modelsAreEqual(this.session.model, prev.model)) {
2399
2405
  // Same model — only thinking level may differ. Avoid setModelTemporary()
@@ -2434,26 +2440,54 @@ export class InteractiveMode implements InteractiveModeContext {
2434
2440
  }
2435
2441
 
2436
2442
  const planModeState = this.session.getPlanModeState();
2443
+ const planModeTools = this.session.getEnabledToolNames();
2444
+ const planModeMountedTools = this.session.getMountedXdevToolNames();
2445
+ const planModeModelState = this.session.model
2446
+ ? { model: this.session.model, thinkingLevel: this.session.configuredThinkingLevel() }
2447
+ : undefined;
2437
2448
  this.session.setPlanModeState(undefined);
2438
2449
  try {
2439
2450
  if (this.#planModePreviousTools !== undefined) {
2440
2451
  await this.session.setActiveToolsByName(this.#planModePreviousTools);
2441
2452
  }
2442
- if (this.#planModePreviousModelState) {
2443
- if (!options?.deferModelRestore) {
2444
- await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
2445
- }
2446
- // If #applyPlanModeModel queued a deferred switch to the plan-role model
2447
- // (because the session was streaming on entry), drop it now: we are
2448
- // leaving plan mode, so flushing it on the next agent_end would land the
2449
- // session on the plan-role model after the user has exited plan mode
2450
- // (issue #816). This runs even when deferModelRestore is set
2451
- // (compact-approval path): otherwise the stale plan switch survives and
2452
- // flushPendingModelSwitch() later clobbers the restored/execution model.
2453
- this.#clearPendingPlanModelSwitch();
2453
+ if (this.#planModePreviousModelState && !options?.deferModelRestore) {
2454
+ await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
2454
2455
  }
2456
+ // If #applyPlanModeModel queued a deferred switch to the plan-role model
2457
+ // (because the session was streaming on entry), drop it now: we are
2458
+ // leaving plan mode, so flushing it on the next agent_end would land the
2459
+ // session on the plan-role model after the user has exited plan mode
2460
+ // (issue #816). This runs even when deferModelRestore is set
2461
+ // (compact-approval path): otherwise the stale plan switch survives and
2462
+ // flushPendingModelSwitch() later clobbers the restored/execution model.
2463
+ if (this.#planModePreviousModelState) this.#clearPendingPlanModelSwitch();
2455
2464
  } catch (error) {
2456
2465
  this.session.setPlanModeState(planModeState);
2466
+ if (
2467
+ planModeModelState &&
2468
+ (!modelsAreEqual(this.session.model, planModeModelState.model) ||
2469
+ this.session.configuredThinkingLevel() !== planModeModelState.thinkingLevel)
2470
+ ) {
2471
+ try {
2472
+ await this.#restorePlanPreviousModel(planModeModelState);
2473
+ } catch (rollbackError) {
2474
+ logger.warn("Failed to restore plan model after plan exit failure", { error: String(rollbackError) });
2475
+ }
2476
+ }
2477
+ const enabledTools = this.session.getEnabledToolNames();
2478
+ const mountedTools = this.session.getMountedXdevToolNames();
2479
+ if (
2480
+ enabledTools.length !== planModeTools.length ||
2481
+ enabledTools.some((name, index) => name !== planModeTools[index]) ||
2482
+ mountedTools.length !== planModeMountedTools.length ||
2483
+ mountedTools.some((name, index) => name !== planModeMountedTools[index])
2484
+ ) {
2485
+ try {
2486
+ await this.session.setActiveToolPresentation(planModeTools, planModeMountedTools);
2487
+ } catch (rollbackError) {
2488
+ logger.warn("Failed to restore plan tools after plan exit failure", { error: String(rollbackError) });
2489
+ }
2490
+ }
2457
2491
  throw error;
2458
2492
  }
2459
2493
  this.session.setPlanProposalHandler?.(null);
@@ -3746,10 +3780,17 @@ export class InteractiveMode implements InteractiveModeContext {
3746
3780
  // first runs the work, the other awaits the same settled promise.
3747
3781
  // The teardown is registered lazily in `init()` — a `/exit` reached
3748
3782
  // before `init()` completed falls back to a direct dispose.
3749
- if (this.#signalTeardown) {
3750
- await this.#signalTeardown();
3751
- } else {
3752
- await this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
3783
+ const stillClosingTimer = setTimeout(() => {
3784
+ this.showStatus("Still closing… (flushing memory backend / network)");
3785
+ }, STILL_CLOSING_DELAY_MS);
3786
+ try {
3787
+ if (this.#signalTeardown) {
3788
+ await this.#signalTeardown();
3789
+ } else {
3790
+ await this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
3791
+ }
3792
+ } finally {
3793
+ clearTimeout(stillClosingTimer);
3753
3794
  }
3754
3795
 
3755
3796
  // Do not force a final render during teardown: disposed session/UI state can
@@ -3794,6 +3835,7 @@ export class InteractiveMode implements InteractiveModeContext {
3794
3835
  const nextEditor = factory
3795
3836
  ? factory(this.ui, getEditorTheme(), this.keybindings)
3796
3837
  : new CustomEditor(getEditorTheme());
3838
+ if (!factory) this.ui.enableScopedInputRender(nextEditor);
3797
3839
 
3798
3840
  nextEditor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
3799
3841
  nextEditor.setImeSafeCursorLayout(this.settings.get("tui.imeSafeCursor"));
@@ -8,6 +8,7 @@
8
8
  import { describe, expect, it, spyOn } from "bun:test";
9
9
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
10
10
  import type { AgentSession } from "../session/agent-session";
11
+ import * as telemetryExport from "../telemetry-export";
11
12
  import { runPrintMode } from "./print-mode";
12
13
 
13
14
  /** Stand-in for `process.exit`: it terminates, so nothing after it should run. */
@@ -34,12 +35,23 @@ describe("print-mode error exit disposes the session before exit", () => {
34
35
  const session = {
35
36
  extensionRunner: undefined,
36
37
  subscribe: () => {},
38
+ settings: { get: () => false },
39
+ sessionManager: { buildSessionContext: () => ({ messages: [] }), getEntries: () => [] },
37
40
  state: { messages: [errorMsg] },
41
+ getLastAssistantMessage: () => errorMsg,
42
+ prepareForHeadlessAdvisorDrain: () => {},
43
+ waitForAdvisorCatchup: async () => {
44
+ order.push("catchup");
45
+ return true;
46
+ },
38
47
  dispose: async () => {
39
48
  order.push("dispose");
40
49
  },
41
50
  } as unknown as AgentSession;
42
51
 
52
+ const flushSpy = spyOn(telemetryExport, "flushTelemetryExport").mockImplementation(async () => {
53
+ order.push("flush");
54
+ });
43
55
  const exitSpy = spyOn(process, "exit").mockImplementation(((code: number) => {
44
56
  order.push("exit");
45
57
  throw new ProcessExit(code);
@@ -53,8 +65,9 @@ describe("print-mode error exit disposes the session before exit", () => {
53
65
  } finally {
54
66
  exitSpy.mockRestore();
55
67
  stderrSpy.mockRestore();
68
+ flushSpy.mockRestore();
56
69
  }
57
70
 
58
- expect(order).toEqual(["dispose", "exit"]);
71
+ expect(order).toEqual(["catchup", "flush", "dispose", "exit"]);
59
72
  });
60
73
  });
@@ -6,11 +6,13 @@
6
6
  * - `omp --mode json "prompt"` - JSON event stream
7
7
  */
8
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
- import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
9
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
10
10
  import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
11
+ import { resolvePlanModelTransition } from "../plan-mode/model-transition";
11
12
  import { type AgentSession, type AgentSessionEvent, SHUTDOWN_CONSOLIDATE_BUDGET_MS } from "../session/agent-session";
12
13
  import { isSilentAbort } from "../session/messages";
13
14
  import { flushTelemetryExport } from "../telemetry-export";
15
+ import { PROPOSE_DEVICE_NAME, writeDeviceDispatch } from "../tools/resolve";
14
16
  import { initializeExtensions } from "./runtime-init";
15
17
 
16
18
  /**
@@ -29,6 +31,11 @@ export interface PrintModeOptions {
29
31
  printThoughts?: boolean;
30
32
  }
31
33
 
34
+ /** Matches the longest built-in provider request deadline while bounding tool-loop stalls. */
35
+ export const PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS = 10 * 60_000;
36
+ /** Error exits cannot hold automation for the full normal drain budget. */
37
+ export const PRINT_MODE_ERROR_ADVISOR_DRAIN_TIMEOUT_MS = 30_000;
38
+
32
39
  /** Drop the provider-opaque replay payload (e.g. encrypted reasoning items) before printing. */
33
40
  function stripProviderPayload<T extends AgentMessage>(message: T): T {
34
41
  if (!("providerPayload" in message) || message.providerPayload === undefined) return message;
@@ -103,8 +110,66 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
103
110
  },
104
111
  });
105
112
 
113
+ // InteractiveMode applies the same startup default during TUI initialization.
114
+ // Print mode has no TUI bootstrap, so arm the shared session directly before
115
+ // the first prompt; persisting the mode_change also lets a later interactive
116
+ // attachment restore and review the generated plan.
117
+ let abortAfterPlanProposal = false;
118
+ const planDefaultArmed =
119
+ session.settings.get("plan.defaultOnStartup") &&
120
+ session.settings.get("plan.enabled") &&
121
+ session.sessionManager.buildSessionContext().messages.length === 0 &&
122
+ !session.sessionManager.getEntries().some(entry => entry.type === "mode_change");
123
+ if (planDefaultArmed) {
124
+ const planFilePath = session.getPlanReferencePath() || "local://PLAN.md";
125
+ const previousTools = session.getEnabledToolNames();
126
+ const planTools = session.hasBuiltInTool("write") ? [...new Set([...previousTools, "write"])] : previousTools;
127
+ await session.setActiveToolsByName(planTools);
128
+ session.setPlanModeState({
129
+ enabled: true,
130
+ planFilePath,
131
+ workflow: "parallel",
132
+ });
133
+ session.sessionManager.appendModeChange("plan", { planFilePath });
134
+ abortAfterPlanProposal = true;
135
+ session.setPlanProposalHandler(async title => {
136
+ const result = await session.preparePlanForReview(title);
137
+ const details = result.details;
138
+ if (details) {
139
+ const state = session.getPlanModeState();
140
+ if (state?.enabled) {
141
+ session.setPlanModeState({ ...state, planFilePath: details.planFilePath });
142
+ }
143
+ session.sessionManager.appendModeChange("plan", { planFilePath: details.planFilePath });
144
+ }
145
+ return result;
146
+ });
147
+
148
+ const resolved = session.resolveRoleModelWithThinking("plan");
149
+ const transition = resolvePlanModelTransition(session.model, resolved, false);
150
+ if (transition.kind === "thinking") {
151
+ session.setThinkingLevel(transition.thinkingLevel);
152
+ } else if (transition.kind === "apply") {
153
+ try {
154
+ await session.setModelTemporary(transition.model, transition.thinkingLevel);
155
+ } catch (error) {
156
+ logger.warn("Failed to switch to plan model for print mode", { error: String(error) });
157
+ }
158
+ }
159
+ }
160
+
106
161
  // Always subscribe to enable session persistence via _handleAgentEvent
107
162
  session.subscribe(event => {
163
+ if (abortAfterPlanProposal && event.type === "tool_execution_end" && !event.isError) {
164
+ const dispatch = writeDeviceDispatch(event.toolName, event.result);
165
+ if (dispatch?.tool === PROPOSE_DEVICE_NAME && dispatch.mode === "execute") {
166
+ abortAfterPlanProposal = false;
167
+ session.markPlanInternalAbortPending();
168
+ void session.abort().finally(() => {
169
+ session.clearPlanInternalAbortPending();
170
+ });
171
+ }
172
+ }
108
173
  // In JSON mode, output all events
109
174
  if (mode === "json") {
110
175
  process.stdout.write(`${JSON.stringify(printableEvent(event))}\n`);
@@ -130,14 +195,19 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
130
195
  await logger.time("print:prompt:next", () => session.prompt(message));
131
196
  }
132
197
 
198
+ // From this point onward a late blocker must be recorded without starting a
199
+ // primary turn whose response print mode would never emit.
200
+ session.prepareForHeadlessAdvisorDrain();
201
+
133
202
  // In text mode, output final response
134
203
  if (mode === "text") {
135
- const state = session.state;
136
- const lastMessage = state.messages[state.messages.length - 1];
137
-
138
- if (lastMessage?.role === "assistant") {
139
- const assistantMsg = lastMessage as AssistantMessage;
204
+ // Read via the session accessor, not the raw state tail: a classifier
205
+ // refusal is pruned from active context at settle, and an aborted turn
206
+ // can trail synthetic tool results — both would hide the terminal
207
+ // assistant message (and its error) from a last-element read.
208
+ const assistantMsg = session.getLastAssistantMessage();
140
209
 
210
+ if (assistantMsg) {
141
211
  // Check for error/aborted — skip silent-abort (plan-mode compaction transition)
142
212
  if (
143
213
  (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") &&
@@ -151,6 +221,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
151
221
  // `dispose()` (releaseTabsForOwner) actually runs — otherwise an
152
222
  // OMP-owned Chromium survives this exit (issue #5643). `dispose()`
153
223
  // is idempotent, so the unreachable call below is a harmless no-op.
224
+ await session.waitForAdvisorCatchup(PRINT_MODE_ERROR_ADVISOR_DRAIN_TIMEOUT_MS);
154
225
  await flushTelemetryExport();
155
226
  await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
156
227
  const flushed = process.stderr.write(`${errorLine}\n`);
@@ -180,14 +251,15 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
180
251
  }
181
252
  }
182
253
 
183
- // Ensure stdout is fully flushed before returning
184
- // This prevents race conditions where the process exits before all output is written
254
+ await session.waitForAdvisorCatchup(PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS);
255
+
256
+ // Ensure stdout, including late JSON advisor events, is fully flushed before returning.
257
+ // This prevents race conditions where the process exits before all output is written.
185
258
  await new Promise<void>((resolve, reject) => {
186
259
  process.stdout.write("", err => {
187
260
  if (err) reject(err);
188
261
  else resolve();
189
262
  });
190
263
  });
191
-
192
264
  await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
193
265
  }
@@ -206,6 +206,7 @@ function normalizeToolResult<TDetails>(result: RpcClientToolResult<TDetails>): A
206
206
 
207
207
  export class RpcClient {
208
208
  #process: ptree.ChildProcess | null = null;
209
+ #reaping: Promise<void> | null = null;
209
210
  #eventListeners: RpcEventListener[] = [];
210
211
  #sessionEventListeners: RpcSessionEventListener[] = [];
211
212
  #subagentLifecycleListeners = new Set<RpcSubagentLifecycleListener>();
@@ -233,6 +234,7 @@ export class RpcClient {
233
234
  * retry without leaking processes.
234
235
  */
235
236
  async start(): Promise<void> {
237
+ await this.#reaping;
236
238
  if (this.#process) {
237
239
  throw new Error("Client already started");
238
240
  }
@@ -268,7 +270,26 @@ export class RpcClient {
268
270
  const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
269
271
  let readySettled = false;
270
272
 
271
- // Process lines in background, intercepting the ready signal
273
+ const reapAfterOutputFailure = async (error: Error) => {
274
+ if (this.#process !== child) return;
275
+
276
+ this.#process = null;
277
+ this.#abortController.abort(error);
278
+ const pendingRequests = Array.from(this.#pendingRequests.values());
279
+ this.#pendingRequests.clear();
280
+ for (const pendingCall of this.#pendingHostToolCalls.values()) pendingCall.controller.abort(error);
281
+ this.#pendingHostToolCalls.clear();
282
+
283
+ try {
284
+ child.kill();
285
+ } catch {
286
+ // The process may already have exited.
287
+ }
288
+ await this.#waitForExit(child);
289
+ for (const request of pendingRequests) request.reject(error);
290
+ };
291
+
292
+ // Process lines in background, intercepting the ready signal.
272
293
  const lines = readJsonl(child.stdout, this.#abortController.signal);
273
294
  void (async () => {
274
295
  for await (const line of lines) {
@@ -279,22 +300,38 @@ export class RpcClient {
279
300
  }
280
301
  this.#handleLine(line);
281
302
  }
282
- // Stream ended without the ready signal the child exited or is
283
- // exiting. Defer to the exit handler below: ptree resolves
284
- // `exited` only after stderr is fully drained (nonzero exits), so
285
- // rejecting here would snapshot a partial stderr tail and lose
286
- // the actual startup error.
287
- if (readySettled) return;
288
- await child.exited.catch(() => {});
303
+ // A closed stdout is terminal even if the child remains alive. Startup
304
+ // failures are reaped by the readyPromise catch below; established
305
+ // workers are reaped here so pending requests cannot hang indefinitely.
289
306
  if (!readySettled) {
290
307
  readySettled = true;
291
- readyReject(new Error(`Agent process exited before ready. Stderr: ${child.peekStderr()}`));
308
+ readyReject(new Error(`Agent output stream ended before ready. Stderr: ${child.peekStderr()}`));
309
+ return;
292
310
  }
293
- })().catch((err: Error) => {
311
+ const exitResult = await Promise.race([
312
+ child.exited.then(
313
+ exitCode => ({ exitCode }),
314
+ cause => ({ cause }),
315
+ ),
316
+ Bun.sleep(100).then(() => null),
317
+ ]);
318
+ const error =
319
+ exitResult === null
320
+ ? new Error(`Agent output stream ended unexpectedly. Stderr: ${child.peekStderr()}`)
321
+ : "exitCode" in exitResult
322
+ ? new Error(`Agent process exited with code ${exitResult.exitCode}. Stderr: ${child.peekStderr()}`)
323
+ : new Error(`Agent output stream ended. Stderr: ${child.peekStderr()}`, {
324
+ cause: exitResult.cause,
325
+ });
326
+ await reapAfterOutputFailure(error);
327
+ })().catch(async (cause: unknown) => {
328
+ const error = cause instanceof Error ? cause : new Error(String(cause));
294
329
  if (!readySettled) {
295
330
  readySettled = true;
296
- readyReject(err);
331
+ readyReject(error);
332
+ return;
297
333
  }
334
+ await reapAfterOutputFailure(new Error(`Agent output reader failed: ${error.message}`, { cause: error }));
298
335
  });
299
336
 
300
337
  // Also race against process exit (in case stdout closes before we read it)
@@ -325,20 +362,12 @@ export class RpcClient {
325
362
  if (this.#customTools.length > 0) {
326
363
  await this.setCustomTools(this.#customTools);
327
364
  }
328
- } catch (err) {
329
- // Startup failed after we spawned the child. Kill it and clear
330
- // state so the caller (or a retry via start() again) does not
331
- // leak the abandoned process (issue #4079).
332
- try {
333
- child.kill();
334
- } catch {
335
- // best-effort cleanup
336
- }
337
- this.#abortController.abort();
338
- if (this.#process === child) {
339
- this.#process = null;
340
- }
341
- throw err;
365
+ } catch (cause) {
366
+ // Startup failed after spawning the child. Reap it before returning
367
+ // so a retry cannot inherit a live worker or its session lock.
368
+ const error = cause instanceof Error ? cause : new Error(String(cause));
369
+ await reapAfterOutputFailure(error);
370
+ throw cause;
342
371
  } finally {
343
372
  clearTimeout(readyTimeout);
344
373
  }
@@ -347,28 +376,40 @@ export class RpcClient {
347
376
  /**
348
377
  * Stop the RPC agent process.
349
378
  */
350
- stop() {
351
- if (!this.#process) return;
379
+ stop(): Promise<void> {
380
+ if (!this.#process) return this.#reaping ?? Promise.resolve();
352
381
 
353
- this.#process.kill();
354
- this.#abortController.abort();
382
+ const error = new Error("Client stopped");
383
+ const child = this.#process;
384
+ child.kill();
385
+ this.#abortController.abort(error);
355
386
  this.#process = null;
387
+ for (const request of this.#pendingRequests.values()) request.reject(error);
356
388
  this.#pendingRequests.clear();
357
389
  for (const pendingCall of this.#pendingHostToolCalls.values()) {
358
- pendingCall.controller.abort();
390
+ pendingCall.controller.abort(error);
359
391
  }
360
392
  this.#pendingHostToolCalls.clear();
393
+ return this.#waitForExit(child);
361
394
  }
362
395
 
363
396
  /**
364
397
  * Stop the RPC agent process and clean up resources.
365
398
  */
366
399
  [Symbol.dispose](): void {
367
- try {
368
- this.stop();
369
- } catch {
370
- // Ignore cleanup errors
371
- }
400
+ void this.stop();
401
+ }
402
+
403
+ #waitForExit(child: ptree.ChildProcess): Promise<void> {
404
+ const reaping = child.exited.then(
405
+ () => {},
406
+ () => {},
407
+ );
408
+ this.#reaping = reaping;
409
+ void reaping.then(() => {
410
+ if (this.#reaping === reaping) this.#reaping = null;
411
+ });
412
+ return reaping;
372
413
  }
373
414
 
374
415
  /**