@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.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 (154) hide show
  1. package/CHANGELOG.md +113 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/config/model-provider-priority.d.ts +1 -0
  7. package/dist/types/config/model-resolver.d.ts +4 -1
  8. package/dist/types/config/settings.d.ts +7 -2
  9. package/dist/types/debug/report-bundle.d.ts +3 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  11. package/dist/types/edit/index.d.ts +0 -1
  12. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  14. package/dist/types/lsp/client.d.ts +10 -0
  15. package/dist/types/lsp/index.d.ts +0 -5
  16. package/dist/types/main.d.ts +14 -9
  17. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  19. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  20. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  21. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  22. package/dist/types/modes/components/session-selector.d.ts +16 -7
  23. package/dist/types/modes/components/status-line.d.ts +2 -0
  24. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  25. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -0
  27. package/dist/types/modes/magic-keywords.d.ts +1 -1
  28. package/dist/types/modes/markdown-prose.d.ts +1 -1
  29. package/dist/types/modes/types.d.ts +7 -0
  30. package/dist/types/modes/workflow.d.ts +3 -3
  31. package/dist/types/session/auth-storage.d.ts +1 -1
  32. package/dist/types/session/messages.d.ts +11 -8
  33. package/dist/types/session/session-manager.d.ts +5 -2
  34. package/dist/types/session/yield-queue.d.ts +10 -1
  35. package/dist/types/task/executor.d.ts +10 -0
  36. package/dist/types/tools/eval-render.d.ts +0 -1
  37. package/dist/types/tools/eval.d.ts +8 -0
  38. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  39. package/dist/types/tools/github-cache.d.ts +12 -0
  40. package/dist/types/tools/index.d.ts +31 -0
  41. package/dist/types/tools/path-utils.d.ts +13 -1
  42. package/dist/types/tools/read.d.ts +2 -1
  43. package/dist/types/tools/render-utils.d.ts +3 -1
  44. package/dist/types/tools/renderers.d.ts +0 -15
  45. package/dist/types/tools/search.d.ts +2 -2
  46. package/dist/types/tools/write.d.ts +0 -2
  47. package/dist/types/tools/yield.d.ts +8 -0
  48. package/dist/types/tui/code-cell.d.ts +0 -2
  49. package/dist/types/tui/hyperlink.d.ts +5 -7
  50. package/dist/types/tui/output-block.d.ts +0 -18
  51. package/package.json +9 -9
  52. package/src/cli/args.ts +3 -1
  53. package/src/cli/dry-balance-cli.ts +2 -4
  54. package/src/cli/gallery-cli.ts +4 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  56. package/src/cli/gallery-fixtures/fs.ts +68 -1
  57. package/src/cli/gallery-fixtures/types.ts +8 -1
  58. package/src/cli/startup-cwd.ts +68 -0
  59. package/src/commands/launch.ts +3 -0
  60. package/src/commit/agentic/agent.ts +1 -0
  61. package/src/commit/model-selection.ts +3 -2
  62. package/src/config/model-provider-priority.ts +55 -0
  63. package/src/config/model-registry.ts +4 -22
  64. package/src/config/model-resolver.ts +39 -7
  65. package/src/config/settings.ts +86 -41
  66. package/src/debug/index.ts +8 -0
  67. package/src/debug/raw-sse-buffer.ts +7 -4
  68. package/src/debug/report-bundle.ts +9 -0
  69. package/src/edit/file-snapshot-store.ts +33 -1
  70. package/src/edit/hashline/diff.ts +86 -0
  71. package/src/edit/hashline/execute.ts +14 -1
  72. package/src/edit/hashline/filesystem.ts +2 -1
  73. package/src/edit/index.ts +31 -17
  74. package/src/edit/renderer.ts +116 -31
  75. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  76. package/src/eval/js/context-manager.ts +32 -15
  77. package/src/eval/js/shared/prelude.txt +26 -10
  78. package/src/eval/llm-bridge.ts +14 -3
  79. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  80. package/src/eval/py/executor.ts +23 -11
  81. package/src/eval/py/prelude.py +1 -1
  82. package/src/extensibility/extensions/types.ts +10 -1
  83. package/src/internal-urls/docs-index.generated.ts +7 -7
  84. package/src/lsp/client.ts +23 -11
  85. package/src/lsp/config.ts +11 -1
  86. package/src/lsp/index.ts +189 -61
  87. package/src/main.ts +144 -78
  88. package/src/mcp/tool-bridge.ts +2 -0
  89. package/src/memories/index.ts +2 -2
  90. package/src/modes/components/assistant-message.ts +3 -15
  91. package/src/modes/components/custom-editor.ts +143 -111
  92. package/src/modes/components/late-diagnostics-message.ts +60 -0
  93. package/src/modes/components/model-selector.ts +59 -13
  94. package/src/modes/components/oauth-selector.ts +33 -7
  95. package/src/modes/components/plan-review-overlay.ts +26 -5
  96. package/src/modes/components/read-tool-group.ts +415 -35
  97. package/src/modes/components/session-selector.ts +89 -35
  98. package/src/modes/components/status-line.ts +19 -4
  99. package/src/modes/components/tips.txt +1 -1
  100. package/src/modes/components/tool-execution.ts +7 -49
  101. package/src/modes/components/transcript-container.ts +108 -32
  102. package/src/modes/components/user-message.ts +1 -1
  103. package/src/modes/controllers/event-controller.ts +32 -1
  104. package/src/modes/controllers/input-controller.ts +56 -9
  105. package/src/modes/interactive-mode.ts +107 -20
  106. package/src/modes/magic-keywords.ts +1 -1
  107. package/src/modes/markdown-prose.ts +1 -1
  108. package/src/modes/theme/shimmer.ts +20 -9
  109. package/src/modes/types.ts +7 -0
  110. package/src/modes/utils/ui-helpers.ts +26 -5
  111. package/src/modes/workflow.ts +10 -10
  112. package/src/prompts/system/manual-continue.md +7 -0
  113. package/src/prompts/system/plan-mode-active.md +56 -72
  114. package/src/prompts/system/workflow-notice.md +1 -1
  115. package/src/prompts/tools/bash.md +9 -0
  116. package/src/prompts/tools/browser.md +1 -1
  117. package/src/prompts/tools/eval.md +5 -2
  118. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  119. package/src/prompts/tools/read.md +2 -2
  120. package/src/sdk.ts +85 -10
  121. package/src/session/agent-session.ts +42 -15
  122. package/src/session/auth-storage.ts +2 -0
  123. package/src/session/messages.ts +21 -14
  124. package/src/session/session-manager.ts +98 -25
  125. package/src/session/yield-queue.ts +20 -2
  126. package/src/task/executor.ts +72 -36
  127. package/src/task/render.ts +3 -4
  128. package/src/tiny/title-client.ts +6 -1
  129. package/src/tools/bash.ts +7 -7
  130. package/src/tools/browser/tab-supervisor.ts +13 -1
  131. package/src/tools/browser/tab-worker.ts +33 -4
  132. package/src/tools/eval-render.ts +4 -23
  133. package/src/tools/eval.ts +13 -2
  134. package/src/tools/find.ts +148 -99
  135. package/src/tools/gh-cache-invalidation.ts +200 -0
  136. package/src/tools/github-cache.ts +25 -0
  137. package/src/tools/index.ts +32 -0
  138. package/src/tools/inspect-image.ts +2 -2
  139. package/src/tools/path-utils.ts +47 -24
  140. package/src/tools/plan-mode-guard.ts +52 -7
  141. package/src/tools/read.ts +41 -20
  142. package/src/tools/render-utils.ts +3 -1
  143. package/src/tools/renderers.ts +0 -15
  144. package/src/tools/search.ts +38 -3
  145. package/src/tools/ssh.ts +0 -1
  146. package/src/tools/todo.ts +1 -0
  147. package/src/tools/write.ts +5 -14
  148. package/src/tools/yield.ts +10 -1
  149. package/src/tui/code-cell.ts +1 -6
  150. package/src/tui/hyperlink.ts +13 -23
  151. package/src/tui/output-block.ts +2 -97
  152. package/src/utils/commit-message-generator.ts +2 -2
  153. package/src/utils/enhanced-paste.ts +30 -2
  154. package/src/web/search/providers/codex.ts +37 -8
