@oh-my-pi/pi-coding-agent 17.0.5 → 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 (46) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/dist/cli.js +2838 -2917
  3. package/dist/types/exec/bash-executor.d.ts +1 -0
  4. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  5. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  6. package/dist/types/modes/components/status-line/component.d.ts +8 -0
  7. package/dist/types/modes/components/usage-row.d.ts +1 -1
  8. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  9. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  10. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  11. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  12. package/dist/types/session/agent-session.d.ts +23 -5
  13. package/dist/types/session/session-paths.d.ts +21 -4
  14. package/dist/types/tools/xdev.d.ts +5 -3
  15. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  16. package/dist/types/vibe/runtime.d.ts +23 -0
  17. package/package.json +12 -12
  18. package/src/exec/bash-executor.ts +68 -8
  19. package/src/extensibility/extensions/load-errors.ts +13 -0
  20. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  21. package/src/main.ts +8 -0
  22. package/src/modes/components/chat-transcript-builder.ts +10 -1
  23. package/src/modes/components/status-line/component.ts +55 -1
  24. package/src/modes/components/usage-row.ts +17 -1
  25. package/src/modes/controllers/command-controller.ts +41 -1
  26. package/src/modes/controllers/event-controller.ts +6 -1
  27. package/src/modes/interactive-mode.ts +62 -52
  28. package/src/modes/noninteractive-dispose.test.ts +2 -0
  29. package/src/modes/print-mode.ts +60 -0
  30. package/src/modes/rpc/rpc-client.ts +76 -35
  31. package/src/modes/rpc/rpc-frame.ts +156 -0
  32. package/src/modes/rpc/rpc-mode.ts +4 -2
  33. package/src/modes/setup-wizard/index.ts +2 -0
  34. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  35. package/src/modes/utils/ui-helpers.ts +6 -1
  36. package/src/prompts/system/workflow-notice.md +89 -74
  37. package/src/session/agent-session.ts +89 -26
  38. package/src/session/session-manager.ts +34 -3
  39. package/src/session/session-paths.ts +38 -9
  40. package/src/task/executor.ts +45 -0
  41. package/src/tools/xdev.ts +11 -4
  42. package/src/tools/yield.ts +4 -1
  43. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  44. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  45. package/src/vibe/runtime.ts +50 -0
  46. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -11,13 +11,13 @@ import { limitMatchesActiveAccount } from "../../../slash-commands/helpers/activ
11
11
  import { type ActiveRepoContext, resolveActiveRepoContextSync } from "../../../utils/active-repo-context";
12
12
  import * as git from "../../../utils/git";
13
13
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
14
+ import { calculateTokensPerSecond } from "../../../utils/token-rate";
14
15
  import { sanitizeStatusText } from "../../shared";
15
16
  import { theme } from "../../theme/theme";
16
17
  import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils";
17
18
  import { getPreset } from "./presets";
18
19
  import { renderSegment, type SegmentContext } from "./segments";
19
20
  import { getSeparator } from "./separators";
