@oh-my-pi/pi-coding-agent 17.0.6 → 17.0.8

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 (83) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +4967 -4948
  3. package/dist/types/config/model-registry.d.ts +1 -1
  4. package/dist/types/config/model-resolver.d.ts +5 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -0
  6. package/dist/types/dap/client.d.ts +19 -0
  7. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  8. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  9. package/dist/types/extensibility/typebox.d.ts +4 -0
  10. package/dist/types/hindsight/client.d.ts +10 -0
  11. package/dist/types/hindsight/config.d.ts +8 -0
  12. package/dist/types/mcp/tool-bridge.d.ts +9 -0
  13. package/dist/types/mnemopi/state.d.ts +2 -0
  14. package/dist/types/modes/components/user-message.d.ts +25 -1
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +7 -1
  17. package/dist/types/modes/types.d.ts +14 -1
  18. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  19. package/dist/types/sdk.d.ts +1 -0
  20. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  21. package/dist/types/session/agent-session.d.ts +78 -2
  22. package/dist/types/task/executor.d.ts +10 -0
  23. package/dist/types/task/index.d.ts +1 -0
  24. package/dist/types/task/prewalk.d.ts +3 -0
  25. package/dist/types/tools/ask.d.ts +10 -0
  26. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  27. package/dist/types/utils/changelog.d.ts +4 -6
  28. package/package.json +12 -13
  29. package/src/cli/models-cli.ts +37 -17
  30. package/src/cli/update-cli.ts +14 -1
  31. package/src/config/model-discovery.ts +3 -2
  32. package/src/config/model-registry.ts +53 -26
  33. package/src/config/model-resolver.ts +56 -31
  34. package/src/config/settings-schema.ts +5 -0
  35. package/src/config/settings.ts +68 -5
  36. package/src/dap/client.ts +86 -7
  37. package/src/edit/diff.ts +4 -4
  38. package/src/eval/py/prelude.py +5 -5
  39. package/src/export/html/template.js +23 -9
  40. package/src/extensibility/extensions/runner.ts +9 -4
  41. package/src/extensibility/extensions/types.ts +3 -0
  42. package/src/extensibility/typebox.ts +22 -9
  43. package/src/hindsight/client.test.ts +42 -0
  44. package/src/hindsight/client.ts +40 -3
  45. package/src/hindsight/config.ts +18 -0
  46. package/src/launch/broker.ts +31 -5
  47. package/src/lsp/index.ts +1 -1
  48. package/src/main.ts +15 -5
  49. package/src/mcp/tool-bridge.ts +9 -0
  50. package/src/mnemopi/state.ts +70 -3
  51. package/src/modes/components/agent-dashboard.ts +6 -2
  52. package/src/modes/components/chat-transcript-builder.ts +13 -2
  53. package/src/modes/components/diff.ts +2 -2
  54. package/src/modes/components/plan-review-overlay.ts +20 -4
  55. package/src/modes/components/user-message.ts +100 -1
  56. package/src/modes/controllers/command-controller.ts +22 -5
  57. package/src/modes/controllers/event-controller.ts +4 -1
  58. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  59. package/src/modes/controllers/input-controller.ts +47 -12
  60. package/src/modes/controllers/selector-controller.ts +82 -6
  61. package/src/modes/interactive-mode.ts +36 -4
  62. package/src/modes/types.ts +16 -3
  63. package/src/modes/utils/ui-helpers.ts +40 -20
  64. package/src/prompts/system/workflow-notice.md +1 -1
  65. package/src/sdk.ts +134 -48
  66. package/src/session/agent-session-error-log.test.ts +59 -0
  67. package/src/session/agent-session.ts +300 -26
  68. package/src/system-prompt.ts +1 -2
  69. package/src/task/executor.ts +41 -27
  70. package/src/task/index.ts +11 -0
  71. package/src/task/prewalk.ts +6 -0
  72. package/src/task/worktree.ts +25 -11
  73. package/src/tools/ask.ts +15 -0
  74. package/src/tools/bash.ts +14 -6
  75. package/src/tools/browser.ts +1 -1
  76. package/src/tools/debug.ts +1 -1
  77. package/src/tools/eval.ts +4 -5
  78. package/src/tools/fetch.ts +1 -1
  79. package/src/tools/hub/launch.ts +7 -0
  80. package/src/tools/tool-timeouts.ts +10 -3
  81. package/src/tools/write.ts +31 -0
  82. package/src/utils/changelog.ts +54 -58
  83. package/src/utils/git.ts +57 -49
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "17.0.6",
4
+ "version": "17.0.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "17.0.6",
56
- "@oh-my-pi/omp-stats": "17.0.6",
57
- "@oh-my-pi/pi-agent-core": "17.0.6",
58
- "@oh-my-pi/pi-ai": "17.0.6",
59
- "@oh-my-pi/pi-catalog": "17.0.6",
60
- "@oh-my-pi/pi-mnemopi": "17.0.6",
61
- "@oh-my-pi/pi-natives": "17.0.6",
62
- "@oh-my-pi/pi-tui": "17.0.6",
63
- "@oh-my-pi/pi-utils": "17.0.6",
64
- "@oh-my-pi/pi-wire": "17.0.6",
65
- "@oh-my-pi/snapcompact": "17.0.6",
55
+ "@oh-my-pi/hashline": "17.0.8",
56
+ "@oh-my-pi/omp-stats": "17.0.8",
57
+ "@oh-my-pi/pi-agent-core": "17.0.8",
58
+ "@oh-my-pi/pi-ai": "17.0.8",
59
+ "@oh-my-pi/pi-catalog": "17.0.8",
60
+ "@oh-my-pi/pi-mnemopi": "17.0.8",
61
+ "@oh-my-pi/pi-natives": "17.0.8",
62
+ "@oh-my-pi/pi-tui": "17.0.8",
63
+ "@oh-my-pi/pi-utils": "17.0.8",
64
+ "@oh-my-pi/pi-wire": "17.0.8",
65
+ "@oh-my-pi/snapcompact": "17.0.8",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/api-logs": "^0.220.0",
68
68
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -79,7 +79,6 @@
79
79
  "@xterm/headless": "^6.0.0",
