@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.9

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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
@@ -5,16 +5,21 @@
5
5
  * `xd://report_issue`, and the system prompt tells the model to write
6
6
  * `<tool>: <concise description>` there when auto-QA is enabled.
7
7
  *
8
- * Enabled by default; gated behind PI_AUTO_QA=1 / `dev.autoqa` so a user who
9
- * flips the setting off short-circuits injection entirely.
8
+ * Enabled by default (`dev.autoqa` defaults to true); `PI_AUTO_QA=0` or an
9
+ * explicit `dev.autoqa: false` short-circuits injection entirely. When the
10
+ * user is only enabled by default (never configured `dev.autoqa` themselves),
11
+ * a persisted `dev.autoqaConsent: "denied"` also disables injection so a "No"
12
+ * in the consent dialog fully turns the feature off.
10
13
  * Records grievances to a local SQLite database; never throws from the device
11
14
  * dispatch path.
12
15
  *
13
- * Before the first record lands, the user's consent is checked. If they've
14
- * never been asked (`dev.autoqaConsent === "unset"`) the process-global
15
- * consent handler — wired by `InteractiveMode` to a Yes/No popup — is invoked
16
- * exactly once and the decision is persisted. Subsequent calls (including from
17
- * subagents) read the cached decision without prompting.
16
+ * Nothing is written until consent resolves. If the user has never been asked
17
+ * (`dev.autoqaConsent === "unset"`) the process-global consent handler —
18
+ * wired by `InteractiveMode` to a Yes/No popup — is invoked exactly once and
19
+ * the decision is persisted; a denial (or dismissal) drops the pending report
20
+ * without touching the database. Subsequent calls (including from subagents)
21
+ * read the cached decision without prompting. `PI_AUTO_QA_PUSH=1` bypasses
22
+ * the dialog for headless environments.
18
23
  *
19
24
  * When the user grants consent, push is automatically active against the
20
25
  * bundled endpoint (`dev.autoqaPush.endpoint`, default `qa.omp.sh`). Each
@@ -42,6 +47,14 @@ export declare function isReportIssueToolCall(toolCall: {
42
47
  }): boolean;
43
48
  /** Call preview for an `xd://report_issue` write. */
44
49
  export declare function renderReportIssueDeviceCall(content: unknown, uiTheme: Theme): Component;
50
+ /**
51
+ * Whether Auto-QA is active for this session.
52
+ *
53
+ * Precedence: `PI_AUTO_QA` env flag > explicit `dev.autoqa` setting >
54
+ * default-on unless the user previously denied consent. The denial veto only
55
+ * applies to the default: explicitly configuring `dev.autoqa: true` re-enables
56
+ * injection (recording still no-ops until consent is granted).
57
+ */
45
58
  export declare function isAutoQaEnabled(settings?: Settings): boolean;
46
59
  /**
47
60
  * Resolver for the user's "share grievances?" consent.
@@ -82,8 +95,8 @@ export declare function __resetAutoQaConsentForTests(): void;
82
95
  export declare function resolveAutoQaConsent(settings: Settings | undefined): Promise<boolean>;
83
96
  /**
84
97
  * Open (or return the cached handle for) the auto-QA SQLite database at
85
- * `~/.omp/agent/autoqa.db`, creating the schema lazily. Returns `null` when
86
- * the agent data dir cannot be resolved.
98
+ * `~/.omp/autoqa.db` (XDG: `$XDG_DATA_HOME/omp/autoqa.db`), creating the
99
+ * schema lazily. Returns `null` when the path cannot be resolved or opened.
87
100
  */
88
101
  export declare function openAutoQaDb(): Database | null;
