@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3

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 (179) hide show
  1. package/CHANGELOG.md +162 -40
  2. package/dist/cli.js +4559 -4543
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +39 -10
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/eval/agent-bridge.d.ts +7 -19
  13. package/dist/types/exec/bash-executor.d.ts +2 -0
  14. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  15. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  17. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
  18. package/dist/types/extensibility/shared-events.d.ts +6 -0
  19. package/dist/types/extensibility/utils.d.ts +2 -2
  20. package/dist/types/lsp/client.d.ts +2 -0
  21. package/dist/types/lsp/types.d.ts +3 -0
  22. package/dist/types/mcp/transports/stdio.d.ts +42 -1
  23. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  24. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  25. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  26. package/dist/types/modes/components/model-hub.d.ts +5 -2
  27. package/dist/types/modes/components/session-selector.d.ts +2 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  30. package/dist/types/modes/print-mode.d.ts +1 -1
  31. package/dist/types/modes/warp-events.d.ts +24 -0
  32. package/dist/types/modes/warp-events.test.d.ts +1 -0
  33. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  34. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  35. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  36. package/dist/types/registry/persisted-agents.d.ts +3 -0
  37. package/dist/types/sdk.d.ts +27 -5
  38. package/dist/types/session/agent-session.d.ts +34 -10
  39. package/dist/types/session/session-entries.d.ts +6 -1
  40. package/dist/types/session/session-history-format.d.ts +10 -0
  41. package/dist/types/session/session-manager.d.ts +19 -0
  42. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  43. package/dist/types/task/executor.d.ts +26 -1
  44. package/dist/types/task/index.d.ts +1 -1
  45. package/dist/types/task/label.d.ts +1 -1
  46. package/dist/types/task/parallel.d.ts +14 -0
  47. package/dist/types/task/structured-subagent.d.ts +111 -0
  48. package/dist/types/task/types.d.ts +51 -0
  49. package/dist/types/telemetry-export.d.ts +34 -9
  50. package/dist/types/tools/approval.d.ts +8 -0
  51. package/dist/types/tools/bash.d.ts +2 -0
  52. package/dist/types/tools/essential-tools.d.ts +29 -0
  53. package/dist/types/tools/fetch.d.ts +4 -5
  54. package/dist/types/tools/hub/messaging.d.ts +5 -1
  55. package/dist/types/tools/image-gen.d.ts +2 -1
  56. package/dist/types/tools/index.d.ts +17 -1
  57. package/dist/types/tools/todo.d.ts +31 -0
  58. package/dist/types/tools/xdev.d.ts +11 -2
  59. package/dist/types/tui/tree-list.d.ts +7 -0
  60. package/dist/types/utils/markit.d.ts +11 -0
  61. package/dist/types/utils/title-generator.d.ts +2 -1
  62. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  63. package/dist/types/web/search/types.d.ts +1 -1
  64. package/package.json +21 -16
  65. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  66. package/src/advisor/__tests__/config.test.ts +58 -2
  67. package/src/advisor/config.ts +76 -24
  68. package/src/advisor/runtime.ts +445 -92
  69. package/src/autolearn/controller.ts +23 -28
  70. package/src/cli/file-processor.ts +1 -2
  71. package/src/cli.ts +5 -1
  72. package/src/config/model-discovery.ts +81 -21
  73. package/src/config/model-registry.ts +25 -6
  74. package/src/config/model-resolver.ts +14 -7
  75. package/src/config/settings-schema.ts +46 -9
  76. package/src/config/settings.ts +405 -25
  77. package/src/cursor.ts +20 -3
  78. package/src/debug/report-bundle.ts +40 -4
  79. package/src/discovery/helpers.ts +28 -5
  80. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  81. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  82. package/src/eval/agent-bridge.ts +104 -477
  83. package/src/eval/jl/prelude.jl +7 -6
  84. package/src/eval/js/shared/prelude.txt +5 -4
  85. package/src/eval/py/prelude.py +11 -39
  86. package/src/eval/rb/prelude.rb +5 -6
  87. package/src/exec/bash-executor.ts +14 -5
  88. package/src/extensibility/custom-tools/loader.ts +3 -3
  89. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  90. package/src/extensibility/extensions/loader.ts +3 -3
  91. package/src/extensibility/extensions/managed-timers.ts +83 -0
  92. package/src/extensibility/extensions/runner.ts +26 -0
  93. package/src/extensibility/extensions/types.ts +18 -0
  94. package/src/extensibility/extensions/wrapper.ts +2 -1
  95. package/src/extensibility/hooks/loader.ts +3 -3
  96. package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
  97. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  98. package/src/extensibility/plugins/manager.ts +2 -2
  99. package/src/extensibility/shared-events.ts +6 -0
  100. package/src/extensibility/utils.ts +91 -25
  101. package/src/irc/bus.ts +22 -3
  102. package/src/launch/broker.ts +3 -2
  103. package/src/launch/client.ts +2 -2
  104. package/src/launch/presence.ts +2 -2
  105. package/src/lsp/client.ts +58 -1
  106. package/src/lsp/index.ts +62 -6
  107. package/src/lsp/types.ts +3 -0
  108. package/src/main.ts +11 -8
  109. package/src/mcp/manager.ts +9 -3
  110. package/src/mcp/oauth-flow.ts +20 -0
  111. package/src/mcp/transports/stdio.test.ts +269 -1
  112. package/src/mcp/transports/stdio.ts +255 -24
  113. package/src/modes/components/advisor-config.ts +65 -3
  114. package/src/modes/components/agent-hub.ts +1 -72
  115. package/src/modes/components/ask-dialog.ts +1 -1
  116. package/src/modes/components/bash-execution.ts +7 -3
  117. package/src/modes/components/eval-execution.ts +3 -1
  118. package/src/modes/components/hook-editor.ts +18 -3
  119. package/src/modes/components/model-hub.ts +138 -42
  120. package/src/modes/components/session-selector.ts +4 -0
  121. package/src/modes/components/status-line/component.test.ts +1 -0
  122. package/src/modes/components/status-line/segments.ts +21 -6
  123. package/src/modes/controllers/command-controller.ts +167 -47
  124. package/src/modes/controllers/event-controller.ts +5 -0
  125. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  126. package/src/modes/controllers/input-controller.ts +12 -12
  127. package/src/modes/controllers/selector-controller.ts +191 -31
  128. package/src/modes/interactive-mode.ts +169 -62
  129. package/src/modes/print-mode.ts +3 -3
  130. package/src/modes/rpc/host-tools.ts +2 -1
  131. package/src/modes/rpc/rpc-mode.ts +19 -4
  132. package/src/modes/warp-events.test.ts +794 -0
  133. package/src/modes/warp-events.ts +232 -0
  134. package/src/plan-mode/model-transition.test.ts +60 -0
  135. package/src/plan-mode/model-transition.ts +51 -0
  136. package/src/prompts/system/system-prompt.md +1 -1
  137. package/src/prompts/tools/eval.md +5 -2
  138. package/src/prompts/tools/read.md +1 -1
  139. package/src/prompts/tools/task.md +12 -8
  140. package/src/prompts/tools/write.md +1 -1
  141. package/src/registry/agent-lifecycle.ts +133 -18
  142. package/src/registry/persisted-agents.ts +74 -0
  143. package/src/sdk.ts +343 -122
  144. package/src/session/agent-session.ts +1359 -368
  145. package/src/session/session-entries.ts +6 -1
  146. package/src/session/session-history-format.ts +20 -5
  147. package/src/session/session-manager.ts +57 -0
  148. package/src/session/streaming-output.ts +41 -1
  149. package/src/slash-commands/builtin-registry.ts +7 -0
  150. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  151. package/src/system-prompt.ts +7 -2
  152. package/src/task/executor.ts +100 -22
  153. package/src/task/index.ts +258 -429
  154. package/src/task/label.ts +2 -0
  155. package/src/task/parallel.ts +43 -0
  156. package/src/task/persisted-revive.ts +19 -7
  157. package/src/task/structured-subagent.ts +642 -0
  158. package/src/task/types.ts +58 -0
  159. package/src/telemetry-export.ts +453 -97
  160. package/src/tools/__tests__/eval-description.test.ts +1 -1
  161. package/src/tools/approval.ts +11 -0
  162. package/src/tools/bash.ts +71 -38
  163. package/src/tools/essential-tools.ts +45 -0
  164. package/src/tools/fetch.ts +28 -105
  165. package/src/tools/gh.ts +169 -2
  166. package/src/tools/hub/messaging.ts +16 -3
  167. package/src/tools/image-gen.ts +69 -7
  168. package/src/tools/index.ts +50 -15
  169. package/src/tools/path-utils.ts +1 -0
  170. package/src/tools/read.ts +62 -29
  171. package/src/tools/todo.ts +126 -13
  172. package/src/tools/write.ts +22 -4
  173. package/src/tools/xdev.ts +14 -3
  174. package/src/tui/tree-list.ts +39 -0
  175. package/src/utils/markit.ts +12 -0
  176. package/src/utils/title-generator.ts +15 -4
  177. package/src/utils/zip.ts +16 -1
  178. package/src/web/search/providers/kimi.ts +18 -12
  179. package/src/web/search/types.ts +6 -1
