@oh-my-pi/pi-coding-agent 17.0.1 → 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 (126) hide show
  1. package/CHANGELOG.md +92 -20
  2. package/dist/cli.js +3485 -3448
  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 +32 -7
  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/exec/bash-executor.d.ts +2 -0
  13. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  16. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  17. package/dist/types/extensibility/shared-events.d.ts +6 -0
  18. package/dist/types/extensibility/utils.d.ts +2 -2
  19. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  20. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  21. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  22. package/dist/types/modes/components/model-hub.d.ts +5 -2
  23. package/dist/types/modes/components/session-selector.d.ts +2 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  25. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  26. package/dist/types/modes/print-mode.d.ts +1 -1
  27. package/dist/types/modes/warp-events.d.ts +24 -0
  28. package/dist/types/modes/warp-events.test.d.ts +1 -0
  29. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  30. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  31. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  32. package/dist/types/sdk.d.ts +13 -2
  33. package/dist/types/session/agent-session.d.ts +34 -10
  34. package/dist/types/session/session-history-format.d.ts +10 -0
  35. package/dist/types/session/session-manager.d.ts +14 -0
  36. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  37. package/dist/types/task/label.d.ts +1 -1
  38. package/dist/types/telemetry-export.d.ts +34 -9
  39. package/dist/types/tools/approval.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +2 -0
  41. package/dist/types/tools/essential-tools.d.ts +29 -0
  42. package/dist/types/tools/image-gen.d.ts +2 -1
  43. package/dist/types/tools/index.d.ts +1 -0
  44. package/dist/types/tools/xdev.d.ts +11 -2
  45. package/dist/types/utils/title-generator.d.ts +2 -1
  46. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  47. package/dist/types/web/search/types.d.ts +1 -1
  48. package/package.json +21 -16
  49. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  50. package/src/advisor/__tests__/config.test.ts +58 -2
  51. package/src/advisor/config.ts +76 -24
  52. package/src/advisor/runtime.ts +445 -92
  53. package/src/autolearn/controller.ts +23 -28
  54. package/src/cli.ts +5 -1
  55. package/src/config/model-discovery.ts +81 -21
  56. package/src/config/model-registry.ts +25 -6
  57. package/src/config/model-resolver.ts +14 -7
  58. package/src/config/settings-schema.ts +42 -6
  59. package/src/config/settings.ts +405 -25
  60. package/src/cursor.ts +20 -3
  61. package/src/debug/report-bundle.ts +40 -4
  62. package/src/discovery/helpers.ts +28 -5
  63. package/src/exec/bash-executor.ts +14 -5
  64. package/src/extensibility/custom-tools/loader.ts +3 -3
  65. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  66. package/src/extensibility/extensions/loader.ts +3 -3
  67. package/src/extensibility/extensions/managed-timers.ts +83 -0
  68. package/src/extensibility/extensions/runner.ts +26 -0
  69. package/src/extensibility/extensions/types.ts +18 -0
  70. package/src/extensibility/extensions/wrapper.ts +2 -1
  71. package/src/extensibility/hooks/loader.ts +3 -3
  72. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  74. package/src/extensibility/plugins/manager.ts +2 -2
  75. package/src/extensibility/shared-events.ts +6 -0
  76. package/src/extensibility/utils.ts +91 -25
  77. package/src/irc/bus.ts +22 -3
  78. package/src/launch/broker.ts +3 -2
  79. package/src/launch/client.ts +2 -2
  80. package/src/launch/presence.ts +2 -2
  81. package/src/lsp/client.ts +1 -1
  82. package/src/main.ts +11 -8
  83. package/src/mcp/manager.ts +9 -3
  84. package/src/mcp/transports/stdio.ts +103 -23
  85. package/src/modes/components/advisor-config.ts +65 -3
  86. package/src/modes/components/ask-dialog.ts +1 -1
  87. package/src/modes/components/hook-editor.ts +18 -3
  88. package/src/modes/components/model-hub.ts +138 -42
  89. package/src/modes/components/session-selector.ts +4 -0
  90. package/src/modes/components/status-line/component.test.ts +1 -0
  91. package/src/modes/components/status-line/segments.ts +21 -6
  92. package/src/modes/controllers/command-controller.ts +167 -47
  93. package/src/modes/controllers/event-controller.ts +5 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  95. package/src/modes/controllers/input-controller.ts +12 -12
  96. package/src/modes/controllers/selector-controller.ts +191 -31
  97. package/src/modes/interactive-mode.ts +139 -54
  98. package/src/modes/print-mode.ts +3 -3
  99. package/src/modes/rpc/host-tools.ts +2 -1
  100. package/src/modes/rpc/rpc-mode.ts +19 -4
  101. package/src/modes/warp-events.test.ts +794 -0
  102. package/src/modes/warp-events.ts +232 -0
  103. package/src/plan-mode/model-transition.test.ts +60 -0
  104. package/src/plan-mode/model-transition.ts +51 -0
  105. package/src/registry/agent-lifecycle.ts +133 -18
  106. package/src/sdk.ts +221 -42
  107. package/src/session/agent-session.ts +1285 -348
  108. package/src/session/session-history-format.ts +20 -5
  109. package/src/session/session-manager.ts +48 -0
  110. package/src/slash-commands/builtin-registry.ts +7 -0
  111. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  112. package/src/task/executor.ts +1 -1
  113. package/src/task/label.ts +2 -0
  114. package/src/telemetry-export.ts +453 -97
  115. package/src/tools/approval.ts +11 -0
  116. package/src/tools/bash.ts +71 -38
  117. package/src/tools/essential-tools.ts +45 -0
  118. package/src/tools/gh.ts +169 -2
  119. package/src/tools/image-gen.ts +69 -7
  120. package/src/tools/index.ts +7 -5
  121. package/src/tools/read.ts +48 -3
  122. package/src/tools/write.ts +22 -4
  123. package/src/tools/xdev.ts +14 -3
  124. package/src/utils/title-generator.ts +15 -4
  125. package/src/web/search/providers/kimi.ts +18 -12
  126. package/src/web/search/types.ts +6 -1
