@oh-my-pi/pi-coding-agent 17.0.0 → 17.0.2

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 (202) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/cli.js +4006 -3966
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/advisor/config.d.ts +14 -6
  5. package/dist/types/advisor/runtime.d.ts +20 -11
  6. package/dist/types/autolearn/controller.d.ts +1 -0
  7. package/dist/types/cli-commands.d.ts +14 -1
  8. package/dist/types/config/model-discovery.d.ts +17 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/model-resolver.d.ts +6 -2
  11. package/dist/types/config/models-config-schema.d.ts +10 -0
  12. package/dist/types/config/models-config.d.ts +6 -0
  13. package/dist/types/config/settings-schema.d.ts +32 -7
  14. package/dist/types/config/settings.d.ts +50 -0
  15. package/dist/types/cursor.d.ts +11 -0
  16. package/dist/types/discovery/helpers.d.ts +5 -2
  17. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  20. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  23. package/dist/types/extensibility/shared-events.d.ts +6 -0
  24. package/dist/types/extensibility/utils.d.ts +2 -2
  25. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  26. package/dist/types/lib/xai-http.d.ts +11 -0
  27. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  28. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  29. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  30. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  31. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  32. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  34. package/dist/types/modes/components/hook-editor.d.ts +16 -2
  35. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  36. package/dist/types/modes/components/model-hub.d.ts +5 -2
  37. package/dist/types/modes/components/session-selector.d.ts +4 -0
  38. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  39. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  42. package/dist/types/modes/interactive-mode.d.ts +6 -0
  43. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  44. package/dist/types/modes/print-mode.d.ts +1 -1
  45. package/dist/types/modes/types.d.ts +10 -0
  46. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  47. package/dist/types/modes/warp-events.d.ts +24 -0
  48. package/dist/types/modes/warp-events.test.d.ts +1 -0
  49. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  50. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  51. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  52. package/dist/types/sdk.d.ts +13 -2
  53. package/dist/types/session/agent-session.d.ts +43 -11
  54. package/dist/types/session/session-history-format.d.ts +10 -0
  55. package/dist/types/session/session-manager.d.ts +14 -0
  56. package/dist/types/session/streaming-output.d.ts +2 -0
  57. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  58. package/dist/types/slash-commands/types.d.ts +4 -4
  59. package/dist/types/system-prompt.d.ts +1 -1
  60. package/dist/types/task/label.d.ts +1 -1
  61. package/dist/types/telemetry-export.d.ts +34 -9
  62. package/dist/types/tools/approval.d.ts +8 -0
  63. package/dist/types/tools/bash.d.ts +2 -0
  64. package/dist/types/tools/browser/registry.d.ts +7 -3
  65. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  66. package/dist/types/tools/essential-tools.d.ts +29 -0
  67. package/dist/types/tools/image-gen.d.ts +2 -1
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/manage-skill.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +11 -2
  71. package/dist/types/utils/title-generator.d.ts +2 -1
  72. package/dist/types/web/search/index.d.ts +2 -0
  73. package/dist/types/web/search/providers/base.d.ts +3 -0
  74. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  75. package/dist/types/web/search/types.d.ts +1 -1
  76. package/package.json +21 -16
  77. package/src/advisor/__tests__/advisor.test.ts +1358 -86
  78. package/src/advisor/__tests__/config.test.ts +58 -2
  79. package/src/advisor/advise-tool.ts +7 -3
  80. package/src/advisor/config.ts +76 -24
  81. package/src/advisor/runtime.ts +445 -92
  82. package/src/autolearn/controller.ts +23 -28
  83. package/src/cli/bench-cli.ts +4 -1
  84. package/src/cli/grep-cli.ts +2 -1
  85. package/src/cli-commands.ts +57 -23
  86. package/src/cli.ts +5 -1
  87. package/src/config/model-discovery.ts +81 -21
  88. package/src/config/model-registry.ts +34 -6
  89. package/src/config/model-resolver.ts +57 -12
  90. package/src/config/models-config-schema.ts +1 -0
  91. package/src/config/settings-schema.ts +43 -6
  92. package/src/config/settings.ts +405 -25
  93. package/src/cursor.ts +20 -3
  94. package/src/debug/report-bundle.ts +40 -4
  95. package/src/discovery/codex.ts +14 -4
  96. package/src/discovery/helpers.ts +28 -5
  97. package/src/discovery/substitute-plugin-root.ts +23 -7
  98. package/src/exec/bash-executor.ts +14 -5
  99. package/src/export/html/template.js +2 -0
  100. package/src/extensibility/custom-tools/loader.ts +3 -3
  101. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  102. package/src/extensibility/extensions/loader.ts +3 -3
  103. package/src/extensibility/extensions/managed-timers.ts +83 -0
  104. package/src/extensibility/extensions/runner.ts +26 -0
  105. package/src/extensibility/extensions/types.ts +18 -0
  106. package/src/extensibility/extensions/wrapper.ts +2 -1
  107. package/src/extensibility/hooks/loader.ts +3 -3
  108. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  109. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  110. package/src/extensibility/plugins/manager.ts +2 -2
  111. package/src/extensibility/shared-events.ts +6 -0
  112. package/src/extensibility/utils.ts +91 -25
  113. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  114. package/src/internal-urls/agent-protocol.ts +73 -39
  115. package/src/irc/bus.ts +22 -3
  116. package/src/launch/broker.ts +3 -2
  117. package/src/launch/client.ts +2 -2
  118. package/src/launch/presence.ts +2 -2
  119. package/src/lib/xai-http.ts +28 -2
  120. package/src/lsp/client.ts +1 -1
  121. package/src/main.ts +11 -8
  122. package/src/mcp/manager.ts +9 -3
  123. package/src/mcp/transports/stdio.ts +103 -23
  124. package/src/modes/acp/acp-agent.ts +28 -9
  125. package/src/modes/acp/acp-event-mapper.ts +8 -0
  126. package/src/modes/acp/acp-mode.ts +18 -4
  127. package/src/modes/components/advisor-config.ts +65 -3
  128. package/src/modes/components/ask-dialog.ts +1 -1
  129. package/src/modes/components/assistant-message.ts +35 -14
  130. package/src/modes/components/chat-transcript-builder.ts +2 -0
  131. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  132. package/src/modes/components/custom-editor.ts +62 -1
  133. package/src/modes/components/hook-editor.ts +35 -5
  134. package/src/modes/components/login-dialog.test.ts +56 -0
  135. package/src/modes/components/login-dialog.ts +7 -3
  136. package/src/modes/components/model-hub.ts +138 -42
  137. package/src/modes/components/plan-review-overlay.ts +51 -10
  138. package/src/modes/components/session-selector.ts +10 -0
  139. package/src/modes/components/status-line/component.test.ts +1 -0
  140. package/src/modes/components/status-line/component.ts +2 -4
  141. package/src/modes/components/status-line/segments.ts +21 -6
  142. package/src/modes/components/tool-execution.test.ts +101 -0
  143. package/src/modes/components/tool-execution.ts +117 -9
  144. package/src/modes/components/transcript-container.ts +30 -2
  145. package/src/modes/controllers/command-controller-shared.ts +1 -1
  146. package/src/modes/controllers/command-controller.ts +168 -48
  147. package/src/modes/controllers/event-controller.ts +6 -0
  148. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  149. package/src/modes/controllers/input-controller.ts +12 -12
  150. package/src/modes/controllers/selector-controller.ts +208 -46
  151. package/src/modes/interactive-mode.ts +205 -65
  152. package/src/modes/noninteractive-dispose.test.ts +60 -0
  153. package/src/modes/print-mode.ts +10 -5
  154. package/src/modes/rpc/host-tools.ts +2 -1
  155. package/src/modes/rpc/rpc-mode.ts +31 -7
  156. package/src/modes/theme/theme.ts +2 -2
  157. package/src/modes/types.ts +10 -0
  158. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  159. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  160. package/src/modes/warp-events.test.ts +794 -0
  161. package/src/modes/warp-events.ts +232 -0
  162. package/src/plan-mode/model-transition.test.ts +60 -0
  163. package/src/plan-mode/model-transition.ts +51 -0
  164. package/src/prompts/system/system-prompt.md +1 -1
  165. package/src/registry/agent-lifecycle.ts +133 -18
  166. package/src/sdk.ts +227 -44
  167. package/src/session/agent-session.ts +1364 -382
  168. package/src/session/session-history-format.ts +20 -5
  169. package/src/session/session-listing.ts +6 -5
  170. package/src/session/session-loader.ts +9 -0
  171. package/src/session/session-manager.ts +48 -0
  172. package/src/session/session-persistence.ts +11 -0
  173. package/src/session/streaming-output.ts +3 -1
  174. package/src/slash-commands/builtin-registry.ts +9 -0
  175. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  176. package/src/slash-commands/types.ts +4 -4
  177. package/src/system-prompt.ts +2 -2
  178. package/src/task/executor.ts +1 -1
  179. package/src/task/label.ts +2 -0
  180. package/src/telemetry-export.ts +453 -97
  181. package/src/tools/approval.ts +11 -0
  182. package/src/tools/bash.ts +71 -38
  183. package/src/tools/browser/registry.ts +9 -2
  184. package/src/tools/browser/tab-supervisor.ts +65 -10
  185. package/src/tools/browser.ts +4 -3
  186. package/src/tools/essential-tools.ts +45 -0
  187. package/src/tools/eval.ts +3 -0
  188. package/src/tools/gh.ts +169 -2
  189. package/src/tools/image-gen.ts +624 -513
  190. package/src/tools/index.ts +10 -6
  191. package/src/tools/manage-skill.ts +5 -3
  192. package/src/tools/output-meta.ts +7 -0
  193. package/src/tools/path-utils.ts +8 -5
  194. package/src/tools/read.ts +48 -3
  195. package/src/tools/write.ts +22 -4
  196. package/src/tools/xdev.ts +14 -3
  197. package/src/utils/title-generator.ts +15 -4
  198. package/src/web/search/index.ts +21 -6
  199. package/src/web/search/providers/base.ts +3 -0
  200. package/src/web/search/providers/kimi.ts +18 -12
  201. package/src/web/search/providers/xai.ts +40 -9
  202. package/src/web/search/types.ts +6 -1
