@oh-my-pi/pi-coding-agent 16.3.12 → 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 (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli.js +3244 -3246
  3. package/dist/types/config/keybindings.d.ts +9 -4
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  6. package/dist/types/mnemopi/state.d.ts +7 -3
  7. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  8. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  9. package/dist/types/modes/interactive-mode.d.ts +3 -1
  10. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  11. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  12. package/dist/types/modes/types.d.ts +3 -1
  13. package/dist/types/session/agent-session.d.ts +3 -4
  14. package/dist/types/tools/read.d.ts +1 -0
  15. package/dist/types/tools/renderers.d.ts +12 -5
  16. package/dist/types/tools/ssh.d.ts +4 -1
  17. package/dist/types/tools/write.d.ts +1 -0
  18. package/package.json +12 -12
  19. package/src/config/keybindings.ts +62 -10
  20. package/src/config/model-registry.ts +85 -20
  21. package/src/config/settings.ts +48 -21
  22. package/src/extensibility/extensions/runner.ts +1 -0
  23. package/src/extensibility/extensions/types.ts +13 -2
  24. package/src/internal-urls/docs-index.generated.txt +1 -1
  25. package/src/mnemopi/state.ts +19 -5
  26. package/src/modes/acp/acp-agent.ts +69 -8
  27. package/src/modes/acp/acp-event-mapper.ts +1 -1
  28. package/src/modes/components/read-tool-group.ts +5 -1
  29. package/src/modes/components/tool-execution.ts +28 -24
  30. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  31. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  32. package/src/modes/controllers/input-controller.ts +5 -55
  33. package/src/modes/interactive-mode.ts +43 -2
  34. package/src/modes/rpc/rpc-client.ts +42 -13
  35. package/src/modes/rpc/rpc-mode.ts +21 -19
  36. package/src/modes/types.ts +3 -0
  37. package/src/prompts/agents/plan.md +0 -1
  38. package/src/prompts/agents/reviewer.md +0 -1
  39. package/src/prompts/tools/grep.md +2 -1
  40. package/src/prompts/tools/memory-edit.md +2 -0
  41. package/src/session/agent-session.ts +8 -5
  42. package/src/tools/grep.ts +58 -13
  43. package/src/tools/image-gen.ts +1 -1
  44. package/src/tools/memory-edit.ts +3 -1
  45. package/src/tools/read.ts +33 -13
  46. package/src/tools/renderers.ts +13 -5
  47. package/src/tools/ssh.ts +10 -3
  48. package/src/tools/tts.ts +1 -1
  49. package/src/tools/write.ts +26 -0
@@ -323,6 +323,11 @@ export declare const KEYBINDINGS: {
323
323
  readonly description: "Toggle speech-to-text (default gesture: hold Space)";
324
324
  };
325
325
  };
326
+ /** Controls inherited keybinding lookup when creating a manager for a named profile. */
327
+ export interface KeybindingsCreateOptions {
328
+ /** Default-profile agent directory whose keybindings are merged before profile-specific bindings. */
329
+ inheritedAgentDir?: string;
330
+ }
326
331
  declare function migrateKeybindingsConfigFile(agentDir: string): void;
327
332
  /**
328
333
  * Manages all keybindings (app + TUI).
@@ -330,18 +335,18 @@ declare function migrateKeybindingsConfigFile(agentDir: string): void;
330
335
  */