@@ -28,6 +28,7 @@ import {
28
28
  visibleWidth,
29
29
  } from "@oh-my-pi/pi-tui";
30
30
  import type { ModelRegistry } from "../../config/model-registry";
31
+ import { type ModelRoleLookup, type ResolvedModelRoleValue, resolveModelRoleValue } from "../../config/model-resolver";
31
32
  import { getKnownRoleIds, getRoleInfo } from "../../config/model-roles";
32
33
  import type { Settings } from "../../config/settings";
33
34
  import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking";
@@ -75,11 +76,19 @@ export interface ScopedModelItem {
75
76
  thinkingLevel?: string;
76
77
  }
77
78
 
79
+ export type ModelRoleSelectionScope = "global" | "project";
80
+
78
81
  export interface ModelHubCallbacks {
79
82
  /** Persist a role assignment. */
80
- onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void;
83
+ onAssign: (
84
+ model: Model,
85
+ role: string,
86
+ thinkingLevel: ConfiguredThinkingLevel | undefined,
87
+ selector: string,
88
+ scope?: ModelRoleSelectionScope,
89
+ ) => void;
81
90
  /** Clear a configured role back to auto-selection. */
82
- onUnassign: (role: string) => void;
91
+ onUnassign: (role: string, scope?: ModelRoleSelectionScope) => void;
83
92
  /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
84
93
  onFallbackChainChange?: (role: string, chain: string[]) => void;
85
94
  /** Locked provider activation: forward to the /login flow. */
@@ -111,18 +120,20 @@ interface StripChip {
111
120
  /** Pre-styled label body (without selection decoration). */
112
121
  styled: string;
113
122
  role?: string;
114
- action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "thinking";
123
+ action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "scope" | "thinking";
115
124
  thinkingLevel?: ConfiguredThinkingLevel;
125
+ scope?: ModelRoleSelectionScope;
116
126
  }
117
127
 
118
128
  type StripState =
119
129
  | {
120
- kind: "role" | "thinking";
130
+ kind: "role" | "scope" | "thinking";
121
131
  item: ModelBrowserItem;
122
132
  role?: string;
133
+ scope?: ModelRoleSelectionScope;
123
134
  chips: StripChip[];
124
135
  index: number;
125
- /** Where to land when a thinking strip closes. */
136
+ /** Where to land when a scope or thinking strip closes. */
126
137
  returnToRoles: boolean;
127
138
  }
128
139
  | {
@@ -556,6 +567,11 @@ export class ModelHubComponent implements Component {
556
567
  this.#tui.requestRender();
557
568
  }
558
569
 
570
+ /** Re-sync after an asynchronous callback finishes mutating settings. */
571
+ refreshAfterExternalMutation(): void {
572
+ this.#refreshAfterMutation();
573
+ }
574
+
559
575
  /**
560
576
  * Recompute per-provider match counts for the active query. Providers
561
577
  * without matches gray out and the scope hop skips them; a provider scope
@@ -745,23 +761,55 @@ export class ModelHubComponent implements Component {
745
761
  this.#openRoleStrip(item);
746
762
  }
747
763
 
764
+ #roleForScope(role: string, scope: ModelRoleSelectionScope): ResolvedModelRoleValue {
765
+ const roleValue =
766
+ scope === "project" ? this.#settings.getProjectModelRole(role) : this.#settings.getGlobalModelRole(role);
767
+ const allModels =
768
+ this.#scopedModels.length > 0 ? this.#scopedModels.map(scoped => scoped.model) : this.#registry.getAll();
769
+ const roleLookup: ModelRoleLookup = {
770
+ getModelRole: scopedRole =>
771
+ scope === "project"
772
+ ? (this.#settings.getProjectModelRole(scopedRole) ?? this.#settings.getGlobalModelRole(scopedRole))
773
+ : this.#settings.getGlobalModelRole(scopedRole),
774
+ };
775
+ return resolveModelRoleValue(roleValue, allModels, { settings: this.#settings, roleLookup });
776
+ }
777
+
778
+ #thinkingLevelForScope(role: string, scope: ModelRoleSelectionScope): ConfiguredThinkingLevel {
779
+ const resolved = this.#roleForScope(role, scope);
780
+ return resolved.explicitThinkingLevel ? (resolved.thinkingLevel ?? ThinkingLevel.Inherit) : ThinkingLevel.Inherit;
781
+ }
782
+
748
783
  /** Persist `role → item`, preserving a still-supported thinking level, then open the thinking strip. */
749
- #assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
784
+ #assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean, scope?: ModelRoleSelectionScope): void {
785
+ if (this.#settings.get("modelRoleStorage") === "project" && scope === undefined) {
786
+ this.#openScopeStrip(item, role, returnToRoles);
787
+ return;
788
+ }
789
+
750
790
  const current = this.#roles[role];
751
791
  let level: ConfiguredThinkingLevel = ThinkingLevel.Inherit;
752
- if (current && !current.autoSelected) {
753
- const supported = this.#thinkingOptionsFor(item.model);
754
- level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit;
755
- }
756
- this.#callbacks.onAssign(item.model, role, level, item.selector);
792
+ if (this.#settings.get("modelRoleStorage") === "project" && scope !== undefined) {
793
+ level = this.#thinkingLevelForScope(role, scope);
794
+ } else if (current && !current.autoSelected) {
795
+ level = current.thinkingLevel;
796
+ }
797
+ const supported = this.#thinkingOptionsFor(item.model);
798
+ if (!supported.includes(level)) level = ThinkingLevel.Inherit;
799
+ this.#callbacks.onAssign(item.model, role, level, item.selector, scope);
757
800
  this.#refreshAfterMutation();
758
- this.#openThinkingStrip(item, role, returnToRoles);
801
+ this.#openThinkingStrip(item, role, returnToRoles, scope);
759
802
  }
760
803
 
761
804
  #unassignRole(role: string): void {
762
805
  const assignment = this.#roles[role];
763
806
  if (!assignment || assignment.autoSelected) return;
764
- this.#callbacks.onUnassign(role);
807
+ if (this.#settings.get("modelRoleStorage") === "project") {
808
+ const source = this.#settings.getModelRoleSource(role);
809
+ this.#callbacks.onUnassign(role, source === "default" ? undefined : source);
810
+ } else {
811
+ this.#callbacks.onUnassign(role);
812
+ }
765
813
  this.#refreshAfterMutation();
766
814
  }