20
- import { calculateTokensPerSecond } from "./token-rate";
21
21
  import type {
22
22
  CollabStatus,
23
23
  EffectiveStatusLineSettings,
@@ -276,6 +276,13 @@ export class StatusLineComponent implements Component {
276
276
  #loopModeStatus: SegmentContext["loopMode"] = null;
277
277
  #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
278
278
  #vibeModeStatus: { enabled: boolean } | null = null;
279
+ /**
280
+ * Injected aggregator that returns the aggregate tok/s of this session's
281
+ * live vibe worker sessions, or null when no workers are streaming. Kept as
282
+ * a callback so the render layer doesn't import the heavy vibe/task
283
+ * dependency graph; interactive-mode wires it to VibeSessionRegistry.
284
+ */
285
+ #vibeWorkerTokenRate: (() => number | null) | null = null;
279
286
  #collabStatus: CollabStatus | null = null;
280
287
  #focusedAgentId: string | undefined;
281
288
  #activeRepoCache: ActiveRepoCache | undefined;
@@ -508,6 +515,17 @@ export class StatusLineComponent implements Component {
508
515
  this.#vibeModeStatus = status ?? null;
509
516
  }
510
517
 
518
+ /**
519
+ * Inject the aggregator that returns the aggregate tok/s of this session's
520
+ * live vibe worker sessions (null when no workers are streaming). Wired by
521
+ * interactive-mode, which owns the VibeSessionRegistry coupling, so the
522
+ * render layer stays off the heavy vibe/task dependency graph. Pass
523
+ * `undefined` to clear.
524
+ */
525
+ setVibeWorkerTokenRateProvider(provider: (() => number | null) | undefined): void {
526
+ this.#vibeWorkerTokenRate = provider ?? null;
527
+ }
528
+
511
529
  setCollabStatus(status: CollabStatus | null): void {
512
530
  this.#collabStatus = status;
513
531
  }
@@ -747,6 +765,31 @@ export class StatusLineComponent implements Component {
747
765
  }
748
766
 
749
767
  #getTokensPerSecond(): number | null {
768
+ // Aggregate tok/s across the main session AND every live vibe worker.
769
+ // In vibe mode the director is often idle while workers stream, so the
770
+ // main session's own rate alone would show a stale/zero value while
771
+ // parallel work is actively generating tokens.
772
+ const workerRate = this.#getVibeWorkerTokensPerSecond();
773
+ if (workerRate !== null) {
774
+ // At least one worker is streaming — add the director's live rate
775
+ // only when it is itself streaming (a finalized last-turn rate would
776
+ // double-count and overstate throughput).
777
+ const mainRate = this.session.isStreaming ? calculateTokensPerSecond(this.session.state.messages, true) : 0;
778
+ return (mainRate ?? 0) + workerRate;
779
+ }
780
+
781
+ // No workers streaming — fall back to the main session's own rate with
782
+ // its sticky per-assistant-message cache so the badge doesn't flicker
783
+ // off in the brief gap between stream end and the finalized message.
784
+ return this.#getMainSessionTokensPerSecond();
785
+ }
786
+
787
+ /**
788
+ * Main session's tok/s with sticky caching keyed on the last assistant
789
+ * message timestamp. Preserves the pre-aggregation behavior when no vibe
790
+ * workers are active.
791
+ */
792
+ #getMainSessionTokensPerSecond(): number | null {
750
793
  let lastAssistantTimestamp: number | null = null;
751
794
  for (let i = this.session.state.messages.length - 1; i >= 0; i--) {
752
795
  const message = this.session.state.messages[i];
@@ -776,6 +819,17 @@ export class StatusLineComponent implements Component {
776
819
  return null;
777
820
  }
778
821
 
822
+ /**
823
+ * Aggregate tok/s across every live vibe worker session owned by this
824
+ * session. Returns null when no workers are streaming (so the main
825
+ * session's own rate shines through unchanged). The aggregation itself is
826
+ * injected via {@link setVibeWorkerTokenRateProvider} to keep this render
827
+ * layer off the heavy vibe/task dependency graph.
828
+ */
829
+ #getVibeWorkerTokensPerSecond(): number | null {
830
+ return this.#vibeWorkerTokenRate?.() ?? null;
831
+ }
832
+
779
833
  #getUsageContextKey(session: AgentSession): string {
780
834
  const activeProvider = session.state.model?.provider ?? session.model?.provider ?? "";
781
835
  if (!activeProvider) return "";
@@ -6,9 +6,25 @@ import { theme } from "../../modes/theme/theme";
6
6
  /** Below this the rate is nonsense (cached/instant responses yield absurd tok/s). */
7
7
  const MIN_DURATION_MS = 100;
8
8
 
9
- export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: number): Container {
9
+ /** Local `YYYY-MM-DD HH:mm:ss` stamp for the per-turn usage row. */
10
+ function formatUsageTimestamp(ms: number): string {
11
+ const d = new Date(ms);
12
+ const pad = (n: number): string => String(n).padStart(2, "0");
13
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
14
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
15
+ return `${date} ${time}`;
16
+ }
17
+
18
+ // `timestamp` is optional and trails the throughput args to preserve the existing
19
+ // (usage, durationMs, ttftMs) call contract — this function is part of the package's
20
+ // public export surface (./modes/components/*).
21
+ export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: number, timestamp?: number): Container {
10
22
  const totalInput = usage.input + usage.cacheWrite;
11
23
  const parts: string[] = [];
24
+ // Lead with the turn's local wall-clock time (down to the second), log-line style.
25
+ if (timestamp !== undefined && Number.isFinite(timestamp) && timestamp > 0) {
26
+ parts.push(formatUsageTimestamp(timestamp));
27
+ }
12
28
  parts.push(`${theme.icon.input} ${formatNumber(totalInput)}`);
13
29
  parts.push(`${theme.icon.output} ${formatNumber(usage.output)}`);
14
30
  if (usage.cacheRead > 0) {
@@ -13,6 +13,7 @@ import {
13
13
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
14
14
  import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
15
15
  import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
16
+ import { type BashResult, isPersistentShellCdCommand } from "../../exec/bash-executor";
16
17
  import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
17
18
  import { shareSession } from "../../export/share";
18
19
  import type { CompactOptions } from "../../extensibility/extensions/types";
@@ -1100,6 +1101,12 @@ export class CommandController {
1100
1101
 
1101
1102
  async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
1102
1103
  const isDeferred = this.ctx.session.isStreaming;
1104
+ const shouldPersistCwd = isPersistentShellCdCommand(command);
1105
+ if (isDeferred && shouldPersistCwd) {
1106
+ this.ctx.showWarning("Wait for the current response to finish or abort it before changing directories.");
1107
+ return;
1108
+ }
1109
+
1103
1110
  this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
1104
1111
 
1105
1112
  if (isDeferred) {
@@ -1120,7 +1127,6 @@ export class CommandController {
1120
1127
  },
1121
1128
  { excludeFromContext, useUserShell: true },
1122
1129
  );
1123
-
1124
1130
  if (this.ctx.bashComponent) {
1125
1131
  const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
1126
1132
  this.ctx.bashComponent.setComplete(result.exitCode, result.cancelled, {
@@ -1128,6 +1134,15 @@ export class CommandController {
1128
1134
  truncation: meta?.truncation,
1129
1135
  });
1130
1136
  }
1137
+ try {
1138
+ if (shouldPersistCwd) await this.#applyBashResultCwd(result);
1139
+ } catch (error) {
1140
+ this.ctx.showError(
1141
+ `Bash command completed, but OMP failed to update its working directory: ${
1142
+ error instanceof Error ? error.message : "Unknown error"
1143
+ }`,
1144
+ );
1145
+ }
1131
1146
  } catch (error) {
1132
1147
  if (this.ctx.bashComponent) {
1133
1148
  this.ctx.bashComponent.setComplete(undefined, false);
@@ -1139,6 +1154,31 @@ export class CommandController {
1139
1154
  this.ctx.ui.requestRender();
1140
1155
  }
1141
1156
 
1157
+ async #moveInteractiveCwd(resolvedPath: string): Promise<void> {
1158
+ await this.ctx.sessionManager.moveTo(resolvedPath);
1159
+ await this.ctx.applyCwdChange(resolvedPath);
1160
+ this.ctx.updateEditorBorderColor();
1161
+ await this.ctx.reloadTodos();
1162
+ }
1163
+
1164
+ async #applyBashResultCwd(result: BashResult): Promise<void> {
1165
+ if (result.cancelled || result.exitCode !== 0 || !result.workingDir) return;
1166
+ if (!path.isAbsolute(result.workingDir)) return;
1167
+
1168
+ const resolvedPath = path.resolve(result.workingDir);
1169
+ if (resolvedPath === path.resolve(this.ctx.sessionManager.getCwd())) return;
1170
+
1171
+ let isDirectory = false;
1172
+ try {
1173
+ isDirectory = (await fs.stat(resolvedPath)).isDirectory();
1174
+ } catch {
1175
+ isDirectory = false;
1176
+ }
1177
+ if (!isDirectory) return;
1178
+
1179
+ await this.#moveInteractiveCwd(resolvedPath);
1180
+ }
1181
+
1142
1182
  async handlePythonCommand(code: string, excludeFromContext = false): Promise<void> {
1143
1183
  const isDeferred = this.ctx.session.isStreaming;
1144
1184
  this.ctx.pythonComponent = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext);
@@ -912,7 +912,12 @@ export class EventController {
912
912
  this.#lastAssistantComponent = lastPostToolAssistantComponent ?? this.ctx.streamingComponent;
913
913
  if (settings.get("display.showTokenUsage") && assistantUsageIsBilled(event.message.usage)) {
914
914
  this.ctx.chatContainer.addChild(
915
- createUsageRowBlock(event.message.usage, event.message.duration, event.message.ttft),
915
+ createUsageRowBlock(
916
+ event.message.usage,
917
+ event.message.duration,
918
+ event.message.ttft,
919
+ event.message.timestamp,
920
+ ),
916
921
  );
917
922
  }
918
923
  this.ctx.streamingComponent = undefined;
@@ -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";
@@ -730,6 +723,13 @@ export class InteractiveMode implements InteractiveModeContext {
730
723
  this.editorContainer.addChild(this.editor);
731
724
  this.statusLine = new StatusLineComponent(session);
732
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
+ );
733
733
  // Lazy provider — the top border rebuild coalesces to at most one
734
734
  // invocation per painted frame instead of firing on every session event
735
735
  // (#4145). The TUI throttles renders at ~30fps, so a long-running eval
@@ -2325,7 +2325,7 @@ export class InteractiveMode implements InteractiveModeContext {
2325
2325
  }
2326
2326
  if (sessionContext.mode === "plan") {
2327
2327
  const planFilePath = sessionContext.modeData?.planFilePath as string | undefined;
2328
- await this.#enterPlanMode({ planFilePath });
2328
+ await this.#enterPlanMode({ planFilePath, preserveRestoredModel: true });
2329
2329
  } else if (sessionContext.mode === "plan_paused") {
2330
2330
  this.planModePaused = true;
2331
2331
  this.#planModeHasEntered = true;
@@ -2333,7 +2333,11 @@ export class InteractiveMode implements InteractiveModeContext {
2333
2333
  }
2334
2334
  }
2335
2335
 
2336
- 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> {
2337
2341
  if (this.planModeEnabled) {
2338
2342
  return;
2339
2343
  }
@@ -2380,44 +2384,22 @@ export class InteractiveMode implements InteractiveModeContext {
2380
2384
  workflow: options?.workflow ?? "parallel",
2381
2385
  reentry: this.#planModeHasEntered,
2382
2386
  });
2383
- this.session.setPlanProposalHandler?.(title => this.#handlePlanProposal(title));
2387
+ this.session.setPlanProposalHandler?.(title => this.session.preparePlanForReview(title));
2384
2388
  if (this.session.isStreaming) {
2385
2389
  await this.session.sendPlanModeContext({ deliverAs: "steer" });
2386
2390
  }
2387
2391
  this.#planModeHasEntered = true;
2388
- 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
+ }
2389
2398
  this.#updatePlanModeStatus();
2390
2399
  this.sessionManager.appendModeChange("plan", { planFilePath });
2391
2400
  this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
2392
2401
  }
2393
2402
 
2394
- /** Plan-proposal handler registered while plan mode is active. The agent
2395
- * submits the finalized plan by writing the chosen `<slug>`/title to
2396
- * `xd://propose`; this handler validates the plan file exists, normalizes
2397
- * the title, and shapes the payload that `event-controller` forwards to
2398
- * `handlePlanApproval`. */
2399
- async #handlePlanProposal(title: string): Promise<AgentToolResult<unknown>> {
2400
- const state = this.session.getPlanModeState?.();
2401
- if (!state?.enabled) {
2402
- throw new ToolError("Plan mode is not active.");
2403
- }
2404
- const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
2405
- suppliedTitle: title,
2406
- statePlanFilePath: state.planFilePath,
2407
- readPlan: url => this.#readPlanFile(url),
2408
- listPlanFiles: () => this.#listLocalPlanFiles(),
2409
- });
2410
- const details: PlanApprovalDetails = {
2411
- planFilePath,
2412
- title: resolvedTitle,
2413
- planExists: true,
2414
- };
2415
- return {
2416
- content: [{ type: "text" as const, text: "Plan ready for approval." }],
2417
- details,
2418
- };
2419
- }
2420
-
2421
2403
  async #restorePlanPreviousModel(prev: { model: Model; thinkingLevel?: ConfiguredThinkingLevel }): Promise<void> {
2422
2404
  if (modelsAreEqual(this.session.model, prev.model)) {
2423
2405
  // Same model — only thinking level may differ. Avoid setModelTemporary()
@@ -2458,26 +2440,54 @@ export class InteractiveMode implements InteractiveModeContext {
2458
2440
  }
2459
2441
 
2460
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;
2461
2448
  this.session.setPlanModeState(undefined);
2462
2449
  try {
2463
2450
  if (this.#planModePreviousTools !== undefined) {
2464
2451
  await this.session.setActiveToolsByName(this.#planModePreviousTools);
2465
2452
  }
2466
- if (this.#planModePreviousModelState) {
2467
- if (!options?.deferModelRestore) {
2468
- await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
2469
- }
2470
- // If #applyPlanModeModel queued a deferred switch to the plan-role model
2471
- // (because the session was streaming on entry), drop it now: we are
2472
- // leaving plan mode, so flushing it on the next agent_end would land the
2473
- // session on the plan-role model after the user has exited plan mode
2474
- // (issue #816). This runs even when deferModelRestore is set
2475
- // (compact-approval path): otherwise the stale plan switch survives and
2476
- // flushPendingModelSwitch() later clobbers the restored/execution model.
2477
- this.#clearPendingPlanModelSwitch();
2453
+ if (this.#planModePreviousModelState && !options?.deferModelRestore) {
2454
+ await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
2478
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();
2479
2464
  } catch (error) {
2480
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
+ }
2481
2491
  throw error;
2482
2492
  }
2483
2493
  this.session.setPlanProposalHandler?.(null);
@@ -35,6 +35,8 @@ describe("print-mode error exit disposes the session before exit", () => {
35
35
  const session = {
36
36
  extensionRunner: undefined,
37
37
  subscribe: () => {},
38
+ settings: { get: () => false },
39
+ sessionManager: { buildSessionContext: () => ({ messages: [] }), getEntries: () => [] },
38
40
  state: { messages: [errorMsg] },
39
41
  getLastAssistantMessage: () => errorMsg,
40
42
  prepareForHeadlessAdvisorDrain: () => {},
@@ -8,9 +8,11 @@
8
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
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
  /**
@@ -108,8 +110,66 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
108
110
  },
109
111
  });
110
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
+
111
161
  // Always subscribe to enable session persistence via _handleAgentEvent
112
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
+ }
113
173
  // In JSON mode, output all events
114
174
  if (mode === "json") {
115
175
  process.stdout.write(`${JSON.stringify(printableEvent(event))}\n`);
@@ -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
  /**