@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.13

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 (113) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3176 -3087
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/keybindings.d.ts +9 -4
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/settings-schema.d.ts +6 -0
  7. package/dist/types/config/settings.d.ts +3 -1
  8. package/dist/types/discovery/helpers.d.ts +9 -0
  9. package/dist/types/exec/bash-executor.d.ts +1 -0
  10. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  11. package/dist/types/extensibility/shared-events.d.ts +2 -2
  12. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  13. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  14. package/dist/types/mnemopi/state.d.ts +7 -3
  15. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  16. package/dist/types/modes/components/model-selector.d.ts +2 -1
  17. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  18. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  19. package/dist/types/modes/interactive-mode.d.ts +3 -1
  20. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  21. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +3 -1
  23. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  24. package/dist/types/modes/workflow.d.ts +5 -1
  25. package/dist/types/session/agent-session.d.ts +8 -4
  26. package/dist/types/system-prompt.d.ts +1 -1
  27. package/dist/types/tools/bash-interactive.d.ts +1 -1
  28. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  29. package/dist/types/tools/bash.d.ts +2 -1
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/grep.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/path-utils.d.ts +24 -0
  34. package/dist/types/tools/read.d.ts +3 -0
  35. package/dist/types/tools/renderers.d.ts +12 -5
  36. package/dist/types/tools/ssh.d.ts +4 -1
  37. package/dist/types/tools/write.d.ts +1 -0
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +145 -0
  41. package/src/advisor/runtime.ts +19 -0
  42. package/src/config/api-key-resolver.ts +7 -2
  43. package/src/config/keybindings.ts +62 -10
  44. package/src/config/model-registry.ts +94 -20
  45. package/src/config/settings-schema.ts +11 -1
  46. package/src/config/settings.ts +59 -21
  47. package/src/discovery/builtin.ts +2 -1
  48. package/src/discovery/claude-plugins.ts +167 -46
  49. package/src/discovery/helpers.ts +16 -1
  50. package/src/edit/renderer.ts +20 -6
  51. package/src/eval/js/worker-core.ts +163 -6
  52. package/src/exec/bash-executor.ts +14 -9
  53. package/src/extensibility/extensions/runner.ts +1 -0
  54. package/src/extensibility/extensions/types.ts +13 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  56. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  57. package/src/extensibility/shared-events.ts +2 -2
  58. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  59. package/src/internal-urls/docs-index.generated.txt +1 -1
  60. package/src/internal-urls/registry-helpers.ts +9 -6
  61. package/src/mnemopi/state.ts +19 -5
  62. package/src/modes/acp/acp-agent.ts +69 -8
  63. package/src/modes/acp/acp-event-mapper.ts +1 -1
  64. package/src/modes/components/model-selector.ts +30 -6
  65. package/src/modes/components/read-tool-group.ts +5 -1
  66. package/src/modes/components/settings-defs.ts +1 -1
  67. package/src/modes/components/status-line/component.ts +14 -2
  68. package/src/modes/components/tool-execution.ts +28 -24
  69. package/src/modes/controllers/command-controller.ts +13 -23
  70. package/src/modes/controllers/event-controller.ts +12 -12
  71. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  72. package/src/modes/controllers/extension-ui-controller.ts +7 -35
  73. package/src/modes/controllers/input-controller.ts +23 -57
  74. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  75. package/src/modes/controllers/selector-controller.ts +16 -5
  76. package/src/modes/github-ref-autocomplete.ts +75 -0
  77. package/src/modes/interactive-mode.ts +97 -12
  78. package/src/modes/prompt-action-autocomplete.ts +35 -0
  79. package/src/modes/rpc/rpc-client.ts +42 -13
  80. package/src/modes/rpc/rpc-mode.ts +21 -19
  81. package/src/modes/types.ts +3 -0
  82. package/src/modes/utils/context-usage.ts +58 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  84. package/src/modes/utils/ui-helpers.ts +2 -2
  85. package/src/modes/workflow.ts +14 -8
  86. package/src/prompts/agents/plan.md +0 -1
  87. package/src/prompts/agents/reviewer.md +0 -1
  88. package/src/prompts/system/plan-mode-active.md +5 -2
  89. package/src/prompts/system/system-prompt.md +1 -2
  90. package/src/prompts/system/workflow-notice.md +69 -50
  91. package/src/prompts/tools/bash.md +18 -7
  92. package/src/prompts/tools/grep.md +2 -1
  93. package/src/prompts/tools/memory-edit.md +2 -0
  94. package/src/prompts/tools/read.md +4 -3
  95. package/src/sdk.ts +11 -0
  96. package/src/session/agent-session.ts +136 -19
  97. package/src/system-prompt.ts +3 -2
  98. package/src/tools/bash-interactive.ts +1 -1
  99. package/src/tools/bash-skill-urls.ts +39 -7
  100. package/src/tools/bash.ts +69 -39
  101. package/src/tools/browser/launch.ts +31 -4
  102. package/src/tools/grep.ts +105 -21
  103. package/src/tools/image-gen.ts +1 -1
  104. package/src/tools/index.ts +11 -0
  105. package/src/tools/memory-edit.ts +3 -1
  106. package/src/tools/path-utils.ts +46 -1
  107. package/src/tools/read.ts +135 -57
  108. package/src/tools/renderers.ts +13 -5
  109. package/src/tools/ssh.ts +10 -3
  110. package/src/tools/tts.ts +1 -1
  111. package/src/tools/write.ts +26 -0
  112. package/src/utils/local-date.ts +7 -0
  113. package/src/utils/open.ts +36 -10