@@ -13,7 +13,11 @@ import {
13
13
  saveWatchdogConfigFile,
14
14
  } from "../../advisor";
15
15
  import { reset as resetCapabilities } from "../../capability";
16
- import { formatModelSelectorValue, resolveAdvisorRoleSelection } from "../../config/model-resolver";
16
+ import {
17
+ formatModelSelectorValue,
18
+ resolveAdvisorRoleSelection,
19
+ resolveModelRoleValue,
20
+ } from "../../config/model-resolver";
17
21
  import { getRoleInfo } from "../../config/model-roles";
18
22
  import { settings } from "../../config/settings";
19
23
  import { disableProvider, enableProvider } from "../../discovery";
@@ -46,7 +50,12 @@ import {
46
50
  type ResetUsageAccount,
47
51
  toResetUsageAccounts,
48
52
  } from "../../slash-commands/helpers/reset-usage";
49
- import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
53
+ import {
54
+ AUTO_THINKING,
55
+ type ConfiguredThinkingLevel,
56
+ concreteThinkingLevel,
57
+ parseConfiguredThinkingLevel,
58
+ } from "../../thinking";
50
59
  import {
51
60
  isImageProviderPreference,
52
61
  isSearchProviderId,
@@ -68,7 +77,7 @@ import { ExtensionDashboard } from "../components/extensions";
68
77
  import { HistorySearchComponent } from "../components/history-search";
69
78
  import { LoginDialogComponent } from "../components/login-dialog";
70
79
  import { LogoutAccountSelectorComponent } from "../components/logout-account-selector";
71
- import { ModelHubComponent } from "../components/model-hub";
80
+ import { ModelHubComponent, type ModelRoleSelectionScope } from "../components/model-hub";
72
81
  import { ModelPickerComponent } from "../components/model-picker";
73
82
  import { OAuthSelectorComponent } from "../components/oauth-selector";
74
83
  import { PluginSelectorComponent } from "../components/plugin-selector";
@@ -87,6 +96,15 @@ const MANUAL_LOGIN_PROMPT = "Paste the authorization code (or full redirect URL)
87
96
 
88
97
  export class SelectorController {
89
98
  constructor(private ctx: InteractiveModeContext) {}
99
+ #defaultRoleMutationTail = Promise.resolve();
100
+
101
+ async #acquireDefaultRoleMutation(): Promise<() => void> {
102
+ const previous = this.#defaultRoleMutationTail;
103
+ const { promise, resolve } = Promise.withResolvers<void>();
104
+ this.#defaultRoleMutationTail = previous.then(() => promise);
105
+ await previous;
106
+ return resolve;
107
+ }
90
108
 
91
109
  async #refreshOAuthProviderAuthState(): Promise<void> {
92
110
  const oauthProviders = getOAuthProviders();
@@ -280,6 +298,13 @@ export class SelectorController {
280
298
  close: done,
281
299
  requestRender: () => this.ctx.ui.requestRender(),
282
300
  notify: message => this.ctx.showStatus(message),
301
+ getAdvisorStats: () => this.ctx.session.getAdvisorStats().advisors,
302
+ getUsageReports: async () => this.ctx.session.fetchUsageReports?.() ?? null,
303
+ resolveActiveAccount: (provider, sessionId) =>
304
+ this.ctx.session.modelRegistry.authStorage.getOAuthAccountIdentity(
305
+ provider,
306
+ sessionId ?? this.ctx.session.sessionId,
307
+ ),
283
308
  });