767
815
 
@@ -771,24 +819,32 @@ export class ModelHubComponent implements Component {
771
819
 
772
820
  #openRoleStrip(item: ModelBrowserItem): void {
773
821
  const chips: StripChip[] = [];
822
+ const scopedStorage = this.#settings.get("modelRoleStorage") === "project";
823
+ const scopes: readonly ModelRoleSelectionScope[] = scopedStorage ? ["project", "global"] : ["global"];
774
824
  for (const role of this.#visibleRoleIds()) {
775
825
  const info = getRoleInfo(role, this.#settings);
776
826
  const assignment = this.#roles[role];
777
- const assignedHere =
778
- !!assignment &&
779
- !assignment.autoSelected &&
780
- assignment.model.provider === item.model.provider &&
781
- assignment.model.id === item.model.id;
782
- const label = (info.tag ?? info.name ?? role).toLowerCase();
783
- chips.push({
784
- label,
785
- styled: assignedHere
786
- ? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) +
787
- theme.fg("dim", ` ${theme.status.success}`)
788
- : theme.fg(info.color ?? "muted", label),
789
- role,
790
- action: assignedHere ? "unassign" : "assign",
791
- });
827
+ for (const scope of scopes) {
828
+ const scopedModel = scopedStorage
829
+ ? this.#roleForScope(role, scope).model
830
+ : assignment && !assignment.autoSelected
831
+ ? assignment.model
832
+ : undefined;
833
+ const assignedHere =
834
+ !!scopedModel && scopedModel.provider === item.model.provider && scopedModel.id === item.model.id;
835
+ const roleLabel = (info.tag ?? info.name ?? role).toLowerCase();
836
+ const label = scopedStorage ? `${scope} ${roleLabel}` : roleLabel;
837
+ chips.push({
838
+ label,
839
+ styled: assignedHere
840
+ ? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) +
841
+ theme.fg("dim", ` ${theme.status.success}`)
842
+ : theme.fg(info.color ?? "muted", label),
843
+ role,
844
+ scope,
845
+ action: assignedHere ? "unassign" : "assign",
846
+ });
847
+ }
792
848
  }