@@ -46,6 +46,9 @@ interface SshRenderArgs {
46
46
  command?: string;
47
47
  timeout?: number;
48
48
  }
49
+ /** Whether the painted call args still carry the streamed raw-JSON buffer —
50
+ * the shape that renders the `⏳ SSH: […]` / `$ …` placeholder. */
51
+ declare function hasStreamedRenderArgs(args: unknown): boolean;
49
52
  interface SshRenderContext {
50
53
  /** Visual lines for truncated output (pre-computed by tool-execution) */
51
54
  visualLines?: string[];
@@ -68,7 +71,7 @@ export declare const sshToolRenderer: {
68
71
  renderContext?: SshRenderContext;
69
72
  }, uiTheme: Theme, args?: SshRenderArgs): Component;
70
73
  mergeCallAndResult: boolean;
71
- forceFirstResultViewportRepaint: boolean;
74
+ forceFirstResultViewportRepaint: typeof hasStreamedRenderArgs;
72
75
  forceResultViewportRepaintOnSettle: boolean;
73
76
  };
74
77
  export {};
@@ -62,5 +62,6 @@ export declare const writeToolRenderer: {
62
62
  isError?: boolean;
63
63
  }, options: RenderResultOptions, uiTheme: Theme, args?: WriteRenderArgs): Component;
64
64
  mergeCallAndResult: boolean;
65
+ forceFirstResultViewportRepaint: (args: unknown, options: RenderResultOptions) => boolean;
65
66
  };
66
67
  export {};
@@ -0,0 +1,2 @@
1
+ /** formatLocalCalendarDate formats a Date as YYYY-MM-DD in the host local timezone. */
2
+ export declare function formatLocalCalendarDate(date?: Date): string;
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": "16.3.11",
4
+ "version": "16.3.13",
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",
@@ -56,17 +56,17 @@
56
56
  "@agentclientprotocol/sdk": "0.25.0",
57
57
  "@babel/parser": "^7.29.7",
58
58
  "@mozilla/readability": "^0.6.0",
59
- "@oh-my-pi/hashline": "16.3.11",
60
- "@oh-my-pi/omp-stats": "16.3.11",
61
- "@oh-my-pi/pi-agent-core": "16.3.11",
62
- "@oh-my-pi/pi-ai": "16.3.11",
63
- "@oh-my-pi/pi-catalog": "16.3.11",
64
- "@oh-my-pi/pi-mnemopi": "16.3.11",
65
- "@oh-my-pi/pi-natives": "16.3.11",
66
- "@oh-my-pi/pi-tui": "16.3.11",
67
- "@oh-my-pi/pi-utils": "16.3.11",
68
- "@oh-my-pi/pi-wire": "16.3.11",
69
- "@oh-my-pi/snapcompact": "16.3.11",
59
+ "@oh-my-pi/hashline": "16.3.13",
60
+ "@oh-my-pi/omp-stats": "16.3.13",
61
+ "@oh-my-pi/pi-agent-core": "16.3.13",
62
+ "@oh-my-pi/pi-ai": "16.3.13",
63
+ "@oh-my-pi/pi-catalog": "16.3.13",
64
+ "@oh-my-pi/pi-mnemopi": "16.3.13",
65
+ "@oh-my-pi/pi-natives": "16.3.13",
66
+ "@oh-my-pi/pi-tui": "16.3.13",
67
+ "@oh-my-pi/pi-utils": "16.3.13",
68
+ "@oh-my-pi/pi-wire": "16.3.13",
69
+ "@oh-my-pi/snapcompact": "16.3.13",
70
70
  "@opentelemetry/api": "^1.9.1",