80
80
  "arktype": "2.2.3",
81
81
  "chalk": "^5.6.2",
82
- "diff": "^9.0.0",
83
82
  "fast-xml-parser": "^5.9.3",
84
83
  "handlebars": "^4.7.9",
85
84
  "header-generator": "^2.1.82",
@@ -17,8 +17,14 @@ import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
17
17
  import chalk from "chalk";
18
18
  import { ModelRegistry } from "../config/model-registry";
19
19
  import { Settings } from "../config/settings";
20
- import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions";
20
+ import {
21
+ discoverAndLoadExtensions,
22
+ ExtensionRunner,
23
+ emitSessionShutdownEvent,
24
+ loadExtensions,
25
+ } from "../extensibility/extensions";
21
26
  import { discoverAuthStorage } from "../sdk";
27
+ import { SessionManager } from "../session/session-manager";
22
28
  import { EventBus } from "../utils/event-bus";
23
29
 
24
30
  export type ModelsAction = "ls" | "find" | "refresh";
@@ -303,25 +309,39 @@ export async function runModelsListing(options: RunModelsListingOptions): Promis
303
309
  eventBus,
304
310
  disabledExtensionIds,
305
311
  );
312
+ const extensionRunner =
313
+ extensionsResult.extensions.length > 0
314
+ ? new ExtensionRunner(
315
+ extensionsResult.extensions,
316
+ extensionsResult.runtime,
317
+ cwd,
318
+ SessionManager.inMemory(cwd),
319
+ modelRegistry,
320
+ )
321
+ : undefined;
306
322
 
307
- for (const { path: extPath, error } of extensionsResult.errors) {
308
- process.stderr.write(`Failed to load extension: ${extPath}: ${error}\n`);
309
- }
323
+ try {
324
+ for (const { path: extPath, error } of extensionsResult.errors) {
325
+ process.stderr.write(`Failed to load extension: ${extPath}: ${error}\n`);
326
+ }
310
327
 
311
- // Mirror sdk.ts: drain pending provider registrations into the registry.
312
- const activeSources = extensionsResult.extensions.map(extension => extension.path);
313
- modelRegistry.syncExtensionSources(activeSources);
314
- for (const sourceId of new Set(activeSources)) {
315
- modelRegistry.clearSourceRegistrations(sourceId);
316
- }
317
- for (const { name, config, sourceId } of extensionsResult.runtime.pendingProviderRegistrations) {
318
- modelRegistry.registerProvider(name, config, sourceId);
319
- }
320
- extensionsResult.runtime.pendingProviderRegistrations = [];
321
- // Discover runtime (extension) provider catalogs now that they are registered.
322
- await modelRegistry.refreshRuntimeProviders(action === "refresh" ? "online" : "online-if-uncached");
328
+ // Mirror sdk.ts: drain pending provider registrations into the registry.
329
+ const activeSources = extensionsResult.extensions.map(extension => extension.path);
330
+ modelRegistry.syncExtensionSources(activeSources);
331
+ for (const sourceId of new Set(activeSources)) {
332
+ modelRegistry.clearSourceRegistrations(sourceId);
333
+ }
334
+ for (const { name, config, sourceId } of extensionsResult.runtime.pendingProviderRegistrations) {
335
+ modelRegistry.registerProvider(name, config, sourceId);
336
+ }
337
+ extensionsResult.runtime.pendingProviderRegistrations = [];
338
+ // Discover runtime (extension) provider catalogs now that they are registered.
339
+ await modelRegistry.refreshRuntimeProviders(action === "refresh" ? "online" : "online-if-uncached");
323
340
 
324
- renderProviderModels(modelRegistry, action, pattern, json);
341
+ renderProviderModels(modelRegistry, action, pattern, json);
342
+ } finally {
343
+ await emitSessionShutdownEvent(extensionRunner);
344
+ }
325
345
  }