793
849
  chips.push({
794
850
  label: `fallbacks:${item.model.id}`,
@@ -804,9 +860,25 @@ export class ModelHubComponent implements Component {
804
860
  this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
805
861
  }
806
862
 
807
- #openThinkingStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
863
+ #openScopeStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
864
+ const chips: StripChip[] = [
865
+ { label: "project", styled: theme.fg("accent", "project"), action: "scope", scope: "project" },
866
+ { label: "global", styled: theme.fg("muted", "global"), action: "scope", scope: "global" },
867
+ ];
868
+ this.#strip = { kind: "scope", item, role, chips, index: 0, returnToRoles };
869
+ }
870
+
871
+ #openThinkingStrip(
872
+ item: ModelBrowserItem,
873
+ role: string,
874
+ returnToRoles: boolean,
875
+ scope?: ModelRoleSelectionScope,
876
+ ): void {
808
877
  const options = this.#thinkingOptionsFor(item.model);
809
- const current = this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
878
+ const current =
879
+ this.#settings.get("modelRoleStorage") === "project" && scope !== undefined
880
+ ? this.#thinkingLevelForScope(role, scope)
881
+ : (this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit);
810
882
  const chips: StripChip[] = options.map(level => {
811
883
  const label = getConfiguredThinkingLevelMetadata(level).label;
812
884
  const glyph = thinkingLevelGlyph(level);
@@ -822,6 +894,7 @@ export class ModelHubComponent implements Component {
822
894
  kind: "thinking",
823
895
  item,
824
896
  role,
897
+ scope,
825
898
  chips,
826
899
  index: preselect >= 0 ? preselect : 0,
827
900
  returnToRoles,
@@ -832,7 +905,7 @@ export class ModelHubComponent implements Component {
832
905
  const strip = this.#strip;
833
906
  this.#strip = null;
834
907
  this.#chipRanges = [];
835
- if (strip?.kind === "thinking" && strip.returnToRoles) {
908
+ if ((strip?.kind === "scope" || strip?.kind === "thinking") && strip.returnToRoles) {
836
909
  this.#setActiveEntry("roles");
837
910
  this.#focus = "list";
838
911
  }
@@ -847,12 +920,16 @@ export class ModelHubComponent implements Component {
847
920
  case "assign":
848
921
  if (chip.role) {
849
922
  this.#strip = null;
850
- this.#assignRole(strip.item, chip.role, false);
923
+ this.#assignRole(strip.item, chip.role, false, chip.scope);
851
924
  }
852
925
  return;
853
926
  case "unassign":
854
927
  if (chip.role) {
855
- this.#callbacks.onUnassign(chip.role);
928
+ if (this.#settings.get("modelRoleStorage") === "project") {
929
+ this.#callbacks.onUnassign(chip.role, chip.scope);
930
+ } else {
931
+ this.#callbacks.onUnassign(chip.role);
932
+ }
856
933
  this.#refreshAfterMutation();
857
934
  }
858
935
  this.#closeStrip();
@@ -869,9 +946,21 @@ export class ModelHubComponent implements Component {
869
946
  this.#closeStrip();
870
947
  this.#startAssignFallback(`${strip.item.model.provider}/*`, null);
871
948
  return;
949
+ case "scope":
950
+ if (strip.role && chip.scope) {
951
+ this.#strip = null;
952
+ this.#assignRole(strip.item, strip.role, strip.returnToRoles, chip.scope);
953
+ }
954
+ return;
872
955
  case "thinking":
873
956
  if (strip.role && chip.thinkingLevel !== undefined) {
874
- this.#callbacks.onAssign(strip.item.model, strip.role, chip.thinkingLevel, strip.item.selector);
957
+ this.#callbacks.onAssign(
958
+ strip.item.model,
959
+ strip.role,
960
+ chip.thinkingLevel,
961
+ strip.item.selector,
962
+ strip.scope,
963
+ );
875
964
  this.#refreshAfterMutation();
876
965
  }
877
966
  this.#closeStrip();
@@ -1305,13 +1394,20 @@ export class ModelHubComponent implements Component {
1305
1394
  if (printable === "t") {
1306
1395
  const assignment = role ? this.#roles[role] : undefined;
1307
1396
  if (role && assignment) {
1397
+ const source =
1398
+ this.#settings.get("modelRoleStorage") === "project"
1399
+ ? this.#settings.getModelRoleSource(role)
1400
+ : "default";
1401
+ const scope = source === "project" || source === "global" ? source : undefined;
1402
+ const scopedModel = scope ? this.#roleForScope(role, scope).model : assignment.model;
1403
+ if (!scopedModel) return;
1308
1404
  const item: ModelBrowserItem = {
1309
- provider: assignment.model.provider,
1310
- id: assignment.model.id,
1311
- model: assignment.model,
1312
- selector: `${assignment.model.provider}/${assignment.model.id}`,
1405
+ provider: scopedModel.provider,
1406
+ id: scopedModel.id,
1407
+ model: scopedModel,
1408
+ selector: `${scopedModel.provider}/${scopedModel.id}`,
1313
1409
  };
1314
- this.#openThinkingStrip(item, role, true);
1410
+ this.#openThinkingStrip(item, role, true, scope);
1315
1411
  }
1316
1412
  return;
1317
1413
  }
@@ -1770,9 +1866,9 @@ export class ModelHubComponent implements Component {
1770
1866
  if (strip.kind === "roleName") {
1771
1867
  return "Enter create + pick model · Esc cancel";
1772
1868
  }
1773
- return strip.kind === "role"
1774
- ? "←/→ choose · Enter assign/clear · Esc cancel"
1775
- : "←/→ thinking level · Enter apply · Esc keep";
1869
+ if (strip.kind === "role") return "←/→ choose · Enter assign/clear · Esc cancel";
1870
+ if (strip.kind === "scope") return "←/→ save scope · Enter choose · Esc cancel";
1871
+ return "←/→ thinking level · Enter apply · Esc keep";
1776
1872
  }
1777
1873
  if (this.#assigning !== null) {
1778
1874
  switch (this.#assigning.kind) {
@@ -879,6 +879,10 @@ export class SessionSelectorComponent extends Container {
879
879
  lockInput(): void {
880
880
  this.#inputLocked = true;
881
881
  }
882
+ /** Re-enable input after a failed resume so the user can pick again. */
883
+ unlockInput(): void {
884
+ this.#inputLocked = false;
885
+ }
882
886
 
883
887
  /**
884
888
  * Dispose the session list explicitly: while the delete-confirmation dialog
@@ -36,6 +36,7 @@ function makeSessionWithLastMessage(lastMessage: unknown, prewalkArmed: boolean
36
36
  getPrewalkState: () => (prewalkArmed ? { target: { id: "cheap-model", provider: "openai" } } : undefined),
37
37
  getAsyncJobSnapshot: () => undefined,
38
38
  isAdvisorActive: () => false,
39
+ getAdvisorStatusOverview: () => ({ configured: false, advisors: [] }),
39
40
  isFastModeActive: () => false,
40
41
  configuredThinkingLevel: () => undefined,
41
42
  modelRegistry: {
@@ -129,9 +129,9 @@ const modelSegment: StatusLineSegment = {
129
129
 
130
130
  // Fast-mode icon and thinking-level suffix trail the model name and are
131
131
  // colored together with it as `statusLineModel`. The advisor "++" badge
132
- // sits between the name and that tail in `accent`, so it reads as a
133
- // distinct marker. theme.fg resets only the fg, so the spans are
134
- // concatenated (not nested) to keep each color intact.
132
+ // sits between the name and that tail, so it reads as a distinct marker.
133
+ // theme.fg resets only the fg, so the spans are concatenated (not
134
+ // nested) to keep each color intact.
135
135
  let tail = "";
136
136
  if (ctx.session.isFastModeActive() && theme.icon.fast) {
137
137
  tail += ` ${theme.icon.fast}`;
@@ -141,10 +141,25 @@ const modelSegment: StatusLineSegment = {
141
141
  }
142
142
 
143
143
  // `statusLineModel` is aliased to `accent` in many themes, so the badge
144
- // uses `success` to stay visibly distinct from the model name color.
144
+ // uses status colors to stay visibly distinct from the model name color.
145
145
  let content = theme.fg("statusLineModel", withIcon(modelIcon, modelName));
146
- if (ctx.session.isAdvisorActive()) {
147
- content += theme.fg("success", "++");
146
+ // Advisor "++" badge, colored by the worst status in the roster:
147
+ // success = all running, warning = quota-exhausted, error = failed,
148
+ // dim = everything paused/no-model. Per-advisor detail lives in
149
+ // `/advisor status`.
150
+ // Optional chaining: lightweight session doubles (test mocks) that don't
151
+ // implement getAdvisorStatusOverview skip the badge instead of crashing.
152
+ const advisorStats = ctx.session.getAdvisorStatusOverview?.();
153
+ if (advisorStats?.configured && advisorStats.advisors.length > 0) {
154
+ const statuses = advisorStats.advisors.map(a => a.status);
155
+ const badgeColor = statuses.includes("error")
156
+ ? "error"
157
+ : statuses.includes("quota_exhausted")
158
+ ? "warning"
159
+ : statuses.includes("running")
160
+ ? "success"
161
+ : "dim";
162
+ content += theme.fg(badgeColor, "++");
148
163
  }
149
164
  if (tail) {
150
165
  content += theme.fg("statusLineModel", tail);
@@ -43,7 +43,7 @@ import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-stora
43
43
  import type { CompactMode } from "../../session/compact-modes";
44
44
  import type { NewSessionOptions } from "../../session/session-entries";
45
45
  import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
46
- import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
46
+ import { formatActiveAccountLabel, limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
47
47
  import { outputMeta } from "../../tools/output-meta";
48
48
  import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
49
49
  import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
@@ -349,46 +349,119 @@ export class CommandController {
349
349
  this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
350
350
  }
351
351
 
352
+ static readonly #advisorStatusGlyph: Record<string, string> = {
353
+ running: "●",
354
+ paused: "○",
355
+ no_model: "○",
356
+ quota_exhausted: "✕",
357
+ error: "✕",
358
+ };
359
+
360
+ static readonly #advisorStatusLabel: Record<string, string> = {
361
+ running: "running",
362
+ paused: "off",
363
+ no_model: "no model",
364
+ quota_exhausted: "quota exhausted",
365
+ error: "error",
366
+ };
367
+
352
368
  async handleAdvisorStatusCommand(): Promise<void> {
353
369
  const stats = this.ctx.session.getAdvisorStats();
354
- if (!stats.active) {
355
- this.ctx.present([
356
- new Spacer(1),
357
- new Text(
358
- stats.configured
359
- ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
360
- : "Advisor is disabled.",
361
- 1,
362
- 0,
363
- ),
364
- ]);
370
+ if (!stats.configured) {
371
+ this.ctx.present([new Spacer(1), new Text("Advisor is disabled.", 1, 0)]);
365
372
  return;
366
373
  }
367
- if (stats.advisors.length > 1) {
374
+ // Fetch live quota data (cached 5 min by the auth-gateway) so we can show
375
+ // real usage windows/reset timers per advisor provider. Non-fatal when absent.
376
+ const usageProvider = this.ctx.session as { fetchUsageReports?: () => Promise<UsageReport[] | null> };
377
+ let usageReports: UsageReport[] | null = null;
378
+ if (usageProvider.fetchUsageReports) {
379
+ try {
380
+ usageReports = await usageProvider.fetchUsageReports();
381
+ } catch {
382
+ // Network/auth failure is non-fatal — just skip the quota line.
383
+ }
384
+ }
385
+ // Resolve the active OAuth identity for each advisor's provider so quota
386
+ // filtering matches the credential actually in use (not sibling accounts).
387
+ const resolveActiveAdvisorAccount = (provider: string, sessionId?: string): OAuthAccountIdentity | undefined =>
388
+ this.ctx.session.modelRegistry.authStorage.getOAuthAccountIdentity(
389
+ provider,
390
+ sessionId ?? this.ctx.session.sessionId,
391
+ );
392
+ const nowMs = Date.now();
393
+ // Roster view: show every configured advisor with its status, even when
394
+ // none are live (all paused/no-model). The old code returned a generic
395
+ // message that hid the per-advisor state the user needs to act on.
396
+ if (stats.advisors.length > 1 || (stats.configured && !stats.active)) {
368
397
  let info = `${theme.bold("Advisor Status")} (${stats.advisors.length} advisors)\n`;
369
398
  for (const a of stats.advisors) {
370
- const ctx =
371
- a.contextWindow > 0
372
- ? `${a.contextTokens.toLocaleString()} / ${a.contextWindow.toLocaleString()} (${Math.round((a.contextTokens / a.contextWindow) * 100)}%)`
373
- : `${a.contextTokens.toLocaleString()}`;
374
- info += `\n${theme.bold(a.name)}\n`;
375
- info += `${theme.fg("dim", "Model:")} ${a.model.provider}/${a.model.id}\n`;
376
- info += `${theme.fg("dim", "Context:")} ${ctx}\n`;
377
- info += `${theme.fg("dim", "Messages:")} ${a.messages.total.toLocaleString()}\n`;
378
- info += `${theme.fg("dim", "Spend:")} ${a.tokens.input.toLocaleString()} in / ${a.tokens.output.toLocaleString()} out`;
379
- if (a.cost > 0) info += `, $${a.cost.toFixed(4)}`;
380
- info += "\n";
399
+ const glyph = CommandController.#advisorStatusGlyph[a.status] ?? "?";
400
+ const label = CommandController.#advisorStatusLabel[a.status] ?? a.status;
401
+ const color =
402
+ a.status === "running"
403
+ ? "success"
404
+ : a.status === "quota_exhausted" || a.status === "error"
405
+ ? "error"
406
+ : "dim";
407
+ info += `\n${theme.fg(color, glyph)} ${theme.bold(a.name)} ${theme.fg("dim", `[${label}]`)}\n`;
408
+ if (a.model) {
409
+ info += `${theme.fg("dim", "Model:")} ${a.model.provider}/${a.model.id}\n`;
410
+ }
411
+ if (a.model && usageReports) {
412
+ const quota = formatCompactQuota(
413
+ a.model.provider,
414
+ usageReports,
415
+ nowMs,
416
+ resolveActiveAdvisorAccount(a.model.provider, a.sessionId),
417
+ );
418
+ if (quota) info += `${theme.fg("dim", quota)}\n`;
419
+ }
420
+ if (a.status === "running" || a.status === "quota_exhausted") {
421
+ const ctx =
422
+ a.contextWindow > 0
423
+ ? `${a.contextTokens.toLocaleString()} / ${a.contextWindow.toLocaleString()} (${Math.round((a.contextTokens / a.contextWindow) * 100)}%)`
424
+ : `${a.contextTokens.toLocaleString()}`;
425
+ info += `${theme.fg("dim", "Context:")} ${ctx}\n`;
426
+ info += `${theme.fg("dim", "Messages:")} ${a.messages.total.toLocaleString()}\n`;
427
+ info += `${theme.fg("dim", "Spend:")} ${a.tokens.input.toLocaleString()} in / ${a.tokens.output.toLocaleString()} out`;
428
+ if (a.cost > 0) info += `, $${a.cost.toFixed(4)}`;
429
+ info += "\n";
430
+ }
431
+ }
432
+ if (stats.active) {
433
+ info += `\n${theme.bold("Totals")}\n`;
434
+ info += `${theme.fg("dim", "Tokens:")} ${stats.tokens.total.toLocaleString()}\n`;
435
+ if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`;
381
436
  }
382
- info += `\n${theme.bold("Totals")}\n`;
383
- info += `${theme.fg("dim", "Tokens:")} ${stats.tokens.total.toLocaleString()}\n`;
384
- if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`;
385
437
  this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
386
438
  return;
387
439
  }
388
- const model = stats.model!;
440
+ // Single active advisor — detailed view.
441
+ const model = stats.model;
389
442
  let info = `${theme.bold("Advisor Status")}\n\n`;
390
- info += `${theme.bold("Provider")}\n`;
391
- info += `${theme.fg("dim", "Model:")} ${model.provider}/${model.id}\n`;
443
+ if (stats.advisors.length === 1) {
444
+ const a = stats.advisors[0];
445
+ const glyph = CommandController.#advisorStatusGlyph[a.status] ?? "?";
446
+ const label = CommandController.#advisorStatusLabel[a.status] ?? a.status;
447
+ info += `${theme.fg(a.status === "running" ? "success" : "error", glyph)} ${a.name} ${theme.fg("dim", `[${label}]`)}\n\n`;
448
+ }
449
+ if (model) {
450
+ info += `${theme.bold("Provider")}\n`;
451
+ info += `${theme.fg("dim", "Model:")} ${model.provider}/${model.id}\n`;
452
+ }
453
+ if (model && usageReports) {
454
+ const quota = formatCompactQuota(
455
+ model.provider,
456
+ usageReports,
457
+ nowMs,
458
+ resolveActiveAdvisorAccount(model.provider, stats.advisors[0]?.sessionId),
459
+ );
460
+ if (quota) {
461
+ info += `\n${theme.bold("Quota")}\n`;
462
+ info += `${theme.fg("dim", quota)}\n`;
463
+ }
464
+ }
392
465
  info += `\n${theme.bold("Messages")}\n`;
393
466
  info += `${theme.fg("dim", "User:")} ${stats.messages.user.toLocaleString()}\n`;
394
467
  info += `${theme.fg("dim", "Assistant:")} ${stats.messages.assistant.toLocaleString()}\n`;
@@ -406,14 +479,7 @@ export class CommandController {
406
479
  if (stats.tokens.cacheRead > 0) {
407
480
  info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
408
481
  }
409
- if (stats.tokens.cacheWrite > 0) {
410
- info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
411
- }
412
- info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
413
- if (stats.cost > 0) {
414
- info += `\n${theme.bold("Cost")}\n`;
415
- info += `${theme.fg("dim", "Total:")} $${stats.cost.toFixed(4)}\n`;
416
- }
482
+ if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`;
417
483
  this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
418
484
  }
419
485
 
@@ -993,6 +1059,12 @@ export class CommandController {
993
1059
  return;
994
1060
  }
995
1061
  }
1062
+ try {
1063
+ await this.ctx.settings.flush();
1064
+ } catch (err) {
1065
+ this.ctx.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`);
1066
+ return;
1067
+ }
996
1068
 
997
1069
  try {
998
1070
  await this.ctx.sessionManager.moveTo(resolvedPath);
@@ -1000,7 +1072,6 @@ export class CommandController {
1000
1072
  this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
1001
1073
  return;
1002
1074
  }
1003
-
1004
1075
  await this.ctx.applyCwdChange(resolvedPath);
1005
1076
 
1006
1077
  this.ctx.updateEditorBorderColor();
@@ -1303,7 +1374,7 @@ export class CommandController {
1303
1374
  }
1304
1375
 
1305
1376
  const BAR_WIDTH_MAX = 24;
1306
- const BAR_WIDTH_MIN = 4;
1377
+ const COLUMN_WIDTH_MIN = 4;
1307
1378
 
1308
1379
  function renderJobLine(job: AsyncJobSnapshotItem, now: number): string {
1309
1380
  const duration = formatDuration(Math.max(0, now - job.startTime));
@@ -1537,6 +1608,57 @@ function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
1537
1608
  }
1538
1609
  return `resets in ${formatDuration(minReset)}`;
1539
1610
  }
1611
+ /**
1612
+ * Compact one-line quota summary for a single advisor's provider.
1613
+ * Returns `null` when the provider has no usage data.
1614
+ * When `activeAccount` is provided, only limits matching that credential
1615
+ * are shown (mirrors `renderUsageReports`'s account-stickiness filtering).
1616
+ * Example output: `Quota: 7d window · 67% used · resets in 3.2d`
1617
+ */
1618
+ export function formatCompactQuota(
1619
+ provider: string,
1620
+ reports: UsageReport[],
1621
+ nowMs: number,
1622
+ activeAccount?: OAuthAccountIdentity,
1623
+ ): string | null {
1624
+ const providerReports = reports.filter(r => r.provider === provider);
1625
+ if (providerReports.length === 0) return null;
1626
+ // Group limits by window id so we show BOTH the 5-hour and 7-day windows
1627
+ // (or any other distinct windows the provider exposes). Within each window,
1628
+ // pick the highest used fraction across accounts — that's the most pressing.
1629
+ const byWindow = new Map<string, { limit: UsageLimit; fraction: number }>();
1630
+ for (const report of providerReports) {
1631
+ for (const limit of report.limits) {
1632
+ // Skip limits that belong to a different credential than the one
1633
+ // the advisor is actually using, so we don't alarm the user with
1634
+ // an exhausted account that isn't theirs.
1635
+ if (activeAccount && !limitMatchesActiveAccount(report, limit, activeAccount)) continue;
1636
+ const fraction = resolveUsedFraction(limit);
1637
+ if (fraction === undefined) continue;
1638
+ const key = limit.window?.id ?? limit.scope.windowId ?? "—";
1639
+ const existing = byWindow.get(key);
1640
+ if (!existing || fraction > existing.fraction) byWindow.set(key, { limit, fraction });
1641
+ }
1642
+ }
1643
+ if (byWindow.size === 0) return null;
1644
+ // Sort windows by urgency (highest fraction first) so the most pressing
1645
+ // quota is always the first thing the user sees.
1646
+ const entries = [...byWindow.values()].sort((a, b) => b.fraction - a.fraction);
1647
+ const lines: string[] = [];
1648
+ for (const { limit, fraction } of entries) {
1649
+ const pct = Math.round(fraction * 100);
1650
+ const windowLabel = limit.window?.label ?? limit.scope.windowId ?? "—";
1651
+ // Include the limit label (account/tier) when it carries identity beyond
1652
+ // the window name, so the user can tell which credential's quota is shown.
1653
+ const identity = limit.label.trim();
1654
+ const header = identity && identity !== windowLabel ? `${windowLabel} (${identity})` : windowLabel;
1655
+ const parts = [`${header}: ${pct}% used`];
1656
+ const reset = resolveResetRange([limit], nowMs);
1657
+ if (reset) parts.push(reset);
1658
+ lines.push(parts.join(" · "));
1659
+ }
1660
+ return `Quota: ${lines.join(" │ ")}`;
1661
+ }
1540
1662
 
1541
1663
  function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string {
1542
1664
  if (status === "exhausted") return uiTheme.fg("error", uiTheme.status.error);
@@ -1571,7 +1693,7 @@ function renderUsageBar(limit: UsageLimit, uiTheme: typeof theme, barWidth: numb
1571
1693
  }
1572
1694
 
1573
1695
  /**
1574
- * Pick a per-column width so n bars + a trailing amount string fit in `available` columns.
1696
+ * Pick a per-account column width so the columns and trailing amount fit in `available`.
1575
1697
  * Falls back to the minimum when the terminal is too narrow rather than wrapping.
1576
1698
  */
1577
1699
  function resolveColumnWidth(count: number, available: number, trailing: number): number {
@@ -1580,10 +1702,7 @@ function resolveColumnWidth(count: number, available: number, trailing: number):
1580
1702
  const gaps = count - 1;
1581
1703
  const spaceForBars = available - indent - gaps - (trailing > 0 ? trailing + 1 : 0);
1582
1704
  const ideal = Math.floor(spaceForBars / count);
1583
- const min = BAR_WIDTH_MIN;
1584
- const max = BAR_WIDTH_MAX;
1585
- if (ideal < min) return min;
1586
- if (ideal > max) return max;
1705
+ if (ideal < COLUMN_WIDTH_MIN) return COLUMN_WIDTH_MIN;
1587
1706
  return ideal;
1588
1707
  }
1589
1708
 
@@ -1642,7 +1761,7 @@ export function renderUsageReports(
1642
1761
  }
1643
1762
 
1644
1763
  lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
1645
- const activeAccountLabel = activeAccount?.email ?? activeAccount?.accountId ?? activeAccount?.projectId;
1764
+ const activeAccountLabel = formatActiveAccountLabel(activeAccount);
1646
1765
  if (activeAccountLabel) {
1647
1766
  lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
1648
1767
  }
@@ -1719,6 +1838,7 @@ export function renderUsageReports(
1719
1838
  const sectionCount = renderableGroups.reduce((max, g) => Math.max(max, g.sortedLimits.length), 0);
1720
1839
  const sectionTrailing = renderableGroups.reduce((max, g) => Math.max(max, visibleWidth(g.amountText)), 0);
1721
1840
  const sectionColumnWidth = resolveColumnWidth(sectionCount, availableWidth, sectionTrailing);
1841
+ const sectionBarWidth = Math.min(sectionColumnWidth, BAR_WIDTH_MAX);
1722
1842
 
1723
1843
  for (const { group, sortedLimits, sortedReports, amountText } of renderableGroups) {
1724
1844
  const status = resolveAggregateStatus(sortedLimits);
@@ -1736,7 +1856,7 @@ export function renderUsageReports(
1736
1856
  );
1737
1857
  lines.push(` ${accountLabels.join(" ")}`.trimEnd());
1738
1858
  const bars = sortedLimits.map(limit =>
1739
- padColumn(renderUsageBar(limit, uiTheme, sectionColumnWidth), sectionColumnWidth),
1859
+ padColumn(renderUsageBar(limit, uiTheme, sectionBarWidth), sectionColumnWidth),
1740
1860
  );
1741
1861
  lines.push(` ${bars.join(" ")} ${amountText}`.trimEnd());
1742
1862
  const resetText = sortedLimits.length <= 1 ? resolveResetRange(sortedLimits, nowMs) : null;