89
102
  export interface FlushResult {
@@ -127,6 +140,8 @@ export declare function __resetAutoQaFlushStateForTests(): void;
127
140
  * Flush queued grievances to the configured backend.
128
141
  */
129
142
  export declare function flushGrievances(db?: Database, settings?: Settings, options?: FlushOptions): Promise<FlushResult>;
143
+ /** Test-only: await the last consent → insert → flush pipeline. */
144
+ export declare function __awaitAutoQaRecordPipelineForTests(): Promise<void>;
130
145
  /**
131
146
  * Execute `write xd://report_issue`. `text` must be either:
132
147
  * - `<tool>: <concise description>` on one line, or
@@ -46,6 +46,12 @@ export declare const TOOL_TIMEOUTS: {
46
46
  export type ToolWithTimeout = keyof typeof TOOL_TIMEOUTS;
47
47
  /**
48
48
  * Clamp a raw timeout to the allowed range for a tool.
49
- * If rawTimeout is undefined, returns the tool's default.
49
+ *
50
+ * When `rawTimeout` is undefined the tool's `default` is used. A positive
51
+ * `maxTimeout` (the `tools.maxTimeout` global ceiling) caps the *resolved*
52
+ * value — including the default-fallback path — before the per-tool `min`/`max`
53
+ * floor and ceiling apply, so a configured global cap governs calls where the
54
+ * agent omits `timeout`, not only explicitly-passed values. `maxTimeout <= 0`
55
+ * means no global cap.
50
56
  */
51
- export declare function clampTimeout(tool: ToolWithTimeout, rawTimeout?: number): number;
57
+ export declare function clampTimeout(tool: ToolWithTimeout, rawTimeout?: number, maxTimeout?: number): number;
@@ -23,13 +23,11 @@ export interface StartupChangelogSelection {
23
23
  selectedEntries: number;
24
24
  }
25
25
  /**
26
- * Parse changelog entries from the file at `changelogPath`. Scans for `## [x.y.z]`
27
- * headings and collects each block until the next heading or EOF.
26
+ * Parse changelog entries from omp's package asset when available, falling back
27
+ * to the copy embedded in compiled binaries.
28
28
  *
29
- * Returns `[]` when `changelogPath` is `undefined` (package directory not
30
- * resolvable see `getChangelogPath`) or the file is missing. Callers MUST NOT
31
- * synthesize a fallback path from the host project's cwd; doing so caused issue
32
- * #1423 (the host project's `CHANGELOG.md` was rendered as omp's).
29
+ * The embedded fallback keeps standalone binaries self-contained without
30
+ * resolving relative to the host project's cwd, which caused issue #1423.
33
31
  */
34
32
  export declare function parseChangelog(changelogPath: string | undefined): Promise<ChangelogEntry[]>;
35
33
  /**
@@ -23,6 +23,15 @@ export declare function searchFirecrawl(params: SearchParams): Promise<SearchRes
23
23
  export declare class FirecrawlProvider extends SearchProvider {
24
24
  readonly id = "firecrawl";
25
25
  readonly label = "Firecrawl";
26
+ /**
27
+ * Auto-chain admission: requires a credential so an unconfigured Firecrawl
28
+ * doesn't displace other providers that the user has set up with API keys.
29
+ */
26
30
  isAvailable(authStorage: AuthStorage): boolean;
31
+ /**
32
+ * Firecrawl supports keyless mode, so an explicit user selection
33
+ * (`webSearch: firecrawl`) works without any credential configured.
34
+ */
35
+ isExplicitlyAvailable(_authStorage: AuthStorage): boolean;
27
36
  search(params: SearchParams): Promise<SearchResponse>;
28
37
  }