@@ -1,6 +1,7 @@
1
1
  // biome-ignore-all lint/suspicious/noTemplateCurlyInString: sample source-code strings (read fixtures) intentionally contain literal ${...}.
2
2
  // Gallery fixtures for the filesystem tools (read, write, find).
3
- import type { GalleryFixture } from "./types";
3
+ import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
4
+ import type { GalleryFixture, GalleryFixtureState, GalleryResult } from "./types";
4
5
 
5
6
  const readSnippet = [
6
7
  "export const findToolRenderer = {",
@@ -36,6 +37,64 @@ const writtenContent = [
36
37
  "",
37
38
  ].join("\n");
38
39
 
40
+ const groupedReadTargets = [
41
+ "packages/coding-agent/test/streaming-preview-height.test.ts:301-409",
42
+ "packages/coding-agent/test/tool-live-region-scrollback.test.ts:143-310",
43
+ "packages/tui/test/streaming-scrollback-defer.test.ts:89-464",
44
+ ];
45
+
46
+ const groupedReadDelimitedPath = groupedReadTargets.join(",");
47
+ const groupedReadRepeatedFile = "packages/coding-agent/src/task/render.ts";
48
+ const groupedReadRepeatedRanges = `${groupedReadRepeatedFile}:507-605,1070-1194,1210-1240,1270-1274`;
49
+
50
+ function textResult(text: string, details?: unknown, isError?: boolean): GalleryResult {
51
+ return { content: [{ type: "text", text }], details, isError };
52
+ }
53
+
54
+ function addGroupedReadArgs(component: ReadToolGroupComponent): void {
55
+ component.updateArgs({ path: groupedReadDelimitedPath }, "read-delimited");
56
+ component.updateArgs({ path: groupedReadRepeatedRanges }, "read-ranges");
57
+ }
58
+
59
+ function renderReadGroupFixtureState(state: GalleryFixtureState, width: number, expanded: boolean): string[] {
60
+ const component = new ReadToolGroupComponent();
61
+ component.setExpanded(expanded);
62
+
63
+ if (state === "streaming") {
64
+ component.updateArgs(
65
+ {
66
+ path: [
67
+ "packages/coding-agent/test/streaming-preview-height.test.ts:301-409",
68
+ "packages/coding-agent/test/tool-live-region-scrollback.test.ts:143-",
69
+ ].join(","),
70
+ },
71
+ "read-delimited",
72
+ );
73
+ return component.render(width);
74
+ }
75
+
76
+ addGroupedReadArgs(component);
77
+ if (state === "progress") return component.render(width);
78
+
79
+ component.updateResult(
80
+ textResult("Read three focused test ranges.", { displayReadTargets: groupedReadTargets }),
81
+ false,
82
+ "read-delimited",
83
+ );
84
+
85
+ if (state === "error") {
86
+ component.updateResult(
87
+ textResult("Error: selector 1270-1274 is outside the file", undefined, true),
88
+ false,
89
+ "read-ranges",
90
+ );
91
+ return component.render(width);
92
+ }
93
+
94
+ component.updateResult(textResult("Read four render.ts ranges."), false, "read-ranges");
95
+ return component.render(width);
96
+ }
97
+
39
98
  export const fsFixtures: Record<string, GalleryFixture> = {
40
99
  read: {
41
100
  label: "Read",
@@ -81,6 +140,14 @@ export const fsFixtures: Record<string, GalleryFixture> = {
81
140
  },
82
141
  },
83
142
 
143
+ read_group: {
144
+ label: "Read Groups",
145
+ args: {},
146
+ result: textResult("Rendered grouped read calls."),
147
+ errorResult: textResult("Rendered grouped read errors.", undefined, true),
148
+ renderState: renderReadGroupFixtureState,
149
+ },
150
+
84
151
  write: {
85
152
  label: "Write",
86
153
  // Streaming: path known, content still arriving (only the imports so far).
@@ -11,14 +11,21 @@ export interface GalleryResult {
11
11
  isError?: boolean;
12
12
  }
13
13
 
14
+ export type GalleryFixtureState = "streaming" | "progress" | "success" | "error";
15
+
14
16
  export interface GalleryFixture {
15
17
  /** Display label for the tool header (defaults to the tool name). */
16
18
  label?: string;
17
19
  /** Edit mode for edit-like tools so the streaming preview dispatches correctly. */
18
20
  editMode?: EditMode;
21
+ /**
22
+ * Custom gallery-only renderer for fixtures that are not one ToolExecutionComponent
23
+ * (for example the read-group transcript component).
24
+ */
25
+ renderState?: (state: GalleryFixtureState, width: number, expanded: boolean) => string[] | Promise<string[]>;
19
26
  /**
20
27
  * Set for tools whose real `AgentTool` attaches `renderCall`/`renderResult`
21
- * directly on the instance (e.g. `lsp`, `task`). The harness then attaches
28
+ * directly on the instance (e.g. `task`). The harness then attaches
22
29
  * the registry renderer onto the fake tool so the component routes through
23
30
  * the custom-tool branch — the same path production takes — instead of the
24
31
  * built-in registry branch. The two branches can diverge, so exercising the
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { getProjectDir, normalizePathForComparison, setProjectDir } from "@oh-my-pi/pi-utils";
5
+ import type { Args } from "./args";
6
+
7
+ async function maybeAutoChdir(parsed: Args): Promise<void> {
8
+ if (parsed.allowHome || parsed.cwd) {
9
+ return;
10
+ }
11
+
12
+ const home = os.homedir();
13
+ if (!home) {
14
+ return;
15
+ }
16
+
17
+ const normalizePath = normalizePathForComparison;
18
+
19
+ const cwd = normalizePath(getProjectDir());
20
+ const normalizedHome = normalizePath(home);
21
+ if (cwd !== normalizedHome) {
22
+ return;
23
+ }
24
+
25
+ const isDirectory = async (p: string) => {
26
+ try {
27
+ const s = await fs.stat(p);
28
+ return s.isDirectory();
29
+ } catch {
30
+ return false;
31
+ }
32
+ };
33
+
34
+ const candidates = [path.join(home, "tmp"), "/tmp", "/var/tmp"];
35
+ for (const candidate of candidates) {
36
+ try {
37
+ if (!(await isDirectory(candidate))) {
38
+ continue;
39
+ }
40
+ setProjectDir(candidate);
41
+ return;
42
+ } catch {
43
+ // Try next candidate.
44
+ }
45
+ }
46
+
47
+ try {
48
+ const fallback = os.tmpdir();
49
+ if (fallback && normalizePath(fallback) !== cwd && (await isDirectory(fallback))) {
50
+ setProjectDir(fallback);
51
+ }
52
+ } catch {
53
+ // Ignore fallback errors.
54
+ }
55
+ }
56
+
57
+ export async function applyStartupCwd(parsed: Args): Promise<void> {
58
+ if (parsed.cwd) {
59
+ setProjectDir(parsed.cwd);
60
+ // setProjectDir resolves the (possibly relative) target against the launch
61
+ // cwd and chdirs into it. Re-sync parsed.cwd to the resolved absolute path
62
+ // so downstream consumers (buildSessionOptions, settings/discovery, session
63
+ // persistence) don't re-resolve a relative string against the new cwd.
64
+ parsed.cwd = getProjectDir();
65
+ return;
66
+ }
67
+ await maybeAutoChdir(parsed);
68
+ }
@@ -49,6 +49,9 @@ export default class Index extends Command {
49
49
  "allow-home": Flags.boolean({
50
50
  description: "Allow starting in ~ without auto-switching to a temp dir",
51
51
  }),
52
+ cwd: Flags.string({
53
+ description: "Directory to start in (overrides the launch cwd)",
54
+ }),
52
55
  mode: Flags.string({
53
56
  description: "Output mode: text (default), json, rpc, or rpc-ui",
54
57
  options: ["text", "json", "rpc", "acp", "rpc-ui"],
@@ -170,6 +170,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
170
170
  await session.prompt(reminder, {
171
171
  attribution: "agent",
172
172
  expandPromptTemplates: false,
173
+ synthetic: true,
173
174
  });
174
175
  }
175
176
 
@@ -3,6 +3,7 @@ import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
3
3
  import type { ApiKeyResolverRegistry } from "../config/api-key-resolver";
4
4
  import { MODEL_ROLE_IDS } from "../config/model-registry";
5
5
  import {
6
+ getModelMatchPreferences,
6
7
  type ModelLookupRegistry,
7
8
  parseModelPattern,
8
9
  resolveModelRoleValue,
@@ -33,7 +34,7 @@ export async function resolvePrimaryModel(
33
34
  modelRegistry: CommitModelRegistry,
34
35
  ): Promise<ResolvedCommitModel> {
35
36
  const available = modelRegistry.getAvailable();
36
- const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
37
+ const matchPreferences = getModelMatchPreferences(settings);
37
38
  const resolved = override
38
39
  ? resolveModelRoleValue(override, available, { settings, matchPreferences, modelRegistry })
39
40
  : resolveRoleSelection(["commit", "smol", ...MODEL_ROLE_IDS], settings, available, modelRegistry);
@@ -73,7 +74,7 @@ export async function resolveSmolModel(
73
74
  }
74
75
  }
75
76
 
76
- const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
77
+ const matchPreferences = getModelMatchPreferences(settings);
77
78
  for (const pattern of MODEL_PRIO.smol) {
78
79
  const candidate = parseModelPattern(pattern, available, matchPreferences, { modelRegistry }).model;
79
80
  if (!candidate) continue;
@@ -0,0 +1,55 @@
1
+ const DEFAULT_MODEL_PROVIDER_ORDER = [
2
+ // First-party / native account providers. Prefer these over relays when the
3
+ // same upstream model is available in more than one place.
4
+ "openai-codex",
5
+ "anthropic",
6
+ "openai",
7
+ "google-gemini-cli",
8
+ "google",
9
+ "google-vertex",
10
+ "kimi-code",
11
+ "moonshot",
12
+ "qwen-portal",
13
+ "zai",
14
+ "xai-oauth",
15
+ "xai",
16
+ "mistral",
17
+ "deepseek",
18
+ "groq",
19
+
20
+ // High-quality aggregators / hosted inference providers.
21
+ "fireworks",
22
+ "cerebras",
23
+ "openrouter",
24
+ "together",
25
+
26
+ // Generic gateways and editor/proxy providers. These are useful when picked
27
+ // explicitly, but should not win ambiguous automatic role selection.
28
+ "alibaba-coding-plan",
29
+ "google-antigravity",
30
+ "opencode-zen",
31
+ "gitlab-duo",
32
+ "opencode-go",
33
+ "kilo",
34
+ "vercel-ai-gateway",
35
+ "cloudflare-ai-gateway",
36
+ "nanogpt",
37
+ "github-copilot",
38
+ ] as const;
39
+
40
+ function addProviderRank(rank: Map<string, number>, provider: string): void {
41
+ const normalized = provider.trim().toLowerCase();
42
+ if (!normalized || rank.has(normalized)) return;
43
+ rank.set(normalized, rank.size);
44
+ }
45
+
46
+ export function buildModelProviderPriorityRank(configuredProviderOrder?: readonly string[]): Map<string, number> {
47
+ const rank = new Map<string, number>();
48
+ for (const provider of configuredProviderOrder ?? []) {
49
+ addProviderRank(rank, provider);
50
+ }
51
+ for (const provider of DEFAULT_MODEL_PROVIDER_ORDER) {
52
+ addProviderRank(rank, provider);
53
+ }
54
+ return rank;
55
+ }
@@ -118,6 +118,7 @@ import {
118
118
  getModelLikeIdSegments,
119
119
  stripBracketedModelIdAffixes,
120
120
  } from "./model-id-affixes";
121
+ import { buildModelProviderPriorityRank } from "./model-provider-priority";
121
122
  import {
122
123
  type ModelOverride,
123
124
  type ModelsConfig,
@@ -2208,27 +2209,8 @@ export class ModelRegistry {
2208
2209
  });
2209
2210
  }
2210
2211
 
2211
- #providerRank(models: readonly Model<Api>[]): Map<string, number> {
2212
- const configuredProviders = getConfiguredProviderOrderFromSettings();
2213
- const result = new Map<string, number>();
2214
- let nextRank = 0;
2215
- for (const provider of configuredProviders) {
2216
- const normalized = provider.trim().toLowerCase();
2217
- if (!normalized || result.has(normalized)) {
2218
- continue;
2219
- }
2220
- result.set(normalized, nextRank);
2221
- nextRank += 1;
2222
- }
2223
- for (const model of models) {
2224
- const normalized = model.provider.toLowerCase();
2225
- if (result.has(normalized)) {
2226
- continue;
2227
- }
2228
- result.set(normalized, nextRank);
2229
- nextRank += 1;
2230
- }
2231
- return result;
2212
+ #providerRank(): Map<string, number> {
2213
+ return buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings());
2232
2214
  }
2233
2215
 
2234
2216
  #resolveCanonicalVariant(
@@ -2238,7 +2220,7 @@ export class ModelRegistry {
2238
2220
  if (variants.length === 0) {
2239
2221
  return undefined;
2240
2222
  }
2241
- const providerRank = this.#providerRank(allCandidates);
2223
+ const providerRank = this.#providerRank();
2242
2224
  const modelOrder = new Map<string, number>();
2243
2225
  for (let index = 0; index < allCandidates.length; index += 1) {
2244
2226
  modelOrder.set(formatCanonicalVariantSelector(allCandidates[index]!), index);
@@ -17,6 +17,7 @@ import { logger } from "@oh-my-pi/pi-utils";
17
17
  import chalk from "chalk";
18
18
  import MODEL_PRIO from "../priority.json" with { type: "json" };
19
19
  import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
20
+ import { buildModelProviderPriorityRank } from "./model-provider-priority";
20
21
  import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
21
22
  import type { Settings } from "./settings";
22
23
 
@@ -179,7 +180,9 @@ export function resolveProviderModelReference(
179
180
  export interface ModelMatchPreferences {
180
181
  /** Most-recently-used model keys (provider/modelId) to prefer when ambiguous. */
181
182
  usageOrder?: string[];
182
- /** Providers to deprioritize when no recent usage is available. */
183
+ /** Provider precedence used for ambiguous unqualified model patterns. */
184
+ providerOrder?: readonly string[];
185
+ /** Providers to deprioritize when no recent usage or provider priority is available. */
183
186
  deprioritizeProviders?: string[];
184
187
  }
185
188
 
@@ -194,6 +197,7 @@ type RestorableModelRegistry = Pick<ModelRegistry, "getAvailable" | "find" | "ge
194
197
  interface ModelPreferenceContext {
195
198
  modelUsageRank: Map<string, number>;
196
199
  providerUsageRank: Map<string, number>;
200
+ providerPriorityRank: Map<string, number>;
197
201
  deprioritizedProviders: Set<string>;
198
202
  modelOrder: Map<string, number>;
199
203
  }
@@ -215,14 +219,35 @@ function buildPreferenceContext(
215
219
  providerUsageRank.set(parsed.provider, i);
216
220
  }
217
221
  }
218
-
219
- const deprioritizedProviders = new Set(preferences?.deprioritizeProviders ?? ["openrouter"]);
222
+ const providerPriorityRank = buildModelProviderPriorityRank(preferences?.providerOrder);
223
+ const deprioritizedProviders = new Set(preferences?.deprioritizeProviders ?? []);
220
224
  const modelOrder = new Map<string, number>();
221
225
  for (let i = 0; i < availableModels.length; i += 1) {
222
226
  modelOrder.set(formatModelString(availableModels[i]), i);
223
227
  }
224
228
 
225
- return { modelUsageRank, providerUsageRank, deprioritizedProviders, modelOrder };
229
+ return { modelUsageRank, providerUsageRank, providerPriorityRank, deprioritizedProviders, modelOrder };
230
+ }
231
+
232
+ export function getModelMatchPreferences(
233
+ settings?: Partial<Pick<Settings, "get" | "getStorage">>,
234
+ ): ModelMatchPreferences {
235
+ return {
236
+ usageOrder: settings?.getStorage?.()?.getModelUsageOrder(),
237
+ providerOrder: settings?.get?.("modelProviderOrder"),
238
+ };
239
+ }
240
+
241
+ function mergeModelMatchPreferences(
242
+ settings: Settings | undefined,
243
+ preferences: ModelMatchPreferences | undefined,
244
+ ): ModelMatchPreferences {
245
+ const settingsPreferences = getModelMatchPreferences(settings);
246
+ return {
247
+ usageOrder: preferences?.usageOrder ?? settingsPreferences.usageOrder,
248
+ providerOrder: preferences?.providerOrder ?? settingsPreferences.providerOrder,
249
+ deprioritizeProviders: preferences?.deprioritizeProviders,
250
+ };
226
251
  }
227
252
 
228
253
  function pickPreferredModel(candidates: Model<Api>[], context: ModelPreferenceContext): Model<Api> {
@@ -236,6 +261,12 @@ function pickPreferredModel(candidates: Model<Api>[], context: ModelPreferenceCo
236
261
  return (aUsage ?? Number.POSITIVE_INFINITY) - (bUsage ?? Number.POSITIVE_INFINITY);
237
262
  }
238
263
 
264
+ const aProviderPriority = context.providerPriorityRank.get(a.provider.toLowerCase());
265
+ const bProviderPriority = context.providerPriorityRank.get(b.provider.toLowerCase());
266
+ if (aProviderPriority !== undefined || bProviderPriority !== undefined) {
267
+ return (aProviderPriority ?? Number.POSITIVE_INFINITY) - (bProviderPriority ?? Number.POSITIVE_INFINITY);
268
+ }
269
+
239
270
  const aProviderUsage = context.providerUsageRank.get(a.provider);
240
271
  const bProviderUsage = context.providerUsageRank.get(b.provider);
241
272
  if (aProviderUsage !== undefined || bProviderUsage !== undefined) {
@@ -618,8 +649,9 @@ export function resolveModelRoleValue(
618
649
  }
619
650
 
620
651
  let warning: string | undefined;
652
+ const matchPreferences = mergeModelMatchPreferences(options?.settings, options?.matchPreferences);
621
653
  for (const effectivePattern of effectivePatterns) {
622
- const resolved = parseModelPattern(effectivePattern, availableModels, options?.matchPreferences, {
654
+ const resolved = parseModelPattern(effectivePattern, availableModels, matchPreferences, {
623
655
  modelRegistry: options?.modelRegistry,
624
656
  });
625
657
  if (resolved.model) {
@@ -720,7 +752,7 @@ export function resolveModelOverride(
720
752
  ): { model?: Model<Api>; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
721
753
  if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
722
754
  const availableModels = modelRegistry.getAvailable();
723
- const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
755
+ const matchPreferences = getModelMatchPreferences(settings);
724
756
  for (const pattern of modelPatterns) {
725
757
  const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
726
758
  settings,
@@ -800,7 +832,7 @@ export function resolveRoleSelection(
800
832
  availableModels: Model<Api>[],
801
833
  modelRegistry?: CanonicalModelRegistry,
802
834
  ): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
803
- const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
835
+ const matchPreferences = getModelMatchPreferences(settings);
804
836
  for (const role of roles) {
805
837
  const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
806
838
  settings,
@@ -72,7 +72,7 @@ export interface SettingsOptions {
72
72
  /**
73
73
  * Get a nested value from an object by path segments.
74
74
  */
75
- function getByPath(obj: RawSettings, segments: string[]): unknown {
75
+ function getByPath(obj: RawSettings, segments: readonly string[]): unknown {
76
76
  let current: unknown = obj;
77
77
  for (const segment of segments) {
78
78
  if (current === null || current === undefined || typeof current !== "object") {
@@ -83,6 +83,10 @@ function getByPath(obj: RawSettings, segments: string[]): unknown {
83
83
  return current;
84
84
  }
85
85
 
86
+ const SETTING_PATH_SEGMENTS: Record<SettingPath, readonly string[]> = Object.fromEntries(
87
+ (Object.keys(SETTINGS_SCHEMA) as SettingPath[]).map(settingPath => [settingPath, settingPath.split(".")]),
88
+ ) as unknown as Record<SettingPath, readonly string[]>;
89
+
86
90
  /**
87
91
  * Set a nested value in an object by path segments.
88
92
  * Creates intermediate objects as needed.
@@ -196,6 +200,8 @@ export class Settings {
196
200
  #overrides: RawSettings = {};
197
201
  /** Merged view (global + project + overrides) */
198
202
  #merged: RawSettings = {};
203
+ /** Cached resolved values from the merged view, including defaults/path scoping */
204
+ #resolvedCache = new Map<SettingPath, unknown>();
199
205
 
200
206
  /** Paths modified during this session (for partial save) */
201
207
  #modified = new Set<string>();
@@ -282,13 +288,15 @@ export class Settings {
282
288
  * Returns the merged value from global + project + overrides, or the default.
283
289
  */
284
290
  get<P extends SettingPath>(path: P): SettingValue<P> {
285
- const segments = path.split(".");
286
- const value = getByPath(this.#merged, segments);
287
- if (value !== undefined) {
288
- const pathScopedValue = resolvePathScopedStringArray(path, value, this.#cwd);
289
- return (pathScopedValue ?? value) as SettingValue<P>;
291
+ if (this.#resolvedCache.has(path)) {
292
+ return this.#resolvedCache.get(path) as SettingValue<P>;
290
293
  }
291
- return getDefault(path);
294
+
295
+ const value = getByPath(this.#merged, SETTING_PATH_SEGMENTS[path]);
296
+ const resolved =
297
+ value !== undefined ? (resolvePathScopedStringArray(path, value, this.#cwd) ?? value) : getDefault(path);
298
+ this.#resolvedCache.set(path, resolved);
299
+ return resolved as SettingValue<P>;
292
300
  }
293
301
 
294
302
  /**
@@ -302,6 +310,7 @@ export class Settings {
302
310
  setByPath(this.#global, segments, value);
303
311
  this.#modified.add(path);
304
312
  this.#rebuildMerged();
313
+ const next = this.get(path);
305
314
  this.#queueSave();
306
315
 
307
316
  // Trigger hook if exists
@@ -309,21 +318,25 @@ export class Settings {
309
318
  if (hook) {
310
319
  hook(value, prev);
311
320
  }
321
+ this.#fireEffectiveSettingChanged(path, next, prev);
312
322
  }
313
323
 
314
324
  /**
315
325
  * Apply runtime overrides (not persisted).
316
326
  */
317
327
  override<P extends SettingPath>(path: P, value: SettingValue<P>): void {
328
+ const prev = this.get(path);
318
329
  const segments = path.split(".");
319
330
  setByPath(this.#overrides, segments, value);
320
331
  this.#rebuildMerged();
332
+ this.#fireEffectiveSettingChanged(path, this.get(path), prev);
321
333
  }
322
334
 
323
335
  /**
324
336
  * Clear a runtime override.
325
337
  */
326
338
  clearOverride(path: SettingPath): void {
339
+ const prev = this.get(path);
327
340
  const segments = path.split(".");
328
341
  let current = this.#overrides;
329
342
  for (let i = 0; i < segments.length - 1; i++) {
@@ -333,6 +346,14 @@ export class Settings {
333
346
  }
334
347
  delete current[segments[segments.length - 1]];
335
348
  this.#rebuildMerged();
349
+ this.#fireEffectiveSettingChanged(path, this.get(path), prev);
350
+ }
351
+
352
+ #fireEffectiveSettingChanged(path: SettingPath, value: unknown, prev: unknown): void {
353
+ if (Object.is(value, prev)) return;
354
+ if (path === "statusLine.sessionAccent") {
355
+ statusLineSessionAccentSignal.fire();
356
+ }
336
357
  }
337
358
 
338
359
  /**
@@ -842,6 +863,7 @@ export class Settings {
842
863
  #rebuildMerged(): void {
843
864
  this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project);
844
865
  this.#merged = this.#deepMerge(this.#merged, this.#overrides);
866
+ this.#resolvedCache.clear();
845
867
  }
846
868
 
847
869
  #fireAllHooks(): void {
@@ -885,6 +907,45 @@ export class Settings {
885
907
 
886
908
  type SettingHook<P extends SettingPath> = (value: SettingValue<P>, prev: SettingValue<P>) => void;
887
909
 
910
+ /**
911
+ * Minimal change-notification primitive backing the exported `on*Changed`
912
+ * subscriptions. Holds a listener set, hands out unsubscribe closures, and
913
+ * isolates errors so a single throwing listener can't abort the rest or bubble
914
+ * out of `Settings.set()`.
915
+ *
916
+ * @typeParam A - argument tuple forwarded to each listener on `fire`.
917
+ */
918
+ class SettingSignal<A extends unknown[] = []> {
919
+ #listeners = new Set<(...args: A) => void>();
920
+
921
+ constructor(private readonly label: string) {}
922
+
923
+ /** Subscribe `cb`; returns an unsubscribe function. */
924
+ on(cb: (...args: A) => void): () => void {
925
+ this.#listeners.add(cb);
926
+ return () => {
927
+ this.#listeners.delete(cb);
928
+ };
929
+ }
930
+
931
+ /**
932
+ * Invoke every listener with `args`. Iterates a snapshot so a listener may
933
+ * (un)subscribe mid-fire without re-entrancy — the Hindsight backend
934
+ * re-registers the fresh state's listener on every rebuild — and wraps each
935
+ * call so a throwing listener is logged and skipped instead of aborting the
936
+ * rest.
937
+ */
938
+ fire(...args: A): void {
939
+ for (const cb of [...this.#listeners]) {
940
+ try {
941
+ cb(...args);
942
+ } catch (err) {
943
+ logger.warn(`Settings: ${this.label} hook failed`, { error: String(err) });
944
+ }
945
+ }
946
+ }
947
+ }
948
+
888
949
  const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
889
950
  "theme.dark": value => {
890
951
  if (typeof value === "string") {
@@ -917,45 +978,34 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
917
978
  },
918
979
  "provider.appendOnlyContext": value => {
919
980
  if (typeof value === "string") {
920
- for (const cb of appendOnlyModeCallbacks) cb(value);
981
+ appendOnlyModeSignal.fire(value);
921
982
  }
922
983
  },
923
- "hindsight.bankId": () => fireHindsightScopeChanged(),
924
- "hindsight.bankIdPrefix": () => fireHindsightScopeChanged(),
925
- "hindsight.scoping": () => fireHindsightScopeChanged(),
984
+ "hindsight.bankId": () => hindsightScopeSignal.fire(),
985
+ "hindsight.bankIdPrefix": () => hindsightScopeSignal.fire(),
986
+ "hindsight.scoping": () => hindsightScopeSignal.fire(),
926
987
  };
927
- /** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */
928
- const appendOnlyModeCallbacks = new Set<(value: string) => void>();
988
+ /** Fires when `provider.appendOnlyContext` changes at runtime. */
989
+ const appendOnlyModeSignal = new SettingSignal<[value: string]>("provider.appendOnlyContext");
929
990
 
930
991
  /**
931
992
  * Subscribe to append-only mode setting changes.
932
993
  * Returns an unsubscribe function. Multiple sessions (main + subagents)
933
994
  * can register independently without overwriting each other.
934
995
  */
935
- export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void {
936
- appendOnlyModeCallbacks.add(cb);
937
- return () => {
938
- appendOnlyModeCallbacks.delete(cb);
939
- };
940
- }
996
+ export const onAppendOnlyModeChanged = (cb: (value: string) => void) => appendOnlyModeSignal.on(cb);
941
997
 
942
- /** Callbacks fired when any `hindsight.bankId` / `bankIdPrefix` / `scoping` value changes. */
943
- const hindsightScopeCallbacks = new Set<() => void>();
998
+ /** Fires when `statusLine.sessionAccent` changes at runtime. */
999
+ const statusLineSessionAccentSignal = new SettingSignal("statusLine.sessionAccent");
944
1000
 
945
- function fireHindsightScopeChanged(): void {
946
- // Snapshot the callback set before invoking — a callback's body is allowed
947
- // to subscribe a NEW callback (the Hindsight backend re-registers the
948
- // fresh state's listener on every rebuild). Iterating the live Set would
949
- // re-invoke those just-added callbacks within the same fire, which spins
950
- // in place: subscribe → invoke → subscribe → invoke → …
951
- for (const cb of [...hindsightScopeCallbacks]) {
952
- try {
953
- cb();
954
- } catch (err) {
955
- logger.warn("Settings: hindsight scope hook failed", { error: String(err) });
956
- }
957
- }
958
- }
1001
+ /**
1002
+ * Subscribe to session-accent setting changes.
1003
+ * Returns an unsubscribe function. Callers should re-read settings in the callback.
1004
+ */
1005
+ export const onStatusLineSessionAccentChanged = (cb: () => void) => statusLineSessionAccentSignal.on(cb);
1006
+
1007
+ /** Fires when any `hindsight.bankId` / `bankIdPrefix` / `scoping` value changes. */
1008
+ const hindsightScopeSignal = new SettingSignal("hindsight scope");
959
1009
 
960
1010
  /**
961
1011
  * Subscribe to changes in the Hindsight bank-scoping settings. Lets the
@@ -967,12 +1017,7 @@ function fireHindsightScopeChanged(): void {
967
1017
  * Returns an unsubscribe function. The callback receives no arguments — the
968
1018
  * caller is expected to re-read the relevant settings via `Settings.get`.
969
1019
  */
970
- export function onHindsightScopeChanged(cb: () => void): () => void {
971
- hindsightScopeCallbacks.add(cb);
972
- return () => {
973
- hindsightScopeCallbacks.delete(cb);
974
- };
975
- }
1020
+ export const onHindsightScopeChanged = (cb: () => void) => hindsightScopeSignal.on(cb);
976
1021
 
977
1022
  // ═══════════════════════════════════════════════════════════════════════════
978
1023
  // Global Singleton