326
346
 
327
347
  /**
@@ -556,6 +556,19 @@ async function pruneBunCacheAfterGlobalInstall(): Promise<BunInstallCachePruneRe
556
556
  return await pruneBunInstallCache(cacheDir, packageNames.size === 0 ? undefined : packageNames);
557
557
  }
558
558
 
559
+ /**
560
+ * Detect a musl-libc Linux host (Alpine, Void-musl) so self-update replaces a
561
+ * musl binary with the musl release asset instead of the glibc build, which
562
+ * would fail to start on the next run. Mirrors the detection in
563
+ * scripts/install.sh.
564
+ */
565
+ function isMuslLinux(): boolean {
566
+ if (process.platform !== "linux") return false;
567
+ if (fs.existsSync("/etc/alpine-release")) return true;
568
+ const loaderArch = process.arch === "arm64" ? "aarch64" : "x86_64";
569
+ return fs.existsSync(`/lib/ld-musl-${loaderArch}.so.1`);
570
+ }
571
+
559
572
  /**
560
573
  * Get the appropriate binary name for this platform.
561
574
  */
@@ -566,7 +579,7 @@ function getBinaryName(): string {
566
579
  let os: string;
567
580
  switch (platform) {
568
581
  case "linux":
569
- os = "linux";
582
+ os = isMuslLinux() ? "linux-musl" : "linux";
570
583
  break;
571
584
  case "darwin":
572
585
  os = "darwin";
@@ -10,6 +10,7 @@ import type { Api, Model, RemoteCompactionConfig } from "@oh-my-pi/pi-ai/types";
10
10
  import { buildModel } from "@oh-my-pi/pi-catalog/build";
11
11
  import {
12
12
  getBundledModelReferenceIndex,
13
+ inheritReferenceThinking,
13
14
  isQwenModelId,
14
15
  resolveModelReference,
15
16
  stripBracketedModelIdAffixes,
@@ -752,7 +753,7 @@ export async function discoverOpenAIModelsList(
752
753
  provider: providerConfig.provider,
753
754
  baseUrl,
754
755
  reasoning: reference?.reasoning ?? false,
755
- thinking: reference?.thinking,
756
+ thinking: inheritReferenceThinking(undefined, reference, providerConfig.provider),
756
757
  input: nativeMetadataForModel?.input ?? reference?.input ?? ["text"],
757
758
  ...(providerConfig.discovery.type === "lm-studio" ? { imageInputDecoder: "stb" as const } : {}),
758
759
  // Proxy/gateway pricing is provider-specific and rarely matches
@@ -907,7 +908,7 @@ export async function discoverProxyModels(
907
908
  provider: providerConfig.provider,
908
909
  baseUrl,
909
910
  reasoning: reference?.reasoning ?? false,
910
- thinking: reference?.thinking,
911
+ thinking: inheritReferenceThinking(undefined, reference, providerConfig.provider),
911
912
  input: reference?.input ?? ["text"],
912
913
  // Proxy pricing is provider-specific and usually does not match
913
914
  // upstream bundled catalogs, so keep costs local-unknown even when
@@ -23,6 +23,7 @@ import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/mode
23
23
  import {
24
24
  googleAntigravityModelManagerOptions,
25
25
  googleGeminiCliModelManagerOptions,
26
+ type OpenAICodexAccount,
26
27
  openaiCodexModelManagerOptions,
27
28
  PROVIDER_DESCRIPTORS,
28
29
  } from "@oh-my-pi/pi-catalog/provider-models";
@@ -64,7 +65,11 @@ const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000;
64
65
  import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
65
66
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
66
67
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
67
- import { getBundledModelReferenceIndex, resolveModelReference } from "@oh-my-pi/pi-catalog/identity";
68
+ import {
69
+ getBundledModelReferenceIndex,
70
+ inheritReferenceThinking,
71
+ resolveModelReference,
72
+ } from "@oh-my-pi/pi-catalog/identity";
68
73
  import { isBunTestRuntime, isRecord, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
69
74
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
70
75
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
@@ -153,7 +158,7 @@ interface ProviderOverride {
153
158
  export function mergeDiscoveredModel<TApi extends Api>(
154
159
  model: Model<TApi>,
155
160
  existing: Model<Api> | undefined,
156
- providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "remoteCompaction" | "transport">,
161
+ providerOverride?: Pick<ProviderOverride, "baseUrl" | "compat" | "headers" | "remoteCompaction" | "transport">,
157
162
  ): Model<TApi> {
158
163
  if (existing) {
159
164
  const supportsTools = model.supportsTools ?? existing.supportsTools;
@@ -167,7 +172,7 @@ export function mergeDiscoveredModel<TApi extends Api>(
167
172
  providerOverride?.remoteCompaction,
168
173
  ),
169
174
  ...(supportsTools !== undefined ? { supportsTools } : {}),
170
- compat: model.compatConfig,
175
+ compat: mergeCompat(model.compatConfig, providerOverride?.compat),
171
176
  } as ModelSpec<TApi>);
172
177
  }
173
178
  if (providerOverride) {
@@ -180,7 +185,7 @@ export function mergeDiscoveredModel<TApi extends Api>(
180
185
  model.remoteCompaction,
181
186
  providerOverride.remoteCompaction,
182
187
  ),
183
- compat: model.compatConfig,
188
+ compat: mergeCompat(model.compatConfig, providerOverride.compat),
184
189
  } as ModelSpec<TApi>);
185
190
  }
186
191
  return model;
@@ -423,20 +428,35 @@ function getOAuthCredentialsForProvider(authStorage: AuthStorage, provider: stri
423
428
  return entries.filter((entry): entry is OAuthCredential => entry.type === "oauth");
424
429
  }
425
430
 
426
- function resolveOAuthAccountIdForAccessToken(
431
+ /**
432
+ * Resolve every configured Codex OAuth account for catalog discovery, refreshing
433
+ * each credential exactly once. Codex `/models` is account-scoped, so discovery
434
+ * must fetch per account and union the results; resolving a single access token
435
+ * (as before) hid models available only through a sibling account (#6265).
436
+ *
437
+ * Returns `null` when any stored account fails to resolve (e.g. a transient
438
+ * refresh failure): the Codex manager is authoritative, so unioning only the
439
+ * accounts that resolved would cache a partial catalog and hide the failed
440
+ * account's models for the cache TTL. Aborting keeps the previous/bundled
441
+ * catalog instead.
442
+ */
443
+ async function resolveCodexDiscoveryAccounts(
427
444
  authStorage: AuthStorage,
428
- provider: string,
429
- accessToken: string,
430
- ): string | undefined {
431
- const oauthCredentials = getOAuthCredentialsForProvider(authStorage, provider);
432
- const matchingCredential = oauthCredentials.find(credential => credential.access === accessToken);
433
- if (matchingCredential) {
434
- return matchingCredential.accountId;
435
- }
436
- if (oauthCredentials.length === 1) {
437
- return oauthCredentials[0].accountId;
445
+ resolvedAccessToken: string,
446
+ ): Promise<OpenAICodexAccount[] | null> {
447
+ const accesses = await authStorage.getOAuthAccesses("openai-codex");
448
+ const accounts: OpenAICodexAccount[] = [];
449
+ for (const access of accesses) {
450
+ if (!access.ok) return null;
451
+ accounts.push({ accessToken: access.accessToken, accountId: access.accountId });
452
+ }
453
+ if (!accounts.some(account => account.accessToken === resolvedAccessToken)) {
454
+ const matchingCredential = getOAuthCredentialsForProvider(authStorage, "openai-codex").find(
455
+ credential => credential.access === resolvedAccessToken,
456
+ );
457
+ accounts.push({ accessToken: resolvedAccessToken, accountId: matchingCredential?.accountId });
438
458
  }
439
- return undefined;
459
+ return accounts;
440
460
  }
441
461
 
442
462
  function mergeCompat<TBase extends object, TOverride extends object>(
@@ -667,7 +687,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
667
687
  provider: resolvedModel.provider,
668
688
  baseUrl: resolvedModel.baseUrl,
669
689
  reasoning: resolvedModel.reasoning ?? reference?.reasoning ?? (options.useDefaults ? false : undefined),
670
- thinking: resolvedModel.thinking ?? reference?.thinking,
690
+ thinking: inheritReferenceThinking(resolvedModel.thinking, reference, resolvedModel.provider),
671
691
  input: input as ("text" | "image")[],
672
692
  ...(supportsTools !== undefined ? { supportsTools } : {}),
673
693
  cost,
@@ -1139,8 +1159,18 @@ export class ModelRegistry {
1139
1159
  models.push(spec);
1140
1160
  continue;
1141
1161
  }
1142
- if (unrestorableHeaderIds.has(spec.id)) continue;
1143
- const bundledHeaders = bundledById?.get(spec.id)?.headers;
1162
+ // Current unrestorable markers prove that neither same-id nor
1163
+ // request-model bundled headers matched the live model. Only markers
1164
+ // from the old id-only writer may recover through `requestModelId`.
1165
+ const unrestorable = unrestorableHeaderIds.has(spec.id);
1166
+ const bundledHeaders = (
1167
+ unrestorable
1168
+ ? cache.legacyHeaderRestoreMarkers && spec.requestModelId
1169
+ ? bundledById?.get(spec.requestModelId)
1170
+ : undefined
1171
+ : (bundledById?.get(spec.id) ??
1172
+ (spec.requestModelId ? bundledById?.get(spec.requestModelId) : undefined))
1173
+ )?.headers;
1144
1174
  if (!bundledHeaders) continue;
1145
1175
  models.push({ ...spec, headers: bundledHeaders });
1146
1176
  }
@@ -1719,14 +1749,11 @@ export class ModelRegistry {
1719
1749
  providerId: "openai-codex",
1720
1750
  authoritative: true,
1721
1751
  resolveKey: value => value,
1722
- createOptions: accessToken => {
1723
- const accountId = resolveOAuthAccountIdForAccessToken(this.authStorage, "openai-codex", accessToken);
1724
- return openaiCodexModelManagerOptions({
1725
- accessToken,
1726
- accountId,
1752
+ createOptions: accessToken =>
1753
+ openaiCodexModelManagerOptions({
1754
+ resolveAccounts: () => resolveCodexDiscoveryAccounts(this.authStorage, accessToken),
1727
1755
  fetch: this.#fetch,
1728
- });
1729
- },
1756
+ }),
1730
1757
  },
1731
1758
  ];
1732
1759
  const disabledProviders = getDisabledProviderIdsFromSettings();
@@ -1598,11 +1598,33 @@ export function filterAvailableModelsByEnabledPatterns(
1598
1598
 
1599
1599
  return includeSyntheticAllowedModels(available, allowedModels);
1600
1600
  }
1601
+ function findExactCliModel(
1602
+ selector: string,
1603
+ allModels: Model<Api>[],
1604
+ availableModels: Model<Api>[],
1605
+ ): Model<Api> | undefined {
1606
+ // Explicit provider/id references stay authoritative against the full catalog.
1607
+ const referenced = findExactModelReferenceMatch(selector, allModels);
1608
+ if (referenced) return referenced;
1609
+
1610
+ // Flat-id (or full-selector-string) matches prefer authenticated providers,
1611
+ // then fall back to catalog order. This covers aggregator-style flat ids
1612
+ // that merely look provider-qualified (e.g. "openai/gpt-oss-120b" hosted on
1613
+ // OpenRouter), where the provider/id decomposition above found nothing.
1614
+ const lower = selector.toLowerCase();
1615
+ const isFlatMatch = (model: Model<Api>) =>
1616
+ model.id.toLowerCase() === lower || formatModelString(model).toLowerCase() === lower;
1617
+ const preferred = availableModels.find(isFlatMatch);
1618
+ if (preferred) return preferred;
1619
+ return availableModels === allModels ? undefined : allModels.find(isFlatMatch);
1620
+ }
1601
1621
 
1602
1622
  export interface ResolveCliModelResult {
1603
1623
  model: Model<Api> | undefined;
1604
- /** configuredPatterns is the full configured fallback chain when the selector resolves through a role. */
1624
+ /** configuredPatterns contains the role's ordered primary candidates. */
1605
1625
  configuredPatterns?: string[];
1626
+ /** configuredRole identifies the role expanded into configuredPatterns. */
1627
+ configuredRole?: string;
1606
1628
  /** configuredPatternIndex identifies the configured role pattern that matched an available model. */
1607
1629
  configuredPatternIndex?: number;
1608
1630
  selector?: string;
@@ -1620,17 +1642,19 @@ export function resolveCliModel(options: {
1620
1642
  cliProvider?: string;
1621
1643
  cliModel?: string;
1622
1644
  modelRegistry: CliModelRegistry;
1645
+ /** Authenticated models to prefer for unqualified selectors; omit to preserve catalog-order behavior. */
1646
+ availableModels?: Model<Api>[];
1623
1647
  settings?: Settings;
1624
1648
  preferences?: ModelMatchPreferences;
1625
1649
  }): ResolveCliModelResult {
1626
- const { cliProvider, cliModel, modelRegistry, settings, preferences } = options;
1650
+ const { cliProvider, cliModel, modelRegistry, settings, preferences, availableModels: preferredModels } = options;
1627
1651
 
1628
1652
  if (!cliModel) {
1629
1653
  return { model: undefined, selector: undefined, warning: undefined, error: undefined };
1630
1654
  }
1631
1655
 
1632
- const availableModels = modelRegistry.getAll();
1633
- if (availableModels.length === 0) {
1656
+ const allModels = modelRegistry.getAll();
1657
+ if (allModels.length === 0) {
1634
1658
  return {
1635
1659
  model: undefined,
1636
1660
  selector: undefined,
@@ -1639,8 +1663,9 @@ export function resolveCliModel(options: {
1639
1663
  };
1640
1664
  }
1641
1665
 
1666
+ const availableModels = preferredModels ?? allModels;
1642
1667
  const providerMap = new Map<string, string>();
1643
- for (const model of availableModels) {
1668
+ for (const model of allModels) {
1644
1669
  providerMap.set(model.provider.toLowerCase(), model.provider);
1645
1670
  }
1646
1671
 
@@ -1656,19 +1681,7 @@ export function resolveCliModel(options: {
1656
1681
 
1657
1682
  const trimmedModel = cliModel.trim();
1658
1683
  if (!provider) {
1659
- const lower = trimmedModel.toLowerCase();
1660
- // When input has provider/id format (e.g. "zai/glm-5"), prefer decomposed
1661
- // provider+id match over flat id match. Without this, a model with id
1662
- // "zai/glm-5" on provider "vercel-ai-gateway" wins over provider "zai"
1663
- // with id "glm-5", because Array.find returns the first catalog hit.
1664
- let exact = findExactModelReferenceMatch(trimmedModel, availableModels);
1665
- if (!exact) {
1666
- // Flat exact id (or full selector) by catalog order: CLI resolution
1667
- // stays deterministic across runs regardless of usage-based ranking.
1668
- exact = availableModels.find(
1669
- model => model.id.toLowerCase() === lower || `${model.provider}/${model.id}`.toLowerCase() === lower,
1670
- );
1671
- }
1684
+ const exact = findExactCliModel(trimmedModel, allModels, availableModels);
1672
1685
  if (exact) {
1673
1686
  return {
1674
1687
  model: exact,
@@ -1684,15 +1697,7 @@ export function resolveCliModel(options: {
1684
1697
  MAX_THINKING_SUFFIX_OPTIONS,
1685
1698
  );
1686
1699
  if (exactThinkingLevel) {
1687
- let exactSuffixed = findExactModelReferenceMatch(exactBase, availableModels);
1688
- if (!exactSuffixed) {
1689
- const lowerExactBase = exactBase.toLowerCase();
1690
- exactSuffixed = availableModels.find(
1691
- model =>
1692
- model.id.toLowerCase() === lowerExactBase ||
1693
- `${model.provider}/${model.id}`.toLowerCase() === lowerExactBase,
1694
- );
1695
- }
1700
+ const exactSuffixed = findExactCliModel(exactBase, allModels, availableModels);
1696
1701
  if (exactSuffixed) {
1697
1702
  return {
1698
1703
  model: exactSuffixed,
@@ -1718,15 +1723,28 @@ export function resolveCliModel(options: {
1718
1723
  ? `${formatModelRoleAlias(bareRoleName)}${bareRoleThinkingLevel ? `:${bareRoleThinkingLevel}` : ""}`
1719
1724
  : undefined;
1720
1725
  if (roleSelector) {
1726
+ const { base: roleAlias } = splitThinkingSuffix(
1727
+ roleSelector,
1728
+ modelRoleAliasPrefixLength(roleSelector) ?? -1,
1729
+ MAX_THINKING_SUFFIX_OPTIONS,
1730
+ );
1731
+ const configuredRole = getModelRoleAlias(roleAlias, settings);
1721
1732
  configuredPatterns = resolveConfiguredModelPatterns([roleSelector], settings);
1722
- const resolved = resolveModelRoleValue(roleSelector, availableModels, {
1733
+ const availableResolved = resolveModelRoleValue(roleSelector, availableModels, {
1723
1734
  settings,
1724
1735
  matchPreferences: preferences,
1725
1736
  });
1737
+ const resolved = availableResolved.model
1738
+ ? availableResolved
1739
+ : resolveModelRoleValue(roleSelector, allModels, {
1740
+ settings,
1741
+ matchPreferences: preferences,
1742
+ });
1726
1743
  if (resolved.model) {
1727
1744
  return {
1728
1745
  model: resolved.model,
1729
1746
  selector: formatModelString(resolved.model),
1747
+ configuredRole,
1730
1748
  configuredPatterns,
1731
1749
  configuredPatternIndex: resolved.matchedPatternIndex,
1732
1750
  thinkingLevel: resolved.thinkingLevel,
@@ -1738,6 +1756,7 @@ export function resolveCliModel(options: {
1738
1756
  return {
1739
1757
  model: undefined,
1740
1758
  configuredPatterns,
1759
+ configuredRole,
1741
1760
  selector: undefined,
1742
1761
  thinkingLevel: undefined,
1743
1762
  warning: resolved.warning,
@@ -1767,7 +1786,7 @@ export function resolveCliModel(options: {
1767
1786
  }
1768
1787
 
1769
1788
  if (provider) {
1770
- const exactProviderMatch = resolveProviderModelReference(provider, pattern, availableModels);
1789
+ const exactProviderMatch = resolveProviderModelReference(provider, pattern, allModels);
1771
1790
  if (exactProviderMatch) {
1772
1791
  return {
1773
1792
  model: exactProviderMatch,
@@ -1779,10 +1798,16 @@ export function resolveCliModel(options: {
1779
1798
  }
1780
1799
  }
1781
1800
 
1782
- const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
1783
- const { model, thinkingLevel, warning, upstream } = parseModelPattern(pattern, candidates, preferences, {
1801
+ const candidates = provider ? allModels.filter(model => model.provider === provider) : availableModels;
1802
+ let parsed = parseModelPattern(pattern, candidates, preferences, {
1784
1803
  allowInvalidThinkingSelectorFallback: false,
1785
1804
  });
1805
+ if (!parsed.model && !provider) {
1806
+ parsed = parseModelPattern(pattern, allModels, preferences, {
1807
+ allowInvalidThinkingSelectorFallback: false,
1808
+ });
1809
+ }
1810
+ const { model, thinkingLevel, warning, upstream } = parsed;
1786
1811
 
1787
1812
  if (!model) {
1788
1813
  const display = provider ? `${provider}/${pattern}` : cliModel;
@@ -2861,6 +2861,11 @@ export const SETTINGS_SCHEMA = {
2861
2861
 
2862
2862
  "hindsight.debug": { type: "boolean", default: false },
2863
2863
 
2864
+ "hindsight.requestTimeoutMs": { type: "number", default: 30_000 },
2865
+ "hindsight.reflectTimeoutMs": { type: "number", default: 120_000 },
2866
+ "hindsight.recallTimeoutMs": { type: "number", default: 30_000 },
2867
+ "hindsight.retainTimeoutMs": { type: "number", default: 60_000 },
2868
+
2864
2869
  "hindsight.mentalModelsEnabled": {
2865
2870
  type: "boolean",
2866
2871
  default: true,
@@ -330,6 +330,8 @@ export class Settings {
330
330
  #modified = new Set<string>();
331
331
  /** Individual project model roles modified during this session */
332
332
  #modifiedProjectModelRoles = new Set<string>();
333
+ /** Individual global model roles modified during this session (for partial save) */
334
+ #modifiedGlobalModelRoles = new Set<string>();
333
335
  /**
334
336
  * Original process-wide model-role overrides captured before a project edit
335
337
  * temporarily replaced them via `#updateRuntimeModelRoleOverride`. Restored
@@ -551,7 +553,7 @@ export class Settings {
551
553
  if (this.#projectSavePromise) {
552
554
  await this.#projectSavePromise;
553
555
  }
554
- if (this.#modified.size > 0) {
556
+ if (this.#modified.size > 0 || this.#modifiedGlobalModelRoles.size > 0) {
555
557
  await this.#saveNow();
556
558
  }
557
559
  if (this.#modifiedProjectModelRoles.size > 0) {
@@ -833,13 +835,22 @@ export class Settings {
833
835
  * stale skip in place.
834
836
  */
835
837
  setModelRole(role: ModelRole | string, modelId: string | undefined): void {
838
+ const prev = this.get("modelRoles");
836
839
  const current = this.#modelRolesFromLayer(this.#global);
837
840
  if (modelId === undefined) {
838
841
  delete current[role];
839
842
  } else {
840
843
  current[role] = modelId;
841
844
  }
842
- this.set("modelRoles", current);
845
+ // Persist per-role rather than marking the whole `modelRoles` path
846
+ // modified: #saveNow merges only the changed role into the re-read
847
+ // file, so a concurrent external edit to a sibling role is not
848
+ // clobbered by this process's stale in-memory snapshot.
849
+ setByPath(this.#global, ["modelRoles"], current);
850
+ this.#modifiedGlobalModelRoles.add(role);
851
+ this.#rebuildMerged();
852
+ this.#queueSave();
853
+ this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
843
854
  if (this.isProjectModelRoleRuntimeOverrideActive(role)) {
844
855
  return;
845
856
  }
@@ -1619,7 +1630,8 @@ export class Settings {
1619
1630
  clearTimeout(this.#saveTimer);
1620
1631
  this.#saveTimer = setTimeout(() => {
1621
1632
  this.#saveTimer = undefined;
1622
- const savePromise = this.#saveNow();
1633
+ const previousSave = this.#savePromise;
1634
+ const savePromise = previousSave ? previousSave.then(() => this.#saveNow()) : this.#saveNow();
1623
1635
  this.#savePromise = savePromise;
1624
1636
  savePromise
1625
1637
  .catch(err => {
@@ -1634,27 +1646,75 @@ export class Settings {
1634
1646
  }
1635
1647
 
1636
1648
  async #saveNow(): Promise<void> {
1637
- if (!this.#persist || !this.#configPath || this.#modified.size === 0) return;
1649
+ if (!this.#persist || !this.#configPath) return;
1650
+ if (this.#modified.size === 0 && this.#modifiedGlobalModelRoles.size === 0) return;
1638
1651
 
1639
1652
  const configPath = this.#configPath;
1640
1653
  const modifiedPaths = [...this.#modified];
1654
+ const modifiedModelRoles = [...this.#modifiedGlobalModelRoles];
1655
+ const globalRolesAtStart = this.#modelRolesFromLayer(this.#global);
1641
1656
  this.#modified.clear();
1657
+ this.#modifiedGlobalModelRoles.clear();
1642
1658
 
1643
1659
  try {
1644
1660
  await withFileLock(configPath, async () => {
1645
1661
  // Re-read to preserve external changes
1646
1662
  const current = await this.#loadYaml(configPath);
1647
1663
 
1648
- // Apply only our modified paths
1664
+ // Apply only our modified whole-value paths
1649
1665
  for (const modPath of modifiedPaths) {
1650
1666
  const segments = modPath.split(".");
1651
1667
  const value = getByPath(this.#global, segments);
1652
1668
  setByPath(current, segments, value);
1653
1669
  }
1654
1670
 
1671
+ // Merge only the model roles captured by this save. Then retain
1672
+ // any role changed while the async read/lock was pending before
1673
+ // replacing #global, so the follow-up save still sees its value.
1674
+ const latestGlobalRoles = this.#modelRolesFromLayer(this.#global);
1675
+ const rolesToPreserve = new Set(this.#modifiedGlobalModelRoles);
1676
+ for (const role in globalRolesAtStart) {
1677
+ if (globalRolesAtStart[role] !== latestGlobalRoles[role]) {
1678
+ rolesToPreserve.add(role);
1679
+ }
1680
+ }
1681
+ for (const role in latestGlobalRoles) {
1682
+ if (globalRolesAtStart[role] !== latestGlobalRoles[role]) {
1683
+ rolesToPreserve.add(role);
1684
+ }
1685
+ }
1686
+ if (modifiedModelRoles.length > 0 || rolesToPreserve.size > 0) {
1687
+ const currentRoles = getByPath(current, ["modelRoles"]);
1688
+ const mergedRoles: Record<string, unknown> = isRecord(currentRoles) ? { ...currentRoles } : {};
1689
+ for (const role of modifiedModelRoles) {
1690
+ if (Object.hasOwn(globalRolesAtStart, role)) {
1691
+ mergedRoles[role] = globalRolesAtStart[role];
1692
+ } else {
1693
+ delete mergedRoles[role];
1694
+ }
1695
+ }
1696
+ for (const role of rolesToPreserve) {
1697
+ if (Object.hasOwn(latestGlobalRoles, role)) {
1698
+ mergedRoles[role] = latestGlobalRoles[role];
1699
+ } else {
1700
+ delete mergedRoles[role];
1701
+ }
1702
+ }
1703
+ setByPath(current, ["modelRoles"], mergedRoles);
1704
+ }
1705
+
1655
1706
  // Update our global with any external changes we preserved
1656
1707
  this.#global = current;
1657
1708
  await Bun.write(configPath, YAML.stringify(this.#global, null, 2));
1709
+ // These pending roles were included in this write. Remove each
1710
+ // only if no newer local change arrived while Bun.write was in
1711
+ // flight; a newer value still needs the queued follow-up save.
1712
+ const globalRolesAfterWrite = this.#modelRolesFromLayer(this.#global);
1713
+ for (const role of rolesToPreserve) {
1714
+ if (latestGlobalRoles[role] === globalRolesAfterWrite[role]) {
1715
+ this.#modifiedGlobalModelRoles.delete(role);
1716
+ }
1717
+ }
1658
1718
  });
1659
1719
  } catch (error) {
1660
1720
  logger.warn("Settings: save failed", { error: String(error) });
@@ -1662,6 +1722,9 @@ export class Settings {
1662
1722
  for (const p of modifiedPaths) {
1663
1723
  this.#modified.add(p);
1664
1724
  }
1725
+ for (const role of modifiedModelRoles) {
1726
+ this.#modifiedGlobalModelRoles.add(role);
1727
+ }
1665
1728
  }
1666
1729
 
1667
1730
  this.#rebuildMerged();