284
309
  overlayHandle = this.ctx.ui.showOverlay(overlay, {
285
310
  anchor: "bottom-center",
@@ -710,55 +735,173 @@ export class SelectorController {
710
735
  this.ctx.session.modelRegistry,
711
736
  this.ctx.session.scopedModels,
712
737
  {
713
- onAssign: async (model, role, thinkingLevel, selector) => {
738
+ onAssign: async (model, role, thinkingLevel, selector, scope?: ModelRoleSelectionScope) => {
739
+ const releaseDefaultMutation = role === "default" ? await this.#acquireDefaultRoleMutation() : undefined;
740
+ const configuredStorage = this.ctx.settings.get("modelRoleStorage");
741
+ const targetScope = configuredStorage === "project" ? (scope ?? "project") : "global";
714
742
  // `auto` is session-global: never baked into a per-role model value
715
743
  // (it can't round-trip through `model:<level>`). Apply it to the session
716
744
  // separately and persist via `defaultThinkingLevel`.
717
745
  const isAuto = thinkingLevel === AUTO_THINKING;
718
746
  const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel;
719
747
  const selectorValue = selector ?? `${model.provider}/${model.id}`;
748
+ const scopeLabel =
749
+ configuredStorage === "project" ? `${targetScope === "project" ? "Project" : "Global"} ` : "";
750
+ const defaultStatusLabel = configuredStorage === "project" ? `${scopeLabel}default` : "Default";
720
751
  try {
721
752
  if (role === "default") {
722
- const { switched } = await this.ctx.session.setModel(model, role, {
723
- selector,
724
- thinkingLevel: isAuto ? ThinkingLevel.Inherit : concreteThinking,
725
- persist: true,
726
- currentContextTokens,
727
- });
728
- if (isAuto) {
729
- if (switched) {
730
- this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
731
- } else {
753
+ const effectiveProvenance = this.ctx.settings.getModelRoleProvenance("default");
754
+ const shadowedGlobal =
755
+ configuredStorage === "project" &&
756
+ targetScope === "global" &&
757
+ (effectiveProvenance === "project" ||
758
+ effectiveProvenance === "overlay" ||
759
+ (effectiveProvenance === "runtime" &&
760
+ this.ctx.settings.isProjectModelRoleRuntimeOverrideActive("default")));
761
+ const shadowedProject =
762
+ configuredStorage === "project" &&
763
+ targetScope === "project" &&
764
+ effectiveProvenance === "overlay";
765
+ if (shadowedGlobal) {
766
+ this.ctx.settings.setModelRole(
767
+ "default",
768
+ formatModelSelectorValue(selectorValue, concreteThinking),
769
+ );
770
+ if (isAuto) {
732
771
  this.ctx.settings.set("defaultThinkingLevel", AUTO_THINKING);
733
772
  }
734
- } else if (switched && concreteThinking && concreteThinking !== ThinkingLevel.Inherit) {
735
- this.ctx.session.setThinkingLevel(concreteThinking);
736
- }
737
- if (switched) {
773
+ } else if (shadowedProject) {
774
+ this.ctx.settings.setProjectModelRole(
775
+ "default",
776
+ formatModelSelectorValue(selectorValue, concreteThinking),
777
+ );
778
+ if (isAuto) {
779
+ this.ctx.settings.set("defaultThinkingLevel", AUTO_THINKING);
780
+ }
781
+ } else {
782
+ const { switched } = await this.ctx.session.setModel(model, role, {
783
+ selector,
784
+ thinkingLevel: isAuto ? ThinkingLevel.Inherit : concreteThinking,
785
+ persist: targetScope === "global",
786
+ currentContextTokens,
787
+ });
788
+ if (!switched) return;
789
+ if (targetScope === "project") {
790
+ this.ctx.settings.setProjectModelRole(
791
+ "default",
792
+ formatModelSelectorValue(selectorValue, concreteThinking),
793
+ );
794
+ }
795
+ if (isAuto) {
796
+ this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
797
+ } else if (concreteThinking && concreteThinking !== ThinkingLevel.Inherit) {
798
+ this.ctx.session.setThinkingLevel(concreteThinking);
799
+ }
738
800
  this.ctx.statusLine.invalidate();
739
801
  this.ctx.updateEditorBorderColor();
740
802
  }
741
- this.ctx.showStatus(`Default model: ${selector ?? model.id}`);
803
+ this.ctx.showStatus(`${defaultStatusLabel} model: ${selector ?? model.id}`);
742
804
  } else {
743
805
  // Other roles (smol, slow, custom): update settings, not the current model.
744
- this.ctx.settings.setModelRole(role, formatModelSelectorValue(selectorValue, concreteThinking));
806
+ const modelRoleValue = formatModelSelectorValue(selectorValue, concreteThinking);
807
+ if (targetScope === "project") {
808
+ this.ctx.settings.setProjectModelRole(role, modelRoleValue);
809
+ } else {
810
+ this.ctx.settings.setModelRole(role, modelRoleValue);
811
+ }
745
812
  if (isAuto) {
746
813
  this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
747
814
  }
748
815
  const roleInfo = getRoleInfo(role, settings);
749
- this.ctx.showStatus(`${roleInfo?.name ?? role} model: ${selector ?? model.id}`);
816
+ this.ctx.showStatus(
817
+ `${scopeLabel}${roleInfo?.tag ?? roleInfo?.name ?? role} model: ${selector ?? model.id}`,
818
+ );
750
819
  }
751
820
  } catch (error) {
752
821
  this.ctx.showError(error instanceof Error ? error.message : String(error));
822
+ } finally {
823
+ releaseDefaultMutation?.();
824
+ hub?.refreshAfterExternalMutation();
753
825
  }
754
826
  },
755
- onUnassign: role => {
827
+ onUnassign: async (role, scope?: ModelRoleSelectionScope) => {
828
+ const releaseDefaultMutation = role === "default" ? await this.#acquireDefaultRoleMutation() : undefined;
829
+ const configuredStorage = this.ctx.settings.get("modelRoleStorage");
830
+ const targetScope = configuredStorage === "project" ? (scope ?? "project") : "global";
831
+ const scopeLabel =
832
+ configuredStorage === "project" ? `${targetScope === "project" ? "Project" : "Global"} ` : "";
756
833
  try {
757
- this.ctx.settings.setModelRole(role, undefined);
834
+ const previousEffectiveRoleValue =
835
+ role === "default" ? this.ctx.settings.getModelRole("default") : undefined;
836
+ if (targetScope === "project") {
837
+ this.ctx.settings.clearProjectModelRole(role);
838
+ } else {
839
+ this.ctx.settings.setModelRole(role, undefined);
840
+ }
758
841
  const roleInfo = getRoleInfo(role, settings);
759
- this.ctx.showStatus(`${roleInfo?.name ?? role} role cleared — auto-selection applies`);
842
+ this.ctx.showStatus(
843
+ `${scopeLabel}${roleInfo?.tag ?? roleInfo?.name ?? role} role cleared — auto-selection applies`,
844
+ );
845
+ // Clearing either persisted scope can also remove a captured
846
+ // runtime override. When that changes the effective default,
847
+ // resolve the newly exposed persisted layer and switch the live
848
+ // session without writing it back to global settings. Overlay
849
+ // and runtime provenance remain authoritative and session-neutral.
850
+ if (role === "default") {
851
+ const fallbackRoleValue = this.ctx.settings.getModelRole("default");
852
+ const fallbackProvenance = this.ctx.settings.getModelRoleProvenance("default");
853
+ const exposesPersistedFallback =
854
+ fallbackProvenance === "project" || fallbackProvenance === "global";
855
+ if (
856
+ fallbackRoleValue &&
857
+ fallbackRoleValue !== previousEffectiveRoleValue &&
858
+ exposesPersistedFallback
859
+ ) {
860
+ const scopedModels = this.ctx.session.scopedModels.map(sm => sm.model);
861
+ const availableModels =
862
+ scopedModels.length > 0 ? scopedModels : this.ctx.session.getAvailableModels();
863
+ const resolved = resolveModelRoleValue(fallbackRoleValue, availableModels, {
864
+ settings: this.ctx.settings,
865
+ });
866
+ if (resolved.model) {
867
+ const fallbackModel = resolved.model;
868
+ const isAuto = resolved.thinkingLevel === AUTO_THINKING;
869
+ let concreteThinking = concreteThinkingLevel(resolved.thinkingLevel);
870
+ let isAutoFromDefault = false;
871
+ if (!resolved.explicitThinkingLevel && !concreteThinking) {
872
+ const defaultLevel = parseConfiguredThinkingLevel(
873
+ this.ctx.settings.get("defaultThinkingLevel"),
874
+ );
875
+ if (defaultLevel === AUTO_THINKING) {
876
+ isAutoFromDefault = true;
877
+ } else if (defaultLevel) {
878
+ concreteThinking = defaultLevel;
879
+ }
880
+ }
881
+ const effectiveIsAuto = isAuto || isAutoFromDefault;
882
+ const { switched } = await this.ctx.session.setModel(fallbackModel, "default", {
883
+ persist: false,
884
+ thinkingLevel: effectiveIsAuto
885
+ ? ThinkingLevel.Inherit
886
+ : (concreteThinking ?? ThinkingLevel.Inherit),
887
+ currentContextTokens,
888
+ });
889
+ if (!switched) return;
890
+ if (effectiveIsAuto) {
891
+ this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
892
+ } else if (concreteThinking && concreteThinking !== ThinkingLevel.Inherit) {
893
+ this.ctx.session.setThinkingLevel(concreteThinking);
894
+ }
895
+ this.ctx.statusLine.invalidate();
896
+ this.ctx.updateEditorBorderColor();
897
+ }
898
+ }
899
+ }
760
900
  } catch (error) {
761
901
  this.ctx.showError(error instanceof Error ? error.message : String(error));
902
+ } finally {
903
+ releaseDefaultMutation?.();
904
+ hub?.refreshAfterExternalMutation();
762
905
  }
763
906
  },
764
907
  onFallbackChainChange: (role, chain) => {
@@ -773,8 +916,8 @@ export class SelectorController {
773
916
  const roleInfo = getRoleInfo(role, settings);
774
917
  this.ctx.showStatus(
775
918
  chain.length > 0
776
- ? `${roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}`
777
- : `${roleInfo?.name ?? role} fallbacks cleared`,
919
+ ? `${roleInfo?.tag ?? roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}`
920
+ : `${roleInfo?.tag ?? roleInfo?.name ?? role} fallbacks cleared`,
778
921
  );
779
922
  } catch (error) {
780
923
  this.ctx.showError(error instanceof Error ? error.message : String(error));
@@ -1124,10 +1267,16 @@ export class SelectorController {
1124
1267
  sessions,
1125
1268
  async (session: SessionInfo) => {
1126
1269
  selector.lockInput();
1270
+ let keepOpen = false;
1127
1271
  try {
1128
- await this.handleResumeSession(session.path);
1272
+ const success = await this.handleResumeSession(session.path);
1273
+ if (!success) {
1274
+ keepOpen = true;
1275
+ selector.unlockInput();
1276
+ this.ctx.ui.requestRender();
1277
+ }
1129
1278
  } finally {
1130
- done();
1279
+ if (!keepOpen) done();
1131
1280
  }
1132
1281
  },
1133
1282
  () => {
@@ -1205,13 +1354,23 @@ export class SelectorController {
1205
1354
  return true;
1206
1355
  }
1207
1356
 
1208
- async handleResumeSession(sessionPath: string): Promise<void> {
1209
- this.ctx.clearTransientSessionUi();
1210
-
1357
+ async handleResumeSession(sessionPath: string, options?: { settingsFlushed?: boolean }): Promise<boolean> {
1211
1358
  const previousCwd = this.ctx.sessionManager.getCwd();
1359
+ // Flush pending settings writes before switching sessions so a save
1360
+ // failure leaves the session, process project dir, and Settings in the
1361
+ // source scope — the switch below mutates the SessionManager cwd.
1362
+ if (!options?.settingsFlushed) {
1363
+ try {
1364
+ await this.ctx.settings.flush();
1365
+ } catch (err) {
1366
+ this.ctx.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`);
1367
+ return false;
1368
+ }
1369
+ }
1212
1370
  // Switch session via AgentSession (emits hook and tool session events). The
1213
1371
  // SessionManager adopts the resumed session's own cwd when it differs.
1214
1372
  await this.ctx.session.switchSession(sessionPath);
1373
+ this.ctx.clearTransientSessionUi();
1215
1374
  const newCwd = this.ctx.sessionManager.getCwd();
1216
1375
  const movedProject = normalizePathForComparison(newCwd) !== normalizePathForComparison(previousCwd);
1217
1376
  if (movedProject) {
@@ -1226,6 +1385,7 @@ export class SelectorController {
1226
1385
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
1227
1386
  await this.ctx.reloadTodos();
1228
1387
  this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session");
1388
+ return true;
1229
1389
  }
1230
1390
 
1231
1391
  async handleSessionDeleteCommand(): Promise<void> {
@@ -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",
@@ -115,7 +123,12 @@ import type { LspStartupServerInfo } from "../tools";
115
123
  import { normalizeLocalScheme } from "../tools/path-utils";
116
124
  import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
117
125
  import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
118
- import { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo";
126
+ import {
127
+ formatPhaseDisplayName,
128
+ selectCollapsedTodos,
129
+ setActiveTodoDescriptionsProvider,
130
+ todoMatchesAnyDescription,
131
+ } from "../tools/todo";
119
132
  import { ToolError } from "../tools/tool-errors";
120
133
  import { vocalizer } from "../tts/vocalizer";
121
134
  import { renderTreeList } from "../tui/tree-list";
@@ -539,6 +552,8 @@ export class InteractiveMode implements InteractiveModeContext {
539
552
  #goalSuppressNextContinuation = false;
540
553
  #planModePreviousModelState: { model: Model; thinkingLevel?: ConfiguredThinkingLevel } | undefined;
541
554
  #pendingModelSwitch: { model: Model; thinkingLevel?: ConfiguredThinkingLevel } | undefined;
555
+ /** Whether #pendingModelSwitch was queued by the live plan-role reconciler. */
556
+ #pendingPlanModelSwitch = false;
542
557
  #planModeHasEntered = false;
543
558
  #planReviewOverlay: PlanReviewOverlay | undefined;
544
559
  #planReviewOverlayHandle: OverlayHandle | undefined;
@@ -941,6 +956,9 @@ export class InteractiveMode implements InteractiveModeContext {
941
956
  this.#observerRegistry.onChange(kind => {
942
957
  this.#scheduleObserverUiSync(kind);
943
958
  });
959
+ // Let the transient todo tool result light up pending todos executed by a
960
+ // live subagent, matching the sticky HUD's active set (#5873).
961
+ setActiveTodoDescriptionsProvider(() => this.#getActiveSubagentDescriptions());
944
962
 
945
963
  // Load initial todos
946
964
  await this.#loadTodoList();
@@ -1023,6 +1041,11 @@ export class InteractiveMode implements InteractiveModeContext {
1023
1041
  this.#handleSessionAccentInputsChanged();
1024
1042
  }),
1025
1043
  );
1044
+ this.#eventBusUnsubscribers.push(
1045
+ onModelRolesChanged(() => {
1046
+ void this.#reapplyPlanModeModelOnRoleChange();
1047
+ }),
1048
+ );
1026
1049
  this.#eventBusUnsubscribers.push(
1027
1050
  this.session.subscribeCommandMetadataChanged(() => {
1028
1051
  const retainedCommands = this.#pendingSlashCommands.filter(command => !command.name.startsWith("skill:"));
@@ -1880,15 +1903,29 @@ export class InteractiveMode implements InteractiveModeContext {
1880
1903
  const isMatched = (todo: TodoItem): boolean =>
1881
1904
  activeDescs.length > 0 && todoMatchesAnyDescription(todo.content, activeDescs);
1882
1905
 
1883
- // Task subtree for a phase. Collapsed previews the first open tasks — the
1884
- // stage's `done/total` makes the hidden count obvious, so there is no
1885
- // "… more" row; expanded lists every task.
1906
+ // Task subtree for a phase. Collapsed runs the shared walking-viewport
1907
+ // policy (completed/abandoned omitted, active work pulled to the head,
1908
+ // then following pending tasks) so the HUD and the transient tool result
1909
+ // can never disagree about the current work (#5873). Expanded lists all.
1886
1910
  const renderTasks = (phase: TodoPhase): string[] => {
1887
- const open = phase.tasks.filter(t => t.status === "pending" || t.status === "in_progress");
1888
- const base = expanded ? phase.tasks : open.length > 0 ? open : phase.tasks;
1889
- const items = expanded ? base : base.slice(0, activeTaskCap);
1911
+ if (expanded) {
1912
+ return renderTreeList(
1913
+ {
1914
+ items: phase.tasks,
1915
+ expanded: true,
1916
+ renderItem: todo => this.#formatTodoLine(todo, "", isMatched(todo)),
1917
+ },
1918
+ theme,
1919
+ );
1920
+ }
1921
+ const selection = selectCollapsedTodos(phase.tasks, isMatched, activeTaskCap);
1890
1922
  return renderTreeList(
1891
- { items, expanded: true, renderItem: todo => this.#formatTodoLine(todo, "", isMatched(todo)) },
1923
+ {
1924
+ items: selection.items,
1925
+ itemType: "task",
1926
+ trailingSummary: selection.summary,
1927
+ renderItem: todo => this.#formatTodoLine(todo, "", isMatched(todo)),
1928
+ },
1892
1929
  theme,
1893
1930
  );
1894
1931
  };
@@ -2080,35 +2117,81 @@ export class InteractiveMode implements InteractiveModeContext {
2080
2117
  if (!resolved.model) return;
2081
2118
 
2082
2119
  const currentModel = this.session.model;
2083
- const sameModel = modelsAreEqual(currentModel, resolved.model);
2084
- const planThinkingLevel = resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined;
2085
-
2120
+ // Capture the pre-plan model so #exitPlanMode can restore it. Only the
2121
+ // entry path records this a mid-planning role change (below) leaves the
2122
+ // active model on the plan role, so overwriting here would restore the old
2123
+ // plan model instead of the user's real pre-plan model.
2086
2124
  this.#planModePreviousModelState = currentModel
2087
2125
  ? { model: currentModel, thinkingLevel: this.session.configuredThinkingLevel() }
2088
2126
  : undefined;
2089
2127
 
2090
- if (!sameModel) {
2091
- if (this.session.isStreaming) {
2092
- this.#pendingModelSwitch = { model: resolved.model, thinkingLevel: planThinkingLevel };
2128
+ await this.#applyPlanModelTransition(currentModel, resolved);
2129
+ }
2130
+
2131
+ /**
2132
+ * Re-resolve the `plan` role and move the active model onto it. Fires when
2133
+ * the plan role is reassigned while plan mode is active: the active model IS
2134
+ * the plan model there, so a settings-only change would otherwise leave the
2135
+ * current turn on the model plan mode was entered with (issue #5657). No-op
2136
+ * outside plan mode — role reassignment for an inactive role only touches
2137
+ * settings.
2138
+ */
2139
+ async #reapplyPlanModeModelOnRoleChange(): Promise<void> {
2140
+ if (!this.planModeEnabled) return;
2141
+ const resolved = this.session.resolveRoleModelWithThinking("plan");
2142
+ if (!resolved.model) {
2143
+ this.#clearPendingPlanModelSwitch();
2144
+ return;
2145
+ }
2146
+ await this.#applyPlanModelTransition(this.session.model, resolved);
2147
+ }
2148
+
2149
+ /**
2150
+ * Drop a stale deferred switch that was queued for a previous plan-role
2151
+ * assignment. Other deferred switches (such as restoring the pre-plan
2152
+ * model) remain intact.
2153
+ */
2154
+ #clearPendingPlanModelSwitch(): void {
2155
+ if (!this.#pendingPlanModelSwitch) return;
2156
+ this.#pendingModelSwitch = undefined;
2157
+ this.#pendingPlanModelSwitch = false;
2158
+ }
2159
+
2160
+ /** Apply (or defer) the model/thinking change implied by the resolved plan role. */
2161
+ async #applyPlanModelTransition(currentModel: Model | undefined, resolved: ResolvedModelRoleValue): Promise<void> {
2162
+ const transition = resolvePlanModelTransition(currentModel, resolved, this.session.isStreaming);
2163
+ if (transition.kind !== "apply" || !transition.deferred) {
2164
+ this.#clearPendingPlanModelSwitch();
2165
+ }
2166
+ switch (transition.kind) {
2167
+ case "none":
2168
+ return;
2169
+ case "thinking":
2170
+ this.session.setThinkingLevel(transition.thinkingLevel);
2171
+ return;
2172
+ case "apply":
2173
+ if (transition.deferred) {
2174
+ this.#pendingModelSwitch = { model: transition.model, thinkingLevel: transition.thinkingLevel };
2175
+ this.#pendingPlanModelSwitch = true;
2176
+ return;
2177
+ }
2178
+ try {
2179
+ await this.session.setModelTemporary(transition.model, transition.thinkingLevel);
2180
+ } catch (error) {
2181
+ this.showWarning(
2182
+ `Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
2183
+ );
2184
+ }
2093
2185
  return;
2094
- }
2095
- try {
2096
- await this.session.setModelTemporary(resolved.model, planThinkingLevel);
2097
- } catch (error) {
2098
- this.showWarning(
2099
- `Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
2100
- );
2101
- }
2102
- } else if (planThinkingLevel) {
2103
- this.session.setThinkingLevel(planThinkingLevel);
2104
2186
  }
2105
2187
  }
2106
2188
 
2107
2189
  /** Apply any deferred model switch after the current stream ends. */
2108
2190
  async flushPendingModelSwitch(): Promise<void> {
2109
2191
  const pending = this.#pendingModelSwitch;
2110
- if (!pending) return;
2111
2192
  this.#pendingModelSwitch = undefined;
2193
+ this.#pendingPlanModelSwitch = false;
2194
+ if (!pending) return;
2112
2195
  try {
2113
2196
  await this.session.setModelTemporary(pending.model, pending.thinkingLevel);
2114
2197
  } catch (error) {
@@ -2120,19 +2203,23 @@ export class InteractiveMode implements InteractiveModeContext {
2120
2203
 
2121
2204
  async #clearTransientModeState(): Promise<void> {
2122
2205
  if (this.planModeEnabled || this.planModePaused) {
2123
- if (this.#planModePreviousTools !== undefined) {
2124
- await this.session.setActiveToolsByName(this.#planModePreviousTools);
2125
- }
2126
- this.session.setPlanProposalHandler?.(null);
2127
2206
  this.session.setPlanModeState(undefined);
2128
- this.planModeEnabled = false;
2129
- this.planModePaused = false;
2130
- this.planModePlanFilePath = undefined;
2131
- this.#planModePreviousTools = undefined;
2132
- this.#planModePreviousModelState = undefined;
2133
- this.#pendingModelSwitch = undefined;
2134
- this.#planModeHasEntered = false;
2135
- this.#updatePlanModeStatus();
2207
+ try {
2208
+ if (this.#planModePreviousTools !== undefined) {
2209
+ await this.session.setActiveToolsByName(this.#planModePreviousTools);
2210
+ }
2211
+ } finally {
2212
+ this.session.setPlanProposalHandler?.(null);
2213
+ this.planModeEnabled = false;
2214
+ this.planModePaused = false;
2215
+ this.planModePlanFilePath = undefined;
2216
+ this.#planModePreviousTools = undefined;
2217
+ this.#planModePreviousModelState = undefined;
2218
+ this.#pendingModelSwitch = undefined;
2219
+ this.#pendingPlanModelSwitch = false;
2220
+ this.#planModeHasEntered = false;
2221
+ this.#updatePlanModeStatus();
2222
+ }
2136
2223
  }
2137
2224
 
2138
2225
  if (this.goalModeEnabled || this.goalModePaused) {
@@ -2314,6 +2401,7 @@ export class InteractiveMode implements InteractiveModeContext {
2314
2401
  this.session.setThinkingLevel(prev.thinkingLevel);
2315
2402
  } else if (this.session.isStreaming) {
2316
2403
  this.#pendingModelSwitch = { model: prev.model, thinkingLevel: prev.thinkingLevel };
2404
+ this.#pendingPlanModelSwitch = false;
2317
2405
  } else {
2318
2406
  await this.session.setModelTemporary(prev.model, prev.thinkingLevel);
2319
2407
  }
@@ -2345,33 +2433,30 @@ export class InteractiveMode implements InteractiveModeContext {
2345
2433
  return;
2346
2434
  }
2347
2435
 
2348
- const previousTools = this.#planModePreviousTools;
2349
- if (previousTools && previousTools.length > 0) {
2350
- await this.session.setActiveToolsByName(previousTools);
2351
- }
2352
- if (this.#planModePreviousModelState) {
2353
- if (!options?.deferModelRestore) {
2354
- await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
2436
+ const planModeState = this.session.getPlanModeState();
2437
+ this.session.setPlanModeState(undefined);
2438
+ try {
2439
+ if (this.#planModePreviousTools !== undefined) {
2440
+ await this.session.setActiveToolsByName(this.#planModePreviousTools);
2355
2441
  }
2356
- // If #applyPlanModeModel queued a deferred switch to the plan-role model
2357
- // (because the session was streaming on entry), drop it now: we are
2358
- // leaving plan mode, so flushing it on the next agent_end would land the
2359
- // session on the plan-role model after the user has exited plan mode
2360
- // (issue #816). This runs even when deferModelRestore is set
2361
- // (compact-approval path): otherwise the stale plan switch survives and
2362
- // flushPendingModelSwitch() later clobbers the restored/execution model.
2363
- // Only clear when the pending target matches the plan-role model — leave
2364
- // any unrelated user-queued switch intact.
2365
- const pending = this.#pendingModelSwitch;
2366
- if (pending) {
2367
- const planResolution = this.session.resolveRoleModelWithThinking("plan");
2368
- if (planResolution.model && modelsAreEqual(pending.model, planResolution.model)) {
2369
- this.#pendingModelSwitch = undefined;
2442
+ if (this.#planModePreviousModelState) {
2443
+ if (!options?.deferModelRestore) {
2444
+ await this.#restorePlanPreviousModel(this.#planModePreviousModelState);
2370
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();
2371
2454
  }
2455
+ } catch (error) {
2456
+ this.session.setPlanModeState(planModeState);
2457
+ throw error;
2372
2458
  }
2373
2459
  this.session.setPlanProposalHandler?.(null);
2374
- this.session.setPlanModeState(undefined);
2375
2460
  this.planModeEnabled = false;
2376
2461
  // Suppress cache-miss marker on the next turn: plan exit changes the system
2377
2462
  // prompt, which predictably invalidates the cache.
@@ -2551,6 +2636,7 @@ export class InteractiveMode implements InteractiveModeContext {
2551
2636
  maxHeight: "100%",
2552
2637
  margin: 0,
2553
2638
  fullscreen: true,
2639
+ mouseTracking: false,
2554
2640
  });
2555
2641
  this.ui.setFocus(overlay);
2556
2642
  this.ui.requestRender();
@@ -2881,6 +2967,18 @@ export class InteractiveMode implements InteractiveModeContext {
2881
2967
  planFilePath: options.planFilePath,
2882
2968
  contextPreserved: options.preserveContext === true,
2883
2969
  });
2970
+ // Close the review overlay only now — after the async title write and plan
2971
+ // prompt are prepared, immediately before the execution turn is queued. The
2972
+ // synthetic prompt below blocks in `session.prompt` for the whole run, so
2973
+ // hiding here (rather than after #approvePlan returns) keeps the operator off
2974
+ // the stale plan-review screen (issue #5688) while #5319's stale-buffer guard
2975
+ // stays intact. Deferring the hide past the awaited `setSessionName` also
2976
+ // prevents restored editor focus from letting operator keystrokes submit a
2977
+ // normal turn ahead of the approved execution turn (PR #5689 review).
2978
+ // `#hidePlanReview` is idempotent, so the caller's trailing `closePlanReview()`
2979
+ // — and the cancelled/error early returns above — stay safe no-ops.
2980
+ this.#hidePlanReview();
2981
+ this.ui.requestRender();
2884
2982
  // A user turn queued during compaction was already fired by
2885
2983
  // `flushCompactionQueue` before we returned from `handleCompactCommand`; the
2886
2984
  // old abort-then-prompt path would have discarded that operator turn AND
@@ -4310,11 +4408,20 @@ export class InteractiveMode implements InteractiveModeContext {
4310
4408
  this.#selectorController.showSessionSelector();
4311
4409
  }
4312
4410
 
4313
- handleResumeSession(sessionPath: string): Promise<void> {
4411
+ async handleResumeSession(sessionPath: string): Promise<void> {
4412
+ // Flush pending settings writes *before* disposing controllers or resetting
4413
+ // observers: a save failure must leave the session, process project dir,
4414
+ // and Settings in the source scope with all UI intact.
4415
+ try {
4416
+ await this.settings.flush();
4417
+ } catch (err) {
4418
+ this.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`);
4419
+ return;
4420
+ }
4314
4421
  this.#btwController.dispose();
4315
4422
  this.#omfgController.dispose();
4316
4423
  this.resetObserverRegistry();
4317
- return this.#selectorController.handleResumeSession(sessionPath);
4424
+ await this.#selectorController.handleResumeSession(sessionPath, { settingsFlushed: true });
4318
4425
  }
4319
4426
 
4320
4427
  handleSessionDeleteCommand(): Promise<void> {