@@ -54,7 +54,7 @@ export declare const SEARCH_PROVIDER_OPTIONS: readonly [{
54
54
  }, {
55
55
  readonly value: "firecrawl";
56
56
  readonly label: "Firecrawl";
57
- readonly description: "Requires FIRECRAWL_API_KEY";
57
+ readonly description: "Uses Firecrawl API when FIRECRAWL_API_KEY is set; falls back to keyless mode";
58
58
  }, {
59
59
  readonly value: "brave";
60
60
  readonly label: "Brave";
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.7",
4
+ "version": "17.0.9",
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",
@@ -51,18 +51,19 @@
51
51
  "dependencies": {
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
+ "@babel/traverse": "^7.29.7",
54
55
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "17.0.7",
56
- "@oh-my-pi/omp-stats": "17.0.7",
57
- "@oh-my-pi/pi-agent-core": "17.0.7",
58
- "@oh-my-pi/pi-ai": "17.0.7",
59
- "@oh-my-pi/pi-catalog": "17.0.7",
60
- "@oh-my-pi/pi-mnemopi": "17.0.7",
61
- "@oh-my-pi/pi-natives": "17.0.7",
62
- "@oh-my-pi/pi-tui": "17.0.7",
63
- "@oh-my-pi/pi-utils": "17.0.7",
64
- "@oh-my-pi/pi-wire": "17.0.7",
65
- "@oh-my-pi/snapcompact": "17.0.7",
56
+ "@oh-my-pi/hashline": "17.0.9",
57
+ "@oh-my-pi/omp-stats": "17.0.9",
58
+ "@oh-my-pi/pi-agent-core": "17.0.9",
59
+ "@oh-my-pi/pi-ai": "17.0.9",
60
+ "@oh-my-pi/pi-catalog": "17.0.9",
61
+ "@oh-my-pi/pi-mnemopi": "17.0.9",
62
+ "@oh-my-pi/pi-natives": "17.0.9",
63
+ "@oh-my-pi/pi-tui": "17.0.9",
64
+ "@oh-my-pi/pi-utils": "17.0.9",
65
+ "@oh-my-pi/pi-wire": "17.0.9",
66
+ "@oh-my-pi/snapcompact": "17.0.9",
66
67
  "@opentelemetry/api": "^1.9.1",
67
68
  "@opentelemetry/api-logs": "^0.220.0",
68
69
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -79,7 +80,6 @@
79
80
  "@xterm/headless": "^6.0.0",
80
81
  "arktype": "2.2.3",
81
82
  "chalk": "^5.6.2",
82
- "diff": "^9.0.0",
83
83
  "fast-xml-parser": "^5.9.3",
84
84
  "handlebars": "^4.7.9",
85
85
  "header-generator": "^2.1.82",
@@ -19,7 +19,7 @@ const BUNDLED_PACKAGES: readonly BundledPackage[] = [
19
19
  { dir: "ai", identifier: "PiAi", rootShim: "legacy-pi-ai-shim.ts" },
20
20
  { dir: "coding-agent", identifier: "PiCodingAgent", rootShim: "legacy-pi-coding-agent-shim.ts" },
21
21
  { dir: "natives", identifier: "PiNatives", rootShim: null },
22
- { dir: "tui", identifier: "PiTui", rootShim: null },
22
+ { dir: "tui", identifier: "PiTui", rootShim: "legacy-pi-tui-shim.ts" },
23
23
  { dir: "utils", identifier: "PiUtils", rootShim: null },
24
24
  ];
25
25
 
@@ -41,7 +41,7 @@ export async function listGrievances(options: ListGrievancesOptions): Promise<vo
41
41
  console.log("[]");
42
42
  } else {
43
43
  console.log(
44
- chalk.dim("No grievances database found. Enable auto-QA with PI_AUTO_QA=1 or the dev.autoqa setting."),
44
+ chalk.dim("No grievances database found. Auto-QA has not recorded any reports yet (or was disabled)."),
45
45
  );
46
46
  }
47
47
  return;
@@ -110,7 +110,7 @@ export async function cleanGrievances(options: CleanGrievancesOptions): Promise<
110
110
  console.log(JSON.stringify({ deleted: 0 }));
111
111
  } else {
112
112
  console.log(
113
- chalk.dim("No grievances database found. Enable auto-QA with PI_AUTO_QA=1 or the dev.autoqa setting."),
113
+ chalk.dim("No grievances database found. Auto-QA has not recorded any reports yet (or was disabled)."),
114
114
  );
115
115
  }
116
116
  return;
@@ -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";
@@ -409,7 +409,7 @@ function formatLimitLine(limit: UsageLimit, labelWidth: number, nowMs: number):
409
409
  const details: string[] = [describeAmount(limit)];
410
410
  const resetsAt = limit.window?.resetsAt;
411
411
  if (resetsAt !== undefined && resetsAt > nowMs) {
412
- details.push(`resets in ${formatDuration(resetsAt - nowMs)}`);
412
+ details.push(`${limit.window?.resetLabel ?? "resets"} in ${formatDuration(resetsAt - nowMs)}`);
413
413
  }
414
414
  const lines = [
415
415
  ` ${STATUS_COLOR[status]("●")} ${padded} ${renderBar(limit)} ${chalk.dim(details.join(" · "))}`,
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
6
+ import { ModelRegistry } from "../model-registry";
7
+
8
+ /**
9
+ * Stub AuthStorage that satisfies the surface used by ModelRegistry's
10
+ * constructor (#loadModels → clearConfigApiKeys, constructor →
11
+ * setFallbackResolver). The awaiter under test never reaches auth-gated code
12
+ * paths, so no real credential store is required.
13
+ */
14
+ function createStubAuthStorage(): AuthStorage {
15
+ const stub = {
16
+ setFallbackResolver: () => {},
17
+ clearConfigApiKeys: () => {},
18
+ hasAuth: () => false,
19
+ };
20
+ return stub as unknown as AuthStorage;
21
+ }
22
+
23
+ describe("ModelRegistry.awaitBackgroundRefresh", () => {
24
+ let tmpDir: string;
25
+ let registry: ModelRegistry;
26
+
27
+ beforeEach(() => {
28
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), "omp-reg-"));
29
+ // Construct with an explicit modelsPath inside the temp dir so the
30
+ // constructor's #loadModels read returns "not-found" rather than
31
+ // touching the host's ~/.omp/agent/models.yaml. isBunTestRuntime()
32
+ // auto-stubs #fetch in the constructor.
33
+ registry = new ModelRegistry(createStubAuthStorage(), path.join(tmpDir, "models.yaml"));
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ rmSync(tmpDir, { recursive: true, force: true });
39
+ });
40
+
41
+ test("resolves immediately when no background refresh is in flight", async () => {
42
+ // No refreshInBackground() called → #backgroundRefresh is undefined.
43
+ // The awaiter must settle within a single microtask, never hanging.
44
+ let settled = false;
45
+ const p = registry.awaitBackgroundRefresh().then(() => {
46
+ settled = true;
47
+ });
48
+ await Promise.resolve();
49
+ await p;
50
+ expect(settled).toBe(true);
51
+ });
52
+
53
+ test("blocks until the in-flight background refresh resolves, then resolves", async () => {
54
+ // Drive refreshInBackground with a controlled refresh() return value so
55
+ // #backgroundRefresh is captured but not yet settled.
56
+ const { promise, resolve } = Promise.withResolvers<void>();
57
+ const refreshSpy = vi.spyOn(registry, "refresh").mockReturnValue(promise);
58
+
59
+ registry.refreshInBackground();
60
+ expect(refreshSpy).toHaveBeenCalledTimes(1);
61
+
62
+ let settled = false;
63
+ const awaitPromise = registry.awaitBackgroundRefresh().then(() => {
64
+ settled = true;
65
+ });
66
+
67
+ // Yield to the microtask queue: the awaiter must still be pending.
68
+ for (let i = 0; i < 5; i++) await Promise.resolve();
69
+ expect(settled).toBe(false);
70
+
71
+ resolve();
72
+
73
+ await awaitPromise;
74
+ expect(settled).toBe(true);
75
+ });
76
+
77
+ test("resolves even when the underlying refresh rejects (refreshInBackground swallows)", async () => {
78
+ // refreshInBackground wraps refresh() in .catch(...) so discovery errors
79
+ // never reach awaitBackgroundRefresh callers. The awaiter must resolve,
80
+ // not propagate the rejection.
81
+ const { promise, reject } = Promise.withResolvers<void>();
82
+ vi.spyOn(registry, "refresh").mockReturnValue(promise);
83
+
84
+ registry.refreshInBackground();
85
+
86
+ let settled = false;
87
+ const awaitPromise = registry.awaitBackgroundRefresh().then(() => {
88
+ settled = true;
89
+ });
90
+
91
+ reject(new Error("synthetic discovery failure"));
92
+
93
+ await awaitPromise;
94
+ expect(settled).toBe(true);
95
+ });
96
+
97
+ test("awaiter is a no-op after the in-flight refresh settles and clears #backgroundRefresh", async () => {
98
+ // Once refreshInBackground's promise resolves, #backgroundRefresh is
99
+ // cleared in the .finally. A subsequent awaitBackgroundRefresh must be
100
+ // an immediate no-op (microtask), not hang waiting for a stale promise
101
+ // or a second refresh that was never started.
102
+ const { promise, resolve } = Promise.withResolvers<void>();
103
+ vi.spyOn(registry, "refresh").mockReturnValue(promise);
104
+
105
+ registry.refreshInBackground();
106
+ resolve();
107
+ await registry.awaitBackgroundRefresh();
108
+
109
+ // Now #backgroundRefresh is cleared. A fresh await must resolve in a
110
+ // single microtask — measure by asserting it settles before a second
111
+ // microtask tick.
112
+ let settled = false;
113
+ const p = registry.awaitBackgroundRefresh().then(() => {
114
+ settled = true;
115
+ });
116
+ await Promise.resolve();
117
+ expect(settled).toBe(true);
118
+ await p;
119
+ });
120
+
121
+ test("refreshInBackground deduplicates: a second call while in-flight starts no new refresh", async () => {
122
+ // The guard `if (this.#backgroundRefresh) return` at the top of
123
+ // refreshInBackground prevents concurrent refreshes. A second call
124
+ // while the first is still pending must not invoke refresh() again.
125
+ const { promise, resolve } = Promise.withResolvers<void>();
126
+ const refreshSpy = vi.spyOn(registry, "refresh").mockReturnValue(promise);
127
+
128
+ registry.refreshInBackground();
129
+ registry.refreshInBackground();
130
+ registry.refreshInBackground();
131
+
132
+ expect(refreshSpy).toHaveBeenCalledTimes(1);
133
+
134
+ resolve();
135
+ await registry.awaitBackgroundRefresh();
136
+
137
+ // After settle, #backgroundRefresh is cleared — a new call DOES start
138
+ // a fresh refresh.
139
+ const { promise: secondPromise, resolve: secondResolve } = Promise.withResolvers<void>();
140
+ refreshSpy.mockReturnValue(secondPromise);
141
+ registry.refreshInBackground();
142
+ expect(refreshSpy).toHaveBeenCalledTimes(2);
143
+
144
+ secondResolve();
145
+ await registry.awaitBackgroundRefresh();
146
+ });
147
+ });
@@ -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";
@@ -157,7 +158,7 @@ interface ProviderOverride {
157
158
  export function mergeDiscoveredModel<TApi extends Api>(
158
159
  model: Model<TApi>,
159
160
  existing: Model<Api> | undefined,
160
- providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "remoteCompaction" | "transport">,
161
+ providerOverride?: Pick<ProviderOverride, "baseUrl" | "compat" | "headers" | "remoteCompaction" | "transport">,
161
162
  ): Model<TApi> {
162
163
  if (existing) {
163
164
  const supportsTools = model.supportsTools ?? existing.supportsTools;
@@ -171,7 +172,7 @@ export function mergeDiscoveredModel<TApi extends Api>(
171
172
  providerOverride?.remoteCompaction,
172
173
  ),
173
174
  ...(supportsTools !== undefined ? { supportsTools } : {}),
174
- compat: model.compatConfig,
175
+ compat: mergeCompat(model.compatConfig, providerOverride?.compat),
175
176
  } as ModelSpec<TApi>);
176
177
  }