71
71
  "@opentelemetry/context-async-hooks": "^2.7.1",
72
72
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -1294,6 +1294,151 @@ describe("advisor", () => {
1294
1294
  expect(failures).toHaveLength(2);
1295
1295
  });
1296
1296
 
1297
+ it("calls onTurnError with state.error before retrying the batch", async () => {
1298
+ const promptInputs: string[] = [];
1299
+ const turnErrors: unknown[] = [];
1300
+ const events: string[] = [];
1301
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1302
+ let promptCalls = 0;
1303
+ const agent: AdvisorAgent = {
1304
+ prompt: async input => {
1305
+ promptCalls++;
1306
+ promptInputs.push(input);
1307
+ events.push(`prompt:${promptCalls}`);
1308
+ state.error = promptCalls === 1 ? "provider failed" : undefined;
1309
+ },
1310
+ abort: () => {},
1311
+ reset: () => {
1312
+ state.error = undefined;
1313
+ },
1314
+ state,
1315
+ };
1316
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1317
+ const host: AdvisorRuntimeHost = {
1318
+ snapshotMessages: () => messages,
1319
+ enqueueAdvice: () => {},
1320
+ onTurnError: error => {
1321
+ turnErrors.push(error);
1322
+ events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
1323
+ },
1324
+ };
1325
+ const runtime = new AdvisorRuntime(agent, host, 1);
1326
+
1327
+ runtime.onTurnEnd(messages);
1328
+ await runtime.waitForCatchup(1000, 1);
1329
+
1330
+ expect(promptInputs).toHaveLength(2);
1331
+ expect(turnErrors).toHaveLength(1);
1332
+ const error = turnErrors[0];
1333
+ if (!(error instanceof Error)) throw new Error("expected advisor turn error");
1334
+ expect(error.message).toBe("provider failed");
1335
+ expect(events).toEqual(["prompt:1", "hook:provider failed", "prompt:2"]);
1336
+ expect(runtime.backlog).toBe(0);
1337
+ });
1338
+
1339
+ it("calls onTurnError for each consecutive failure including the dropped third turn", async () => {
1340
+ const promptInputs: string[] = [];
1341
+ const turnErrors: unknown[] = [];
1342
+ const failures: unknown[] = [];
1343
+ const events: string[] = [];
1344
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1345
+ let promptCalls = 0;
1346
+ const agent: AdvisorAgent = {
1347
+ prompt: async input => {
1348
+ promptCalls++;
1349
+ promptInputs.push(input);
1350
+ events.push(`prompt:${promptCalls}`);
1351
+ state.error = `provider failed ${promptCalls}`;
1352
+ },
1353
+ abort: () => {},
1354
+ reset: () => {
1355
+ state.error = undefined;
1356
+ },
1357
+ state,
1358
+ };
1359
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1360
+ const host: AdvisorRuntimeHost = {
1361
+ snapshotMessages: () => messages,
1362
+ enqueueAdvice: () => {},
1363
+ onTurnError: error => {
1364
+ turnErrors.push(error);
1365
+ events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
1366
+ },
1367
+ notifyFailure: error => {
1368
+ failures.push(error);
1369
+ events.push(`notify:${error instanceof Error ? error.message : String(error)}`);
1370
+ },
1371
+ };
1372
+ const runtime = new AdvisorRuntime(agent, host, 1);
1373
+
1374
+ runtime.onTurnEnd(messages);
1375
+ await runtime.waitForCatchup(1000, 1);
1376
+
1377
+ expect(promptInputs).toHaveLength(3);
1378
+ expect(turnErrors.map(error => (error instanceof Error ? error.message : String(error)))).toEqual([
1379
+ "provider failed 1",
1380
+ "provider failed 2",
1381
+ "provider failed 3",
1382
+ ]);
1383
+ expect(failures).toHaveLength(1);
1384
+ const failure = failures[0];
1385
+ if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
1386
+ expect(failure.message).toBe("provider failed 3");
1387
+ expect(events).toEqual([
1388
+ "prompt:1",
1389
+ "hook:provider failed 1",
1390
+ "prompt:2",
1391
+ "hook:provider failed 2",
1392
+ "prompt:3",
1393
+ "hook:provider failed 3",
1394
+ "notify:provider failed 3",
1395
+ ]);
1396
+ expect(runtime.backlog).toBe(0);
1397
+ });
1398
+
1399
+ it("continues retrying when onTurnError rejects", async () => {
1400
+ const promptInputs: string[] = [];
1401
+ const turnErrors: unknown[] = [];
1402
+ const events: string[] = [];
1403
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1404
+ let promptCalls = 0;
1405
+ const agent: AdvisorAgent = {
1406
+ prompt: async input => {
1407
+ promptCalls++;
1408
+ promptInputs.push(input);
1409
+ events.push(`prompt:${promptCalls}`);
1410
+ state.error = promptCalls === 1 ? "provider failed" : undefined;
1411
+ },
1412
+ abort: () => {},
1413
+ reset: () => {
1414
+ state.error = undefined;
1415
+ },
1416
+ state,
1417
+ };
1418
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1419
+ const host: AdvisorRuntimeHost = {
1420
+ snapshotMessages: () => messages,
1421
+ enqueueAdvice: () => {},
1422
+ onTurnError: async error => {
1423
+ turnErrors.push(error);
1424
+ events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
1425
+ throw new Error("hook failed");
1426
+ },
1427
+ };
1428
+ const runtime = new AdvisorRuntime(agent, host, 1);
1429
+
1430
+ runtime.onTurnEnd(messages);
1431
+ await runtime.waitForCatchup(1000, 1);
1432
+
1433
+ expect(promptInputs).toHaveLength(2);
1434
+ expect(turnErrors).toHaveLength(1);
1435
+ const error = turnErrors[0];
1436
+ if (!(error instanceof Error)) throw new Error("expected advisor turn error");
1437
+ expect(error.message).toBe("provider failed");
1438
+ expect(events).toEqual(["prompt:1", "hook:provider failed", "prompt:2"]);
1439
+ expect(runtime.backlog).toBe(0);
1440
+ });
1441
+
1297
1442
  it("rolls advisor state back after each failed prompt so retries don't replay duplicate turns", async () => {
1298
1443
  // The real `Agent` appends the user batch + a synthetic `stopReason: "error"`
1299
1444
  // assistant turn before `state.error` is read. Without rollback, the runtime's
@@ -48,6 +48,17 @@ export interface AdvisorRuntimeHost {
48
48
  * one that routes `advise()` results back to the primary.
49
49
  */
50
50
  beginAdvisorUpdate?(): void;
51
+ /**
52
+ * Called with the error of every failed advisor turn, before the retry sleep
53
+ * or the dropped-after-3 path. Lets the host apply credential-level remedies
54
+ * the advisor loop lacks: the in-stream a/b/c auth retry rotates through
55
+ * sibling credentials within one request but never blocks the LAST failing
56
+ * one — the primary agent's retry pipeline does that via
57
+ * `markUsageLimitReached`, so without this hook the advisor re-picks the
58
+ * same usage-limited account on every retry. Errors thrown here are logged
59
+ * and swallowed.
60
+ */
61
+ onTurnError?(error: unknown): Promise<void> | void;
51
62
  /** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
52
63
  notifyFailure?(error: unknown): void;
53
64
  }
@@ -352,6 +363,14 @@ export class AdvisorRuntime {
352
363
  if (this.#epoch !== epoch) continue;
353
364
  this.#rollbackFailedTurn(messageSnapshot);
354
365
  logger.debug("advisor turn failed", { err: String(err) });
366
+ try {
367
+ await this.host.onTurnError?.(err);
368
+ } catch (hookErr) {
369
+ logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
370
+ }
371
+ // The hook awaits; a reset during it invalidates this batch like the
372
+ // prompt await above — drop it instead of requeueing stale content.
373
+ if (this.#epoch !== epoch) continue;
355
374
  this.#consecutiveFailures++;
356
375
  if (this.#consecutiveFailures >= 3) {
357
376
  logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
@@ -49,7 +49,7 @@ export function createApiKeyResolver(
49
49
  options: ApiKeyResolverOptions = {},
50
50
  ): ApiKeyResolver {
51
51
  const { sessionId, baseUrl, modelId } = options;
52
- return async ({ lastChance, error, signal }) => {
52
+ return async ({ lastChance, error, signal, previousKey }) => {
53
53
  if (error === undefined) {
54
54
  return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId });
55
55
  }
@@ -59,7 +59,12 @@ export function createApiKeyResolver(
59
59
  // sibling exists we switch immediately; the precise no-sibling backoff
60
60
  // is owned by `markUsageLimitReached` (default + server usage-report
61
61
  // reset) and the outer whole-turn retry layer.
62
- await registry.authStorage.rotateSessionCredential(provider, sessionId, { error, modelId, signal });
62
+ await registry.authStorage.rotateSessionCredential(provider, sessionId, {
63
+ error,
64
+ modelId,
65
+ signal,
66
+ apiKey: previousKey,
67
+ });
63
68
  return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId });
64
69
  }
65
70
  return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId, forceRefresh: true, signal });
@@ -9,7 +9,7 @@ import {
9
9
  TUI_KEYBINDINGS,
10
10
  KeybindingsManager as TuiKeybindingsManager,
11
11
  } from "@oh-my-pi/pi-tui";
12
- import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
12
+ import { getActiveProfile, getAgentDir, getProfileRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
13
13
  import { JSONC, YAML } from "bun";
14
14
 
15
15
  /**
@@ -375,6 +375,12 @@ interface KeybindingsConfigPaths {
375
375
  writeBackPath: string;
376
376
  }
377
377
 
378
+ /** Controls inherited keybinding lookup when creating a manager for a named profile. */
379
+ export interface KeybindingsCreateOptions {
380
+ /** Default-profile agent directory whose keybindings are merged before profile-specific bindings. */
381
+ inheritedAgentDir?: string;
382
+ }
383
+
378
384
  /**
379
385
  * Load raw config from a file synchronously.
380
386
  * Returns parsed JSON/YAML or null if file doesn't exist or is invalid.
@@ -428,6 +434,48 @@ function resolveKeybindingsConfigPaths(agentDir: string): KeybindingsConfigPaths
428
434
  return { readPath: ymlPath, writeBackPath: ymlPath };
429
435
  }
430
436
 
437
+ function mergeKeybindingsConfig(
438
+ inheritedConfig: KeybindingsConfig,
439
+ profileConfig: KeybindingsConfig,
440
+ ): KeybindingsConfig {
441
+ return { ...inheritedConfig, ...profileConfig };
442
+ }
443
+
444
+ function resolveInheritedAgentDir(agentDir: string, options: KeybindingsCreateOptions): string | undefined {
445
+ const inheritedAgentDir =
446
+ options.inheritedAgentDir ?? (getActiveProfile() ? path.join(getProfileRootDir(undefined), "agent") : undefined);
447
+ if (!inheritedAgentDir) return undefined;
448
+ if (path.resolve(inheritedAgentDir) === path.resolve(agentDir)) return undefined;
449
+ return inheritedAgentDir;
450
+ }
451
+
452
+ function loadMergedKeybindingsConfig(
453
+ agentDir: string,
454
+ options: KeybindingsCreateOptions,
455
+ ): {
456
+ config: KeybindingsConfig;
457
+ profilePath: string;
458
+ inheritedPath: string | undefined;
459
+ } {
460
+ const profilePaths = resolveKeybindingsConfigPaths(agentDir);
461
+ const profile = loadKeybindingsConfig(profilePaths.readPath, profilePaths.writeBackPath);
462
+ const inheritedAgentDir = resolveInheritedAgentDir(agentDir, options);
463
+ if (!inheritedAgentDir) {
464
+ return { config: profile.config, profilePath: profile.persistedPath, inheritedPath: undefined };
465
+ }
466
+
467
+ const inheritedPaths = resolveKeybindingsConfigPaths(inheritedAgentDir);
468
+ // Read-only: a named-profile process must never write migration output into
469
+ // the default profile's agent dir. Name migration still applies in-memory;
470
+ // the on-disk migration happens when the default profile itself launches.
471
+ const inherited = loadKeybindingsConfig(inheritedPaths.readPath, undefined);
472
+ return {
473
+ config: mergeKeybindingsConfig(inherited.config, profile.config),
474
+ profilePath: profile.persistedPath,
475
+ inheritedPath: inherited.persistedPath,
476
+ };
477
+ }
478
+
431
479
  /**
432
480
  * Load and migrate keybindings config.
433
481
  * Legacy JSON is read for compatibility, but successful write-back goes to YAML.
@@ -499,22 +547,23 @@ function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
499
547
  */
500
548
  export class KeybindingsManager extends TuiKeybindingsManager {
501
549
  #configPath: string | undefined;
550
+ #inheritedConfigPath: string | undefined;
502
551
  #userBindings: KeybindingsConfig;
503
552
 
504
- constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
553
+ constructor(userBindings: KeybindingsConfig = {}, configPath?: string, inheritedConfigPath?: string) {
505
554
  super(KEYBINDINGS, userBindings);
506
555
  this.#configPath = configPath;
556
+ this.#inheritedConfigPath = inheritedConfigPath;
507
557
  this.#userBindings = userBindings;
508
558
  }
509
559
 
510
560
  /**
511
- * Create from config file at agentDir/keybindings.yml.
561
+ * Create from config files at agentDir/keybindings.yml and the default profile.
512
562
  * Legacy keybindings.json is migrated to keybindings.yml on load.
513
563
  */
514
- static create(agentDir: string = getAgentDir()): KeybindingsManager {
515
- const { readPath, writeBackPath } = resolveKeybindingsConfigPaths(agentDir);
516
- const { config: userBindings, persistedPath } = KeybindingsManager.#loadFromFile(readPath, writeBackPath);
517
- const manager = new KeybindingsManager(userBindings, persistedPath);
564
+ static create(agentDir: string = getAgentDir(), options: KeybindingsCreateOptions = {}): KeybindingsManager {
565
+ const { config: userBindings, profilePath, inheritedPath } = loadMergedKeybindingsConfig(agentDir, options);
566
+ const manager = new KeybindingsManager(userBindings, profilePath, inheritedPath);
518
567
  // Set globally so getKeybindings() returns this manager
519
568
  setKeybindings(manager);
520
569
  return manager;
@@ -528,12 +577,15 @@ export class KeybindingsManager extends TuiKeybindingsManager {
528
577
  }
529
578
 
530
579
  /**
531
- * Reload keybindings from the config file.
580
+ * Reload keybindings from the config files.
532
581
  */
533
582
  reload(): void {
534
583
  if (!this.#configPath) return;
535
- const { config } = KeybindingsManager.#loadFromFile(this.#configPath);
536
- this.setUserBindings(config);
584
+ const { config: inheritedConfig } = this.#inheritedConfigPath
585
+ ? KeybindingsManager.#loadFromFile(this.#inheritedConfigPath)
586
+ : { config: {} };
587
+ const { config: profileConfig } = KeybindingsManager.#loadFromFile(this.#configPath);
588
+ this.setUserBindings(mergeKeybindingsConfig(inheritedConfig, profileConfig));
537
589
  }
538
590
 
539
591
  setUserBindings(userBindings: KeybindingsConfig): void {
@@ -54,6 +54,12 @@ const LOCAL_PROVIDER_PLACEHOLDERS = new Set<string>(["llama-cpp-local", "lm-stud
54
54
  * so a successful fast path does not leave an armed timeout signal for concurrent GC.
55
55
  */
56
56
  const RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS = 15_000;
57
+ // Built-in discovery preflight mirror of the catalog model-manager's private
58
+ // cache timings (model-manager.ts: DEFAULT_CACHE_TTL_MS / NON_AUTHORITATIVE_RETRY_MS).
59
+ // Built-in descriptors never override cacheTtlMs, so agreeing with these values
60
+ // makes the OAuth-refresh preflight fire exactly when the manager will fetch.
61
+ const BUILT_IN_DISCOVERY_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
62
+ const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000;
57
63
 
58
64
  import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
59
65
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
@@ -1560,12 +1566,11 @@ export class ModelRegistry {
1560
1566
  ): Promise<BuiltInDiscoveryResult> {
1561
1567
  // Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
1562
1568
  const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
1563
- const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => {
1564
- if (configuredDiscoveryProviders.has(opts.providerId)) {
1565
- return false;
1566
- }
1567
- return providerFilter ? providerFilter.has(opts.providerId) : true;
1568
- });
1569
+ const managerOptions = await this.#collectBuiltInModelManagerOptions(
1570
+ strategy,
1571
+ providerFilter,
1572
+ configuredDiscoveryProviders,
1573
+ );
1569
1574
  if (managerOptions.length === 0) {
1570
1575
  return { models: [], authoritativeProviders: new Set() };
1571
1576
  }
@@ -1583,7 +1588,49 @@ export class ModelRegistry {
1583
1588
  return { models, authoritativeProviders };
1584
1589
  }
1585
1590
 
1586
- async #collectBuiltInModelManagerOptions(): Promise<ModelManagerOptions<Api>[]> {
1591
+ async #resolveBuiltInDiscoveryApiKey(
1592
+ providerId: string,
1593
+ strategy: ModelRefreshStrategy,
1594
+ cacheProviderId: string,
1595
+ ): Promise<string | undefined> {
1596
+ const peekedKey = await this.#peekApiKeyForProvider(providerId);
1597
+ if (isAuthenticated(peekedKey) || strategy === "offline") {
1598
+ return peekedKey;
1599
+ }
1600
+ const oauthCredentials = getOAuthCredentialsForProvider(this.authStorage, providerId);
1601
+ if (oauthCredentials.length === 0) {
1602
+ return peekedKey;
1603
+ }
1604
+ if (strategy === "online-if-uncached") {
1605
+ // Mirror shouldFetchRemoteSources: built-in managers use the catalog's
1606
+ // default TTL, so only refresh when the manager will actually fetch.
1607
+ const cache = readModelCache<Api>(
1608
+ cacheProviderId,
1609
+ BUILT_IN_DISCOVERY_CACHE_TTL_MS,
1610
+ Date.now,
1611
+ this.#cacheDbPath,
1612
+ );
1613
+ const cacheAgeMs = cache ? Date.now() - cache.updatedAt : Number.POSITIVE_INFINITY;
1614
+ if (cache?.fresh && (cache.authoritative || cacheAgeMs < BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS)) {
1615
+ return peekedKey;
1616
+ }
1617
+ }
1618
+ try {
1619
+ return await this.getApiKeyForProvider(providerId);
1620
+ } catch (error) {
1621
+ logger.debug("OAuth refresh failed during model discovery preflight", {
1622
+ provider: providerId,
1623
+ error: error instanceof Error ? error.message : String(error),
1624
+ });
1625
+ return peekedKey;
1626
+ }
1627
+ }
1628
+
1629
+ async #collectBuiltInModelManagerOptions(
1630
+ strategy: ModelRefreshStrategy,
1631
+ providerFilter: ReadonlySet<string> | undefined,
1632
+ configuredDiscoveryProviders: ReadonlySet<string>,
1633
+ ): Promise<ModelManagerOptions<Api>[]> {
1587
1634
  const specialProviderDescriptors: Array<{
1588
1635
  providerId: string;
1589
1636
  resolveKey: (value: string | undefined) => string | undefined;
@@ -1622,20 +1669,33 @@ export class ModelRegistry {
1622
1669
  },
1623
1670
  ];
1624
1671
  const disabledProviders = getDisabledProviderIdsFromSettings();
1625
- const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(
1626
- descriptor => !disabledProviders.has(descriptor.providerId),
1672
+ const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(descriptor => {
1673
+ if (disabledProviders.has(descriptor.providerId)) return false;
1674
+ if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
1675
+ return providerFilter ? providerFilter.has(descriptor.providerId) : true;
1676
+ });
1677
+ const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(descriptor => {
1678
+ if (disabledProviders.has(descriptor.providerId)) return false;
1679
+ if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
1680
+ return providerFilter ? providerFilter.has(descriptor.providerId) : true;
1681
+ });
1682
+ const standardProviderKeys = await Promise.all(
1683
+ standardProviderDescriptors.map(descriptor => {
1684
+ const discoveryBaseUrl =
1685
+ this.#runtimeProviderOverrides.get(descriptor.providerId)?.baseUrl ??
1686
+ this.#providerOverrides.get(descriptor.providerId)?.baseUrl ??
1687
+ this.getProviderBaseUrl(descriptor.providerId);
1688
+ const cacheProviderId =
1689
+ descriptor.createModelManagerOptions({ baseUrl: discoveryBaseUrl, fetch: this.#fetch })
1690
+ .cacheProviderId ?? descriptor.providerId;
1691
+ return this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, cacheProviderId);
1692
+ }),
1627
1693
  );
1628
- const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(
1629
- descriptor => !disabledProviders.has(descriptor.providerId),
1694
+ const specialKeys = await Promise.all(
1695
+ enabledSpecialProviderDescriptors.map(descriptor =>
1696
+ this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, descriptor.providerId),
1697
+ ),
1630
1698
  );
1631
- // Use peekApiKey to avoid OAuth token refresh during discovery.
1632
- // The token is only needed if the dynamic fetch fires (cache miss),
1633
- // and failures there are handled gracefully.
1634
- const peekKey = (descriptor: { providerId: string }) => this.#peekApiKeyForProvider(descriptor.providerId);
1635
- const [standardProviderKeys, specialKeys] = await Promise.all([
1636
- Promise.all(standardProviderDescriptors.map(peekKey)),
1637
- Promise.all(enabledSpecialProviderDescriptors.map(peekKey)),
1638
- ]);
1639
1699
  const options: ModelManagerOptions<Api>[] = [];
1640
1700
  for (let i = 0; i < standardProviderDescriptors.length; i++) {
1641
1701
  const descriptor = standardProviderDescriptors[i];
@@ -1670,7 +1730,12 @@ export class ModelRegistry {
1670
1730
  }
1671
1731
  // Append runtime model managers registered by extensions via fetchDynamicModels.
1672
1732
  for (const { options: managerOpts } of this.#runtimeModelManagers.values()) {
1673
- options.push(managerOpts);
1733
+ if (
1734
+ !configuredDiscoveryProviders.has(managerOpts.providerId) &&
1735
+ (!providerFilter || providerFilter.has(managerOpts.providerId))
1736
+ ) {
1737
+ options.push(managerOpts);
1738
+ }
1674
1739
  }
1675
1740
  return options;
1676
1741
  }
@@ -2266,6 +2331,15 @@ export class ModelRegistry {
2266
2331
  return true;
2267
2332
  }
2268
2333
 
2334
+ /**
2335
+ * Clear the cooldown suppression for one selector after an explicit user selection.
2336
+ */
2337
+ clearSuppressedSelector(selector: string): void {
2338
+ this.#suppressedSelectors.delete(
2339
+ normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined),
2340
+ );
2341
+ }
2342
+
2269
2343
  /**
2270
2344
  * Clear all cooldown suppressions recorded via {@link suppressSelector}.
2271
2345
  * Used to reset retry-fallback cooldown state without a full {@link refresh}.
@@ -1367,7 +1367,17 @@ export const SETTINGS_SCHEMA = {
1367
1367
  description: "Allow retry recovery to switch to configured fallback models",
1368
1368
  },
1369
1369
  },
1370
- "retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
1370
+ "retry.fallbackChains": {
1371
+ type: "record",
1372
+ default: {} as Record<string, string[]>,
1373
+ ui: {
1374
+ tab: "model",
1375
+ group: "Retry & Fallback",
1376
+ label: "Retry Fallback Chains",
1377
+ description:
1378
+ 'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.',
1379
+ },
1380
+ },
1371
1381
  "retry.fallbackRevertPolicy": {
1372
1382
  type: "enum",
1373
1383
  values: ["cooldown-expiry", "never"] as const,