331
336
  export declare class KeybindingsManager extends TuiKeybindingsManager {
332
337
  #private;
333
- constructor(userBindings?: KeybindingsConfig, configPath?: string);
338
+ constructor(userBindings?: KeybindingsConfig, configPath?: string, inheritedConfigPath?: string);
334
339
  /**
335
- * Create from config file at agentDir/keybindings.yml.
340
+ * Create from config files at agentDir/keybindings.yml and the default profile.
336
341
  * Legacy keybindings.json is migrated to keybindings.yml on load.
337
342
  */
338
- static create(agentDir?: string): KeybindingsManager;
343
+ static create(agentDir?: string, options?: KeybindingsCreateOptions): KeybindingsManager;
339
344
  /**
340
345
  * Create an in-memory keybindings manager without file persistence.
341
346
  */
342
347
  static inMemory(userBindings?: KeybindingsConfig): KeybindingsManager;
343
348
  /**
344
- * Reload keybindings from the config file.
349
+ * Reload keybindings from the config files.
345
350
  */
346
351
  reload(): void;
347
352
  setUserBindings(userBindings: KeybindingsConfig): void;
@@ -24,7 +24,7 @@ export interface RawSettings {
24
24
  export interface SettingsOptions {
25
25
  /** Current working directory for project settings discovery */
26
26
  cwd?: string;
27
- /** Agent directory for config.yml storage */
27
+ /** Agent directory for config.yml/config.yaml storage */
28
28
  agentDir?: string;
29
29
  /** Don't persist to disk (for tests) */
30
30
  inMemory?: boolean;
@@ -11,7 +11,7 @@ import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLe
11
11
  import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
12
12
  import type { Api, AssistantMessageEvent, AssistantMessageEventStream, Context, ImageContent, Model, ModelSpec, ProviderResponseMetadata, SimpleStreamOptions, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai";
13
13
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
14
- import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
14
+ import type { AutocompleteItem, AutocompleteProvider, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
15
15
  import type { logger as PiLogger } from "@oh-my-pi/pi-utils";
16
16
  import type { Type as arktype } from "arktype";
17
17
  import type * as zod from "zod/v4";
@@ -89,6 +89,8 @@ export type ExtensionUiComponent = Component & {
89
89
  };
90
90
  export type ExtensionUiComponentFactory = (tui: TUI, theme: Theme) => ExtensionUiComponent;
91
91
  export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | undefined;
92
+ /** Wrap the current autocomplete provider with additional behavior (pi-compatible). */
93
+ export type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider;
92
94
  /**
93
95
  * UI context for extensions to request interactive UI.
94
96
  * Each mode (interactive, RPC, print) provides its own implementation.
@@ -135,6 +137,13 @@ export interface ExtensionUIContext {
135
137
  editor(title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions, editorOptions?: {
136
138
  promptStyle?: boolean;
137
139
  }): Promise<string | undefined>;
140
+ /**
141
+ * Stack additional autocomplete behavior on top of the built-in provider
142
+ * (pi-compatible). Interactive mode rebuilds the editor's provider through
143
+ * every registered factory, in registration order; headless modes (print,
144
+ * RPC, ACP, subagents) accept and ignore the factory.
145
+ */
146
+ addAutocompleteProvider(factory: AutocompleteProviderFactory): void;
138
147
  /**
139
148
  * Set a custom editor component via factory function, or `undefined` to restore the default editor.
140
149
  *
@@ -707,7 +716,7 @@ export interface ExtensionAPI {
707
716
  triggerTurn?: boolean;
708
717
  deliverAs?: "steer" | "followUp" | "nextTurn";
709
718
  }): void;
710
- /** Send a user message to the agent, or queue it when deliverAs is set. */
719
+ /** Send a user prompt: idle starts a turn; streaming queues as steer unless deliverAs is set. */
711
720
  sendUserMessage(content: string | (TextContent | ImageContent)[], options?: {
712
721
  deliverAs?: "steer" | "followUp";
713
722
  }): void;
@@ -33,10 +33,14 @@ export interface MnemopiMemoryEditOptions {
33
33
  replacementId?: string;
34
34
  }
35
35
  export interface MnemopiMemoryEditResult {
36
- status: "updated" | "deleted" | "invalidated" | "not_found";
36
+ status: "updated" | "deleted" | "invalidated" | "not_found" | "not_editable";
37
37
  bank?: string;
38
- store?: "working" | "episodic";
38
+ store?: MnemopiMemoryStore;
39
39
  }
40
+ /** Which mnemopi table a resolved memory id lives in. `fact` rows are
41
+ * read-only projections of fact extraction (issue #4725): resolvable for
42
+ * reads, never editable. */
43
+ export type MnemopiMemoryStore = "working" | "episodic" | "fact";
40
44
  /**
41
45
  * Full-row lookup result produced by {@link MnemopiSessionState.getScopedMemory}.
42
46
  * Mirrors the shape stored in mnemopi's working/episodic tables, tagged with
@@ -45,7 +49,7 @@ export interface MnemopiMemoryEditResult {
45
49
  */
46
50
  export interface MnemopiScopedMemoryHit {
47
51
  bank: string;
48
- store: "working" | "episodic";
52
+ store: MnemopiMemoryStore;
49
53
  row: {
50
54
  id: string;
51
55
  content: string;
@@ -30,4 +30,5 @@ export declare function buildToolCallStartUpdate(input: {
30
30
  export declare function normalizeReplayToolArguments(value: unknown): {
31
31
  args: unknown;
32
32
  };
33
+ export declare function extractAssistantMessageText(value: unknown): string;
33
34
  export {};
@@ -6,6 +6,7 @@ export declare function readArgsTargetInternalUrl(args: unknown): boolean;
6
6
  type ReadRenderArgs = {
7
7
  path?: string;
8
8
  file_path?: string;
9
+ selector?: string;
9
10
  sel?: string;
10
11
  };
11
12
  type ReadToolGroupOptions = {
@@ -7,7 +7,7 @@ import type { CollabGuestLink } from "../collab/guest.js";
7
7
  import type { CollabHost } from "../collab/host.js";
8
8
  import { KeybindingsManager } from "../config/keybindings.js";
9
9
  import { Settings } from "../config/settings.js";
10
- import type { ExtensionUIContext, ExtensionUIDialogOptions, ExtensionUISelectItem, ExtensionWidgetContent, ExtensionWidgetOptions } from "../extensibility/extensions/index.js";
10
+ import type { AutocompleteProviderFactory, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionUISelectItem, ExtensionWidgetContent, ExtensionWidgetOptions } from "../extensibility/extensions/index.js";
11
11
  import type { CompactOptions } from "../extensibility/extensions/types.js";
12
12
  import type { Skill } from "../extensibility/skills.js";
13
13
  import type { MCPManager } from "../mcp/index.js";
@@ -180,6 +180,8 @@ export declare class InteractiveMode implements InteractiveModeContext {
180
180
  refreshTitleSystemPrompt(cwd?: string): Promise<void>;
181
181
  /** Reload slash commands and autocomplete for the provided working directory. */
182
182
  refreshSlashCommandState(cwd?: string): Promise<void>;
183
+ /** Stack extension autocomplete behavior on top of the built-in editor provider (#4919). */
184
+ addAutocompleteProvider(factory: AutocompleteProviderFactory): void;
183
185
  /**
184
186
  * Re-point the process and every cwd-derived cache at `newCwd` after the
185
187
  * active session's working directory changed (`/move` relocation or resuming
@@ -269,17 +269,23 @@ export declare class RpcClient {
269
269
  /**
270
270
  * Trigger OAuth login for the given provider.
271
271
  * The server will emit an `open_url` extension_ui_request for the auth URL.
272
+ * Providers that require pasted-code completion may then emit an `input`
273
+ * extension_ui_request; pass `onManualCodeInput` to satisfy it.
272
274
  * Resolves when login completes or rejects on failure.
273
275
  *
274
276
  * @param onOpenUrl Called when the server emits the auth URL. The host must
275
- * open `url` in a browser for the callback-server OAuth flow to complete.
276
- * When the flow's callback server hosts a `/launch` redirect, `launchUrl`
277
- * is a short loopback URL that 302s to `url` — hosts SHOULD surface it as
278
- * the truncation-safe copy target so terminal viewport clipping cannot
279
- * corrupt trailing OAuth query parameters (e.g. `code_challenge_method=S256`).
277
+ * open `url` in a browser. When the flow's callback server hosts a
278
+ * `/launch` redirect, `launchUrl` is a short loopback URL that 302s to
279
+ * `url` — hosts SHOULD surface it as the truncation-safe copy target so
280
+ * terminal viewport clipping cannot corrupt trailing OAuth query
281
+ * parameters (e.g. `code_challenge_method=S256`).
280
282
  */
281
283
  login(providerId: string, options?: {
282
284
  onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void;
285
+ onManualCodeInput?: (prompt: {
286
+ title: string;
287
+ placeholder?: string;
288
+ }) => string | Promise<string>;
283
289
  }): Promise<{
284
290
  providerId: string;
285
291
  }>;
@@ -38,7 +38,7 @@ export type RpcSkillCommandSession = Pick<AgentSession, "promptCustomMessage" |
38
38
  export type RpcSkillCommandResult = {
39
39
  agentInvoked: true;
40
40
  };
41
- export declare function tryRunRpcSkillCommand(session: RpcSkillCommandSession, text: string): Promise<RpcSkillCommandResult | false>;
41
+ export declare function tryRunRpcSkillCommand(session: RpcSkillCommandSession, text: string, streamingBehavior?: "steer" | "followUp"): Promise<RpcSkillCommandResult | false>;
42
42
  export declare function reportLocalOnlyPromptResult(input: {
43
43
  id: string | undefined;
44
44
  prompt: Promise<boolean>;
@@ -6,7 +6,7 @@ import type { CollabGuestLink } from "../collab/guest.js";
6
6
  import type { CollabHost } from "../collab/host.js";
7
7
  import type { KeybindingsManager } from "../config/keybindings.js";
8
8
  import type { Settings } from "../config/settings.js";
9
- import type { ExtensionUIContext, ExtensionUIDialogOptions, ExtensionUISelectItem, ExtensionWidgetContent, ExtensionWidgetOptions } from "../extensibility/extensions/index.js";
9
+ import type { AutocompleteProviderFactory, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionUISelectItem, ExtensionWidgetContent, ExtensionWidgetOptions } from "../extensibility/extensions/index.js";
10
10
  import type { CompactOptions } from "../extensibility/extensions/types.js";
11
11
  import type { Skill } from "../extensibility/skills.js";
12
12
  import type { MCPManager } from "../mcp/index.js";
@@ -195,6 +195,8 @@ export interface InteractiveModeContext {
195
195
  checkShutdownRequested(): Promise<void>;
196
196
  setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
197
197
  initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void;
198
+ /** Stack extension autocomplete behavior on top of the built-in editor provider. */
199
+ addAutocompleteProvider(factory: AutocompleteProviderFactory): void;
198
200
  setEditorComponent(factory: ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => CustomEditor) | undefined): void;
199
201
  /**
200
202
  * Mount transcript content and repaint once. The single sink for "show this in
@@ -814,11 +814,10 @@ export declare class AgentSession {
814
814
  queueChipText?: string;
815
815
  }): Promise<boolean>;
816
816
  /**
817
- * Send a user message to the agent.
818
- * When deliverAs is set, queue the message instead of starting a new turn.
817
+ * Send a user message through the prompt flow.
819
818
  *
820
- * @param content User message content (string or content array)
821
- * @param options.deliverAs Delivery mode: "steer" or "followUp"
819
+ * Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming.
820
+ * Explicit `deliverAs` queues without starting a turn in either state.
822
821
  */
823
822
  sendUserMessage(content: string | (TextContent | ImageContent)[], options?: {
824
823
  deliverAs?: "steer" | "followUp";
@@ -69,6 +69,7 @@ export declare class ReadTool implements AgentTool<typeof readSchema, ReadToolDe
69
69
  interface ReadRenderArgs {
70
70
  path?: unknown;
71
71
  file_path?: unknown;
72
+ selector?: unknown;
72
73
  sel?: string;
73
74
  offset?: number;
74
75
  limit?: number;
@@ -6,6 +6,14 @@
6
6
  import type { Component } from "@oh-my-pi/pi-tui";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types.js";
8
8
  import type { Theme } from "../modes/theme/theme.js";
9
+ /**
10
+ * Per-renderer opt-in for a full viewport replay when the first result
11
+ * replaces a painted pending-call render. A predicate receives the painted
12
+ * call args and render options so the repaint stays scoped to the pending
13
+ * shapes that actually re-anchor (an over-eager replay wipes native
14
+ * scrollback on direct terminals).
15
+ */
16
+ export type FirstResultViewportRepaint = boolean | ((args: unknown, options: RenderResultOptions) => boolean);
9
17
  export type ToolRenderer = {
10
18
  renderCall: (args: unknown, options: RenderResultOptions, theme: Theme) => Component;
11
19
  renderResult: (result: {
@@ -33,12 +41,11 @@ export type ToolRenderer = {
33
41
  */
34
42
  animatedPartialResult?: boolean | ((args: unknown) => boolean);
35
43
  /**
36
- * Whether replacing a streamed pending placeholder with the first result
37
- * requires a full viewport repaint. Use for merged renderers whose pending
38
- * streamed args may have committed placeholder rows that the result render
39
- * re-anchors instead of preserving.
44
+ * Whether replacing a pending call render with the first result requires a
45
+ * full viewport repaint. Use for merged renderers whose pending rows can be
46
+ * re-anchored instead of preserved by the result render.
40
47
  */
41
- forceFirstResultViewportRepaint?: boolean;
48
+ forceFirstResultViewportRepaint?: FirstResultViewportRepaint;
42
49
  /**
43
50
  * Whether settling a provisional partial result into the final render requires
44
51
  * a full viewport repaint. Use when the result renderer changes chrome or
@@ -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 {};
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.12",
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.12",
60
- "@oh-my-pi/omp-stats": "16.3.12",
61
- "@oh-my-pi/pi-agent-core": "16.3.12",
62
- "@oh-my-pi/pi-ai": "16.3.12",
63
- "@oh-my-pi/pi-catalog": "16.3.12",
64
- "@oh-my-pi/pi-mnemopi": "16.3.12",
65
- "@oh-my-pi/pi-natives": "16.3.12",
66
- "@oh-my-pi/pi-tui": "16.3.12",
67
- "@oh-my-pi/pi-utils": "16.3.12",
68
- "@oh-my-pi/pi-wire": "16.3.12",
69
- "@oh-my-pi/snapcompact": "16.3.12",
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",
@@ -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
  }