177
178
  if (providerOverride) {
@@ -184,7 +185,7 @@ export function mergeDiscoveredModel<TApi extends Api>(
184
185
  model.remoteCompaction,
185
186
  providerOverride.remoteCompaction,
186
187
  ),
187
- compat: model.compatConfig,
188
+ compat: mergeCompat(model.compatConfig, providerOverride.compat),
188
189
  } as ModelSpec<TApi>);
189
190
  }
190
191
  return model;
@@ -427,20 +428,35 @@ function getOAuthCredentialsForProvider(authStorage: AuthStorage, provider: stri
427
428
  return entries.filter((entry): entry is OAuthCredential => entry.type === "oauth");
428
429
  }
429
430
 
430
- 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(
431
444
  authStorage: AuthStorage,
432
- provider: string,
433
- accessToken: string,
434
- ): string | undefined {
435
- const oauthCredentials = getOAuthCredentialsForProvider(authStorage, provider);
436
- const matchingCredential = oauthCredentials.find(credential => credential.access === accessToken);
437
- if (matchingCredential) {
438
- return matchingCredential.accountId;
439
- }
440
- if (oauthCredentials.length === 1) {
441
- 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 });
442
458
  }
443
- return undefined;
459
+ return accounts;
444
460
  }
445
461
 