@@ -56,8 +56,15 @@ import { reset as resetCapabilities } from "../capability";
56
56
  import type { CollabGuestLink } from "../collab/guest";
57
57
  import type { CollabHost } from "../collab/host";
58
58
  import { KeybindingsManager } from "../config/keybindings";
59
+ import type { ResolvedModelRoleValue } from "../config/model-resolver";
59
60
  import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
60
- import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
61
+ import {
62
+ isSettingsInitialized,
63
+ onModelRolesChanged,
64
+ onStatusLineSessionAccentChanged,
65
+ Settings,
66
+ settings,
67
+ } from "../config/settings";
61
68
  import { clearClaudePluginRootsCache } from "../discovery/helpers";
62
69
  import type {
63
70
  AutocompleteProviderFactory,
@@ -88,6 +95,7 @@ import {
88
95
  resolveApprovedPlan,
89
96
  resolvePlanTitle,
90
97
  } from "../plan-mode/approved-plan";
98
+ import { resolvePlanModelTransition } from "../plan-mode/model-transition";
91
99
  import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
92
100
  import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
93
101
  type: "text",
@@ -519,6 +527,8 @@ export class InteractiveMode implements InteractiveModeContext {
519
527
  collabHost?: CollabHost;
520
528
  collabGuest?: CollabGuestLink;
521
529
 
530
+ #pendingCommandOutput: Component[] = [];
531
+ #pendingCommandOutputSessionId: string | undefined;
522
532
  #pendingSlashCommands: SlashCommand[] = [];
523
533
  /** Built-in editor autocomplete provider, before extension wrapping. */
524
534
  #baseAutocompleteProvider: AutocompleteProvider | undefined;
@@ -537,6 +547,8 @@ export class InteractiveMode implements InteractiveModeContext {
537
547
  #goalSuppressNextContinuation = false;
538
548
  #planModePreviousModelState: { model: Model; thinkingLevel?: ConfiguredThinkingLevel } | undefined;
539
549
  #pendingModelSwitch: { model: Model; thinkingLevel?: ConfiguredThinkingLevel } | undefined;
550
+ /** Whether #pendingModelSwitch was queued by the live plan-role reconciler. */
551
+ #pendingPlanModelSwitch = false;
540
552
  #planModeHasEntered = false;
541
553
  #planReviewOverlay: PlanReviewOverlay | undefined;
542
554
  #planReviewOverlayHandle: OverlayHandle | undefined;
@@ -733,15 +745,7 @@ export class InteractiveMode implements InteractiveModeContext {
733
745
  description: `${loaded.command.description} (${loaded.source})`,
734
746
  }));
735
747
 
736
- // Build skill commands from session.skills (if enabled)
737
- const skillCommandList: SlashCommand[] = [];
738
- if (settings.get("skills.enableSkillCommands")) {
739
- for (const skill of this.session.skills) {
740
- const commandName = `skill:${skill.name}`;
741
- this.skillCommands.set(commandName, skill);
742
- skillCommandList.push({ name: commandName, description: skill.description });
743
- }
744
- }
748
+ const skillCommandList = this.#rebuildSkillCommandsFromSession();
745
749
 
746
750
  const builtinCommands = buildTuiBuiltinSlashCommands({ ctx: this });
747
751
  // Store pending commands for init() where file commands are loaded async
@@ -1029,6 +1033,18 @@ export class InteractiveMode implements InteractiveModeContext {
1029
1033
  this.#handleSessionAccentInputsChanged();
1030
1034
  }),
1031
1035
  );
1036
+ this.#eventBusUnsubscribers.push(
1037
+ onModelRolesChanged(() => {
1038
+ void this.#reapplyPlanModeModelOnRoleChange();
1039
+ }),
1040
+ );
1041
+ this.#eventBusUnsubscribers.push(
1042
+ this.session.subscribeCommandMetadataChanged(() => {
1043
+ const retainedCommands = this.#pendingSlashCommands.filter(command => !command.name.startsWith("skill:"));
1044
+ const skillCommands = this.#rebuildSkillCommandsFromSession();
1045
+ this.#pendingSlashCommands = [...retainedCommands, ...skillCommands];
1046
+ }),
1047
+ );
1032
1048
  // Set up theme file watcher
1033
1049
  this.#eventBusUnsubscribers.push(
1034
1050
  onThemeChange(event => {
@@ -1077,6 +1093,27 @@ export class InteractiveMode implements InteractiveModeContext {
1077
1093
  this.session.setTitleSystemPrompt(resolved);
1078
1094
  }
1079
1095
 
1096
+ #rebuildSkillCommandsFromSession(): SlashCommand[] {
1097
+ const commands: SlashCommand[] = [];
1098
+ this.skillCommands.clear();
1099
+ if (this.session.skillsSettings?.enableSkillCommands !== false) {
1100
+ for (const skill of this.session.skills) {
1101
+ const commandName = `skill:${skill.name}`;
1102
+ this.skillCommands.set(commandName, skill);
1103
+ commands.push({ name: commandName, description: skill.description });
1104
+ }
1105
+ }
1106
+ return commands;
1107
+ }
1108
+
1109
+ /** Reload session skills and the `/skill:<name>` command list. */
1110
+ async refreshSkillState(): Promise<void> {
1111
+ await this.session.refreshSkills();
1112
+ const retainedCommands = this.#pendingSlashCommands.filter(command => !command.name.startsWith("skill:"));
1113
+ const skillCommands = this.#rebuildSkillCommandsFromSession();
1114
+ this.#pendingSlashCommands = [...retainedCommands, ...skillCommands];
1115
+ }
1116
+
1080
1117
  /** Reload slash commands and autocomplete for the provided working directory. */
1081
1118
  async refreshSlashCommandState(cwd?: string): Promise<void> {
1082
1119
  const basePath = cwd ?? this.sessionManager.getCwd();
@@ -1175,6 +1212,7 @@ export class InteractiveMode implements InteractiveModeContext {
1175
1212
  clearClaudePluginRootsCache();
1176
1213
  await this.refreshTitleSystemPrompt(newCwd);
1177
1214
  resetCapabilities();
1215
+ await this.refreshSkillState();
1178
1216
  await this.refreshSlashCommandState(newCwd);
1179
1217
  setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
1180
1218
  this.statusLine.invalidate();
@@ -2057,35 +2095,81 @@ export class InteractiveMode implements InteractiveModeContext {
2057
2095
  if (!resolved.model) return;
2058
2096
 
2059
2097
  const currentModel = this.session.model;
2060
- const sameModel = modelsAreEqual(currentModel, resolved.model);
2061
- const planThinkingLevel = resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined;
2062
-
2098
+ // Capture the pre-plan model so #exitPlanMode can restore it. Only the
2099
+ // entry path records this a mid-planning role change (below) leaves the
2100
+ // active model on the plan role, so overwriting here would restore the old
2101
+ // plan model instead of the user's real pre-plan model.
2063
2102
  this.#planModePreviousModelState = currentModel
2064
2103
  ? { model: currentModel, thinkingLevel: this.session.configuredThinkingLevel() }
2065
2104
  : undefined;
2066
2105
 
2067
- if (!sameModel) {
2068
- if (this.session.isStreaming) {
2069
- this.#pendingModelSwitch = { model: resolved.model, thinkingLevel: planThinkingLevel };
2106
+ await this.#applyPlanModelTransition(currentModel, resolved);
2107
+ }
2108
+
2109
+ /**
2110
+ * Re-resolve the `plan` role and move the active model onto it. Fires when
2111
+ * the plan role is reassigned while plan mode is active: the active model IS
2112
+ * the plan model there, so a settings-only change would otherwise leave the
2113
+ * current turn on the model plan mode was entered with (issue #5657). No-op
2114
+ * outside plan mode — role reassignment for an inactive role only touches
2115
+ * settings.
2116
+ */
2117
+ async #reapplyPlanModeModelOnRoleChange(): Promise<void> {
2118
+ if (!this.planModeEnabled) return;
2119
+ const resolved = this.session.resolveRoleModelWithThinking("plan");
2120
+ if (!resolved.model) {
2121
+ this.#clearPendingPlanModelSwitch();
2122
+ return;
2123
+ }
2124
+ await this.#applyPlanModelTransition(this.session.model, resolved);
2125
+ }
2126
+
2127
+ /**
2128
+ * Drop a stale deferred switch that was queued for a previous plan-role
2129
+ * assignment. Other deferred switches (such as restoring the pre-plan
2130
+ * model) remain intact.
2131
+ */
2132
+ #clearPendingPlanModelSwitch(): void {
2133
+ if (!this.#pendingPlanModelSwitch) return;
2134
+ this.#pendingModelSwitch = undefined;
2135
+ this.#pendingPlanModelSwitch = false;
2136
+ }
2137
+
2138
+ /** Apply (or defer) the model/thinking change implied by the resolved plan role. */
2139
+ async #applyPlanModelTransition(currentModel: Model | undefined, resolved: ResolvedModelRoleValue): Promise<void> {
2140
+ const transition = resolvePlanModelTransition(currentModel, resolved, this.session.isStreaming);
2141
+ if (transition.kind !== "apply" || !transition.deferred) {
2142
+ this.#clearPendingPlanModelSwitch();
2143
+ }
2144
+ switch (transition.kind) {
2145
+ case "none":
2146
+ return;
2147
+ case "thinking":
2148
+ this.session.setThinkingLevel(transition.thinkingLevel);
2149
+ return;
2150
+ case "apply":
2151
+ if (transition.deferred) {
2152
+ this.#pendingModelSwitch = { model: transition.model, thinkingLevel: transition.thinkingLevel };
2153
+ this.#pendingPlanModelSwitch = true;
2154
+ return;
2155
+ }
2156
+ try {
2157
+ await this.session.setModelTemporary(transition.model, transition.thinkingLevel);
2158
+ } catch (error) {
2159
+ this.showWarning(
2160
+ `Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
2161
+ );
2162
+ }
2070
2163
  return;
2071
- }
2072
- try {
2073
- await this.session.setModelTemporary(resolved.model, planThinkingLevel);
2074
- } catch (error) {
2075
- this.showWarning(
2076
- `Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
2077
- );
2078
- }
2079
- } else if (planThinkingLevel) {
2080
- this.session.setThinkingLevel(planThinkingLevel);
2081
2164
  }
2082
2165
  }
2083
2166
 
2084
2167
  /** Apply any deferred model switch after the current stream ends. */
2085
2168
  async flushPendingModelSwitch(): Promise<void> {
2086
2169
  const pending = this.#pendingModelSwitch;
2087
- if (!pending) return;
2088
2170
  this.#pendingModelSwitch = undefined;
2171
+ this.#pendingPlanModelSwitch = false;
2172
+ if (!pending) return;
2089
2173
  try {
2090
2174
  await this.session.setModelTemporary(pending.model, pending.thinkingLevel);
2091
2175
  } catch (error) {
@@ -2097,19 +2181,23 @@ export class InteractiveMode implements InteractiveModeContext {
2097
2181
 
2098
2182
  async #clearTransientModeState(): Promise<void> {
2099
2183
  if (this.planModeEnabled || this.planModePaused) {
2100
- if (this.#planModePreviousTools !== undefined) {
2101
- await this.session.setActiveToolsByName(this.#planModePreviousTools);
2102
- }
2103
- this.session.setPlanProposalHandler?.(null);
2104
2184
  this.session.setPlanModeState(undefined);
2105
- this.planModeEnabled = false;
2106
- this.planModePaused = false;
2107
- this.planModePlanFilePath = undefined;
2108
- this.#planModePreviousTools = undefined;
2109
- this.#planModePreviousModelState = undefined;
2110
- this.#pendingModelSwitch = undefined;
2111
- this.#planModeHasEntered = false;
2112
- this.#updatePlanModeStatus();
2185
+ try {
2186
+ if (this.#planModePreviousTools !== undefined) {
2187
+ await this.session.setActiveToolsByName(this.#planModePreviousTools);
2188
+ }
2189
+ } finally {
2190
+ this.session.setPlanProposalHandler?.(null);
2191
+ this.planModeEnabled = false;
2192
+ this.planModePaused = false;
2193
+ this.planModePlanFilePath = undefined;
2194
+ this.#planModePreviousTools = undefined;
2195
+ this.#planModePreviousModelState = undefined;
2196
+ this.#pendingModelSwitch = undefined;
2197
+ this.#pendingPlanModelSwitch = false;
2198
+ this.#planModeHasEntered = false;
2199
+ this.#updatePlanModeStatus();
2200
+ }
2113
2201
  }
2114
2202
 
2115
2203
  if (this.goalModeEnabled || this.goalModePaused) {
@@ -2291,6 +2379,7 @@ export class InteractiveMode implements InteractiveModeContext {
2291
2379
  this.session.setThinkingLevel(prev.thinkingLevel);
2292
2380
  } else if (this.session.isStreaming) {
2293
2381
  this.#pendingModelSwitch = { model: prev.model, thinkingLevel: prev.thinkingLevel };
2382
+ this.#pendingPlanModelSwitch = false;
2294
2383
  } else {
2295
2384
  await this.session.setModelTemporary(prev.model, prev.thinkingLevel);
2296
2385
  }
@@ -2322,33 +2411,30 @@ export class InteractiveMode implements InteractiveModeContext {
2322
2411
  return;
2323
2412
  }
2324
2413
 
2325
- const previousTools = this.#planModePreviousTools;
2326
- if (previousTools && previousTools.length > 0) {
2327
- await this.session.setActiveToolsByName(previousTools);
2328
- }
2329
- if (this.#planModePreviousModelState) {
2330
- if (!options?.deferModelRestore) {
2331
- await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
2414
+ const planModeState = this.session.getPlanModeState();
2415
+ this.session.setPlanModeState(undefined);
2416
+ try {
2417
+ if (this.#planModePreviousTools !== undefined) {
2418
+ await this.session.setActiveToolsByName(this.#planModePreviousTools);
2332
2419
  }
2333
- // If #applyPlanModeModel queued a deferred switch to the plan-role model
2334
- // (because the session was streaming on entry), drop it now: we are
2335
- // leaving plan mode, so flushing it on the next agent_end would land the
2336
- // session on the plan-role model after the user has exited plan mode
2337
- // (issue #816). This runs even when deferModelRestore is set
2338
- // (compact-approval path): otherwise the stale plan switch survives and
2339
- // flushPendingModelSwitch() later clobbers the restored/execution model.
2340
- // Only clear when the pending target matches the plan-role model — leave
2341
- // any unrelated user-queued switch intact.
2342
- const pending = this.#pendingModelSwitch;
2343
- if (pending) {
2344
- const planResolution = this.session.resolveRoleModelWithThinking("plan");
2345
- if (planResolution.model && modelsAreEqual(pending.model, planResolution.model)) {
2346
- this.#pendingModelSwitch = undefined;
2420
+ if (this.#planModePreviousModelState) {
2421
+ if (!options?.deferModelRestore) {
2422
+ await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
2347
2423
  }
2424
+ // If #applyPlanModeModel queued a deferred switch to the plan-role model
2425
+ // (because the session was streaming on entry), drop it now: we are
2426
+ // leaving plan mode, so flushing it on the next agent_end would land the
2427
+ // session on the plan-role model after the user has exited plan mode
2428
+ // (issue #816). This runs even when deferModelRestore is set
2429
+ // (compact-approval path): otherwise the stale plan switch survives and
2430
+ // flushPendingModelSwitch() later clobbers the restored/execution model.
2431
+ this.#clearPendingPlanModelSwitch();
2348
2432
  }
2433
+ } catch (error) {
2434
+ this.session.setPlanModeState(planModeState);
2435
+ throw error;
2349
2436
  }
2350
2437
  this.session.setPlanProposalHandler?.(null);
2351
- this.session.setPlanModeState(undefined);
2352
2438
  this.planModeEnabled = false;
2353
2439
  // Suppress cache-miss marker on the next turn: plan exit changes the system
2354
2440
  // prompt, which predictably invalidates the cache.
@@ -2498,8 +2584,6 @@ export class InteractiveMode implements InteractiveModeContext {
2498
2584
  const finish = (choice: string | undefined): void => {
2499
2585
  if (settled) return;
2500
2586
  settled = true;
2501
- this.#hidePlanReview();
2502
- this.ui.requestRender();
2503
2587
  resolve(choice);
2504
2588
  };
2505
2589
  const overlay = new PlanReviewOverlay(
@@ -2530,6 +2614,7 @@ export class InteractiveMode implements InteractiveModeContext {
2530
2614
  maxHeight: "100%",
2531
2615
  margin: 0,
2532
2616
  fullscreen: true,
2617
+ mouseTracking: false,
2533
2618
  });
2534
2619
  this.ui.setFocus(overlay);
2535
2620
  this.ui.requestRender();
@@ -2860,6 +2945,18 @@ export class InteractiveMode implements InteractiveModeContext {
2860
2945
  planFilePath: options.planFilePath,
2861
2946
  contextPreserved: options.preserveContext === true,
2862
2947
  });
2948
+ // Close the review overlay only now — after the async title write and plan
2949
+ // prompt are prepared, immediately before the execution turn is queued. The
2950
+ // synthetic prompt below blocks in `session.prompt` for the whole run, so
2951
+ // hiding here (rather than after #approvePlan returns) keeps the operator off
2952
+ // the stale plan-review screen (issue #5688) while #5319's stale-buffer guard
2953
+ // stays intact. Deferring the hide past the awaited `setSessionName` also
2954
+ // prevents restored editor focus from letting operator keystrokes submit a
2955
+ // normal turn ahead of the approved execution turn (PR #5689 review).
2956
+ // `#hidePlanReview` is idempotent, so the caller's trailing `closePlanReview()`
2957
+ // — and the cancelled/error early returns above — stay safe no-ops.
2958
+ this.#hidePlanReview();
2959
+ this.ui.requestRender();
2863
2960
  // A user turn queued during compaction was already fired by
2864
2961
  // `flushCompactionQueue` before we returned from `handleCompactCommand`; the
2865
2962
  // old abort-then-prompt path would have discarded that operator turn AND
@@ -3423,6 +3520,10 @@ export class InteractiveMode implements InteractiveModeContext {
3423
3520
  },
3424
3521
  { slider },
3425
3522
  );
3523
+ const closePlanReview = (): void => {
3524
+ this.#hidePlanReview();
3525
+ this.ui.requestRender();
3526
+ };
3426
3527
 
3427
3528
  if (choice === "Approve and execute" || choice === "Approve and compact context" || choice === keepContextLabel) {
3428
3529
  try {
@@ -3435,6 +3536,7 @@ export class InteractiveMode implements InteractiveModeContext {
3435
3536
  }
3436
3537
  if (!latestPlanContent) {
3437
3538
  this.showError(`Plan file not found at ${planFilePath}`);
3539
+ closePlanReview();
3438
3540
  return;
3439
3541
  }
3440
3542
  // Capture the operator's tier choice and hand it to #approvePlan, which
@@ -3477,6 +3579,7 @@ export class InteractiveMode implements InteractiveModeContext {
3477
3579
  `Failed to finalize approved plan: ${error instanceof Error ? error.message : String(error)}`,
3478
3580
  );
3479
3581
  }
3582
+ closePlanReview();
3480
3583
  return;
3481
3584
  }
3482
3585
 
@@ -3495,8 +3598,10 @@ export class InteractiveMode implements InteractiveModeContext {
3495
3598
  } catch (error) {
3496
3599
  this.showError(`Failed to refine plan: ${error instanceof Error ? error.message : String(error)}`);
3497
3600
  }
3601
+ closePlanReview();
3498
3602
  return;
3499
3603
  }
3604
+ closePlanReview();
3500
3605
  }
3501
3606
 
3502
3607
  /**
@@ -3711,6 +3816,32 @@ export class InteractiveMode implements InteractiveModeContext {
3711
3816
  this.ui.requestRender();
3712
3817
  }
3713
3818
 
3819
+ /** Defer transcript command panels until the active turn can no longer grow above them. */
3820
+ presentCommandOutput(content: Component | readonly Component[]): void {
3821
+ if (!this.session.isStreaming) {
3822
+ this.present(content);
3823
+ return;
3824
+ }
3825
+ const sessionId = this.sessionManager.getSessionId();
3826
+ if (this.#pendingCommandOutput.length > 0 && this.#pendingCommandOutputSessionId !== sessionId) {
3827
+ this.#pendingCommandOutput = [];
3828
+ }
3829
+ this.#pendingCommandOutputSessionId = sessionId;
3830
+ const items = Array.isArray(content) ? content : [content as Component];
3831
+ this.#pendingCommandOutput.push(...items);
3832
+ }
3833
+
3834
+ /** Mount every command panel queued for the current session while the agent was streaming. */
3835
+ flushPendingCommandOutput(): void {
3836
+ if (this.#pendingCommandOutput.length === 0) return;
3837
+ const pending = this.#pendingCommandOutput;
3838
+ const pendingSessionId = this.#pendingCommandOutputSessionId;
3839
+ this.#pendingCommandOutput = [];
3840
+ this.#pendingCommandOutputSessionId = undefined;
3841
+ if (pendingSessionId !== this.sessionManager.getSessionId()) return;
3842
+ this.present(pending);
3843
+ }
3844
+
3714
3845
  #mountChatChild(item: Component): void {
3715
3846
  this.chatContainer.addChild(item);
3716
3847
  if (item instanceof ChatBlock) item.mount(this.#chatHost);
@@ -4255,11 +4386,20 @@ export class InteractiveMode implements InteractiveModeContext {
4255
4386
  this.#selectorController.showSessionSelector();
4256
4387
  }
4257
4388
 
4258
- handleResumeSession(sessionPath: string): Promise<void> {
4389
+ async handleResumeSession(sessionPath: string): Promise<void> {
4390
+ // Flush pending settings writes *before* disposing controllers or resetting
4391
+ // observers: a save failure must leave the session, process project dir,
4392
+ // and Settings in the source scope with all UI intact.
4393
+ try {
4394
+ await this.settings.flush();
4395
+ } catch (err) {
4396
+ this.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`);
4397
+ return;
4398
+ }
4259
4399
  this.#btwController.dispose();
4260
4400
  this.#omfgController.dispose();
4261
4401
  this.resetObserverRegistry();
4262
- return this.#selectorController.handleResumeSession(sessionPath);
4402
+ await this.#selectorController.handleResumeSession(sessionPath, { settingsFlushed: true });
4263
4403
  }
4264
4404
 
4265
4405
  handleSessionDeleteCommand(): Promise<void> {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Contract: the print-mode assistant-error/aborted exit path MUST run the
3
+ * awaited `session.dispose()` (which contains the bounded browser reaper
4
+ * `releaseTabsForOwner`) before terminating the process. It previously called
5
+ * `process.exit(1)` ahead of the `dispose()` at the end of `runPrintMode`, so
6
+ * an OMP-owned Chromium survived the exit (issue #5643).
7
+ */
8
+ import { describe, expect, it, spyOn } from "bun:test";
9
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
10
+ import type { AgentSession } from "../session/agent-session";
11
+ import { runPrintMode } from "./print-mode";
12
+
13
+ /** Stand-in for `process.exit`: it terminates, so nothing after it should run. */
14
+ class ProcessExit extends Error {
15
+ constructor(readonly code: number) {
16
+ super(`process.exit(${code})`);
17
+ }
18
+ }
19
+
20
+ describe("print-mode error exit disposes the session before exit", () => {
21
+ it("disposes on the assistant-error path before process.exit(1)", async () => {
22
+ const order: string[] = [];
23
+ const errorMsg: AssistantMessage = {
24
+ role: "assistant",
25
+ content: [],
26
+ api: "openai-responses",
27
+ provider: "openai",
28
+ model: "gpt-test",
29
+ usage: {} as AssistantMessage["usage"],
30
+ stopReason: "error",
31
+ errorMessage: "boom",
32
+ timestamp: 1,
33
+ };
34
+ const session = {
35
+ extensionRunner: undefined,
36
+ subscribe: () => {},
37
+ state: { messages: [errorMsg] },
38
+ dispose: async () => {
39
+ order.push("dispose");
40
+ },
41
+ } as unknown as AgentSession;
42
+
43
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code: number) => {
44
+ order.push("exit");
45
+ throw new ProcessExit(code);
46
+ }) as never);
47
+ const stderrSpy = spyOn(process.stderr, "write").mockImplementation((() => true) as never);
48
+
49
+ try {
50
+ await runPrintMode(session, { mode: "text" });
51
+ } catch (err) {
52
+ if (!(err instanceof ProcessExit)) throw err;
53
+ } finally {
54
+ exitSpy.mockRestore();
55
+ stderrSpy.mockRestore();
56
+ }
57
+
58
+ expect(order).toEqual(["dispose", "exit"]);
59
+ });
60
+ });
@@ -8,7 +8,7 @@
8
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
9
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
10
10
  import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
11
- import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
11
+ import { type AgentSession, type AgentSessionEvent, SHUTDOWN_CONSOLIDATE_BUDGET_MS } from "../session/agent-session";
12
12
  import { isSilentAbort } from "../session/messages";
13
13
  import { flushTelemetryExport } from "../telemetry-export";
14
14
  import { initializeExtensions } from "./runtime-init";
@@ -144,10 +144,15 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
144
144
  !isSilentAbort(assistantMsg)
145
145
  ) {
146
146
  const errorLine = sanitizeText(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
147
- // Flush before this hard exit — it bypasses the awaited postmortem.quit()
148
- // in main(), and the postmortem `exit` handler can't await, so the error
149
- // spans would otherwise stay buffered in the batch processor and drop.
147
+ // This branch hard-exits, bypassing the `await session.dispose()` at
148
+ // the end of runPrintMode. Flush telemetry and dispose the session
149
+ // HERE so error spans reach the exporter (the postmortem `exit`
150
+ // handler can't await) and the browser reaper installed in
151
+ // `dispose()` (releaseTabsForOwner) actually runs — otherwise an
152
+ // OMP-owned Chromium survives this exit (issue #5643). `dispose()`
153
+ // is idempotent, so the unreachable call below is a harmless no-op.
150
154
  await flushTelemetryExport();
155
+ await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
151
156
  const flushed = process.stderr.write(`${errorLine}\n`);
152
157
  if (flushed) {
153
158
  process.exit(1);
@@ -184,5 +189,5 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
184
189
  });
185
190
  });
186
191
 
187
- await session.dispose();
192
+ await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
188
193
  }
@@ -3,6 +3,7 @@ import type { Static, TSchema } from "@oh-my-pi/pi-ai";
3
3
  import { Snowflake } from "@oh-my-pi/pi-utils";
4
4
  import { applyToolProxy } from "../../extensibility/tool-proxy";
5
5
  import type { Theme } from "../../modes/theme/theme";
6
+ import { defaultLoadModeForToolName } from "../../tools/essential-tools";
6
7
  import type {
7
8
  RpcHostToolCallRequest,
8
9
  RpcHostToolCancelRequest,
@@ -54,7 +55,7 @@ class RpcHostToolAdapter<TParams extends TSchema = TSchema, TTheme extends Theme
54
55
  this.#definition = definition;
55
56
  this.#bridge = bridge;
56
57
  applyToolProxy(definition, this);
57
- this.loadMode = definition.loadMode ?? "discoverable";
58
+ this.loadMode = defaultLoadModeForToolName(definition.name, definition.loadMode);
58
59
  }
59
60
 
60
61
  execute(
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
14
14
  import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
15
- import { $env, isRecord, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
15
+ import { $env, isRecord, readLines, Snowflake } from "@oh-my-pi/pi-utils";
16
16
  import { reset as resetCapabilities } from "../../capability";
17
17
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
18
18
  import {
@@ -29,6 +29,7 @@ import type { AgentSession } from "../../session/agent-session";
29
29
  import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../../session/messages";
30
30
  import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
31
31
  import { buildAvailableSlashCommands } from "../../slash-commands/available-commands";
32
+ import { defaultLoadModeForToolName } from "../../tools/essential-tools";
32
33
  import type { EventBus } from "../../utils/event-bus";
33
34
  import { initializeExtensions } from "../runtime-init";
34
35
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
@@ -507,7 +508,7 @@ function normalizeHostToolDefinitions(tools: RpcHostToolDefinition[]): RpcHostTo
507
508
  description,
508
509
  parameters: tool.parameters,
509
510
  hidden: tool.hidden === true,
510
- loadMode: tool.loadMode ?? "discoverable",
511
+ loadMode: defaultLoadModeForToolName(name, tool.loadMode),
511
512
  };
512
513
  });
513
514
  }
@@ -914,6 +915,7 @@ export async function runRpcMode(
914
915
  const projectPath = await resolveActiveProjectRegistryPath(cwd);
915
916
  clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
916
917
  resetCapabilities();
918
+ await session.refreshSkills();
917
919
  session.setSlashCommands(await loadSlashCommands({ cwd }));
918
920
  await emitAvailableCommandsUpdate();
919
921
  };
@@ -1346,9 +1348,12 @@ export async function runRpcMode(
1346
1348
  const shutdownCoordinator = new RpcShutdownCoordinator({
1347
1349
  isShutdownRequested: () => shutdownState.requested,
1348
1350
  performShutdown: async () => {
1349
- if (session.extensionRunner?.hasHandlers("session_shutdown")) {
1350
- await session.extensionRunner.emit({ type: "session_shutdown" });
1351
- }
1351
+ // Route through the idempotent session.dispose() so the browser
1352
+ // reaper (releaseTabsForOwner) and other bounded teardown run before
1353
+ // the process exits. dispose() also emits `session_shutdown`, so we
1354
+ // must NOT emit it separately here or the event fires twice. Skipping
1355
+ // dispose left OMP-owned Chromium alive after RPC shutdown (#5643).
1356
+ await session.dispose();
1352
1357
  process.exit(0);
1353
1358
  },
1354
1359
  });
@@ -1371,8 +1376,22 @@ export async function runRpcMode(
1371
1376
 
1372
1377
  // Keep the stdin reader moving: side-channel frames dispatch immediately,
1373
1378
  // ordinary commands serialize through inputDispatcher, and bash remains
1374
- // background-dispatched so abort_bash can overtake it.
1375
- for await (const parsed of readJsonl(Bun.stdin.stream())) {
1379
+ // background-dispatched so abort_bash can overtake it. Frames are read
1380
+ // line-by-line and parsed here (not via readJsonl) so a single malformed
1381
+ // line is reported as an error frame and the loop keeps running instead of
1382
+ // throwing out of the generator and killing the whole process (issue #5194).
1383
+ const decoder = new TextDecoder();
1384
+ for await (const line of readLines(Bun.stdin.stream())) {
1385
+ const text = decoder.decode(line).trim();
1386
+ if (!text) continue;
1387
+ let parsed: unknown;
1388
+ try {
1389
+ parsed = JSON.parse(text);
1390
+ } catch (e: unknown) {
1391
+ const message = e instanceof Error ? e.message : String(e);
1392
+ output(error(undefined, "parse", `Failed to parse command: ${message}`));
1393
+ continue;
1394
+ }
1376
1395
  inputDispatcher.dispatch(parsed);
1377
1396
  }
1378
1397
 
@@ -1384,5 +1403,10 @@ export async function runRpcMode(
1384
1403
  await inputDispatcher.drain();
1385
1404
  await shutdownCoordinator.drain();
1386
1405
  subagentRegistry?.dispose();
1406
+ // Dispose the main session before exiting so the browser reaper and other
1407
+ // bounded teardown run on the stdin-EOF path too (#5643). Idempotent: a
1408
+ // prior pi.shutdown() through the coordinator makes this await settle
1409
+ // immediately.
1410
+ await session.dispose();
1387
1411
  process.exit(0);
1388
1412
  }
@@ -614,8 +614,8 @@ const NERD_SYMBOLS: SymbolMap = {
614
614
  "icon.throughput": "\uf0e4",
615
615
  // pick:  | alt:  
616
616
  "icon.host": "\uf109",
617
- // pick: | alt:  
618
- "icon.session": "\uf550",
617
+ // pick: 󰁑 (nf-md-arrow_left_bold_hexagon_outline) | alt:  
618
+ "icon.session": "\u{f0051}",
619
619
  // pick:  | alt: 
620
620
  "icon.package": "\uf487",
621
621
  // pick:  | alt:  
@@ -237,6 +237,14 @@ export interface InteractiveModeContext {
237
237
  * runs) so their timers/subscriptions start.
238
238
  */
239
239
  present(content: Component | readonly Component[]): void;
240
+ /**
241
+ * Mount command output immediately while idle, or defer it until the active
242
+ * agent turn ends so a growing live block cannot push duplicate rows into
243
+ * native scrollback.
244
+ */
245
+ presentCommandOutput(content: Component | readonly Component[]): void;
246
+ /** Mount command output deferred by {@link presentCommandOutput}. */
247
+ flushPendingCommandOutput(): void;
240
248
  /**
241
249
  * Dispose every live block in the transcript (stopping timers/subscriptions)
242
250
  * and clear it. Used before a full rebuild so animated/streaming blocks do not
@@ -347,6 +355,8 @@ export interface InteractiveModeContext {
347
355
  ): Promise<CompactionOutcome>;
348
356
  openInBrowser(urlOrPath: string): void;
349
357
  refreshSlashCommandState(cwd?: string): Promise<void>;
358
+ /** Reload session skills and derived `/skill:<name>` commands. */
359
+ refreshSkillState(): Promise<void>;
350
360
  applyCwdChange(newCwd: string): Promise<void>;
351
361
 
352
362
  // Selector handling