446
462
  function mergeCompat<TBase extends object, TOverride extends object>(
@@ -853,6 +869,28 @@ export class ModelRegistry {
853
869
  this.#backgroundRefresh = refreshPromise;
854
870
  }
855
871
 
872
+ /**
873
+ * Wait for any in-flight background model discovery to settle.
874
+ *
875
+ * Background discovery started by {@link refreshInBackground} is
876
+ * fire-and-forget; RPC consumers (e.g. `get_available_models`,
877
+ * `set_model`) and deferred `--model` resolution that read the registry
878
+ * immediately after session creation can otherwise observe a partial
879
+ * catalog before discovery-backed providers have populated `#models`.
880
+ * Awaiting the tracked promise ensures the response reflects every
881
+ * configured provider once the initial background refresh resolves.
882
+ *
883
+ * No-op when no refresh is in flight (`#backgroundRefresh` cleared in the
884
+ * `finally` of `refreshInBackground` on completion). Resolves immediately
885
+ * in that case so already-warm sessions are unaffected. Discovery errors
886
+ * remain swallowed by `refreshInBackground`'s existing `.catch`.
887
+ */
888
+ async awaitBackgroundRefresh(): Promise<void> {
889
+ if (this.#backgroundRefresh) {
890
+ await this.#backgroundRefresh;
891
+ }
892
+ }
893
+
856
894
  async refreshProvider(providerId: string, strategy: ModelRefreshStrategy = "online"): Promise<void> {
857
895
  this.#reloadStaticModels();
858
896
  for (const selector of this.#suppressedSelectors.keys()) {
@@ -1143,8 +1181,18 @@ export class ModelRegistry {
1143
1181
  models.push(spec);
1144
1182
  continue;
1145
1183
  }
1146
- if (unrestorableHeaderIds.has(spec.id)) continue;
1147
- const bundledHeaders = bundledById?.get(spec.id)?.headers;
1184
+ // Current unrestorable markers prove that neither same-id nor
1185
+ // request-model bundled headers matched the live model. Only markers
1186
+ // from the old id-only writer may recover through `requestModelId`.
1187
+ const unrestorable = unrestorableHeaderIds.has(spec.id);
1188
+ const bundledHeaders = (
1189
+ unrestorable
1190
+ ? cache.legacyHeaderRestoreMarkers && spec.requestModelId
1191
+ ? bundledById?.get(spec.requestModelId)
1192
+ : undefined
1193
+ : (bundledById?.get(spec.id) ??
1194
+ (spec.requestModelId ? bundledById?.get(spec.requestModelId) : undefined))
1195
+ )?.headers;
1148
1196
  if (!bundledHeaders) continue;
1149
1197
  models.push({ ...spec, headers: bundledHeaders });
1150
1198
  }
@@ -1723,14 +1771,11 @@ export class ModelRegistry {
1723
1771
  providerId: "openai-codex",
1724
1772
  authoritative: true,
1725
1773
  resolveKey: value => value,
1726
- createOptions: accessToken => {
1727
- const accountId = resolveOAuthAccountIdForAccessToken(this.authStorage, "openai-codex", accessToken);
1728
- return openaiCodexModelManagerOptions({
1729
- accessToken,
1730
- accountId,
1774
+ createOptions: accessToken =>
1775
+ openaiCodexModelManagerOptions({
1776
+ resolveAccounts: () => resolveCodexDiscoveryAccounts(this.authStorage, accessToken),
1731
1777
  fetch: this.#fetch,
1732
- });
1733
- },
1778
+ }),
1734
1779
  },
1735
1780
  ];
1736
1781
  const disabledProviders = getDisabledProviderIdsFromSettings();
@@ -2078,6 +2123,24 @@ export class ModelRegistry {
2078
2123
  .map(provider => provider.provider);
2079
2124
  }
2080
2125
 
2126
+ /**
2127
+ * Whether `providerId` is known to the registry: it has at least one live
2128
+ * model, or it is configured for dynamic discovery (models.yml `discovery:`
2129
+ * or a runtime extension provider) and is not disabled. Discovery-only
2130
+ * providers can hold zero models at startup — cached rows never persist
2131
+ * live auth headers (#5780), so a provider whose discovered models all
2132
+ * carry config headers (`authHeader: true`) only materializes models after
2133
+ * the online refresh completes.
2134
+ */
2135
+ hasProvider(providerId: string): boolean {
2136
+ if (this.#models.some(model => model.provider === providerId)) return true;
2137
+ if (getDisabledProviderIdsFromSettings().has(providerId)) return false;
2138
+ return (
2139
+ this.#discoverableProviders.some(provider => provider.provider === providerId) ||
2140
+ this.#runtimeModelManagers.has(providerId)
2141
+ );
2142
+ }
2143
+
2081
2144
  getProviderDiscoveryState(provider: string): ProviderDiscoveryState | undefined {
2082
2145
  return this.#providerDiscoveryStates.get(provider);
2083
2146
  }