@oh-my-pi/pi-coding-agent 17.2.3 → 17.2.4

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 (92) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/{CHANGELOG-c5hpqt9r.md → CHANGELOG-bpmhv26t.md} +41 -0
  3. package/dist/cli.js +3270 -3270
  4. package/dist/types/advisor/advise-tool.d.ts +6 -0
  5. package/dist/types/advisor/runtime.d.ts +4 -4
  6. package/dist/types/capability/mcp.d.ts +9 -0
  7. package/dist/types/cli/models-cli.d.ts +1 -1
  8. package/dist/types/config/keybindings.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/discovery/helpers.d.ts +6 -0
  11. package/dist/types/discovery/omp-extension-roots.d.ts +21 -2
  12. package/dist/types/extensibility/extensions/loader.d.ts +6 -2
  13. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +2 -2
  14. package/dist/types/launch/paths.d.ts +2 -1
  15. package/dist/types/mcp/request-id.d.ts +19 -0
  16. package/dist/types/mcp/types.d.ts +14 -0
  17. package/dist/types/memory-backend/index.d.ts +1 -0
  18. package/dist/types/memory-backend/messages.d.ts +15 -0
  19. package/dist/types/modes/components/model-browser.d.ts +7 -5
  20. package/dist/types/modes/components/model-picker.d.ts +9 -3
  21. package/dist/types/modes/components/read-tool-group.d.ts +9 -0
  22. package/dist/types/modes/types.d.ts +1 -1
  23. package/dist/types/registry/agent-lifecycle.d.ts +14 -5
  24. package/dist/types/registry/agent-registry.d.ts +4 -3
  25. package/dist/types/sdk.d.ts +1 -1
  26. package/dist/types/secrets/index.d.ts +6 -8
  27. package/dist/types/session/agent-session.d.ts +6 -1
  28. package/dist/types/session/model-controls.d.ts +0 -1
  29. package/dist/types/session/session-advisors.d.ts +7 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +11 -5
  32. package/dist/types/system-prompt.d.ts +2 -0
  33. package/dist/types/tools/default-renderer.d.ts +3 -0
  34. package/package.json +12 -12
  35. package/scripts/legacy-pi-virtual-module.ts +12 -2
  36. package/src/advisor/advise-tool.ts +18 -0
  37. package/src/advisor/runtime.ts +6 -6
  38. package/src/capability/mcp.ts +7 -0
  39. package/src/cli/models-cli.ts +13 -16
  40. package/src/collab/host.ts +1 -1
  41. package/src/config/keybindings.ts +14 -9
  42. package/src/config/mcp-schema.json +5 -0
  43. package/src/config/model-registry.ts +7 -1
  44. package/src/config/settings-schema.ts +1 -1
  45. package/src/discovery/builtin.ts +10 -0
  46. package/src/discovery/helpers.ts +10 -0
  47. package/src/discovery/mcp-json.ts +11 -1
  48. package/src/discovery/omp-extension-roots.ts +79 -20
  49. package/src/discovery/omp-plugins.ts +10 -1
  50. package/src/extensibility/extensions/loader.ts +66 -28
  51. package/src/extensibility/plugins/marketplace/registry.ts +4 -8
  52. package/src/launch/paths.ts +2 -5
  53. package/src/main.ts +24 -22
  54. package/src/mcp/config.ts +1 -0
  55. package/src/mcp/request-id.ts +24 -0
  56. package/src/mcp/transports/http.ts +4 -2
  57. package/src/mcp/transports/sse.ts +4 -2
  58. package/src/mcp/transports/stdio.ts +4 -2
  59. package/src/mcp/types.ts +15 -0
  60. package/src/memory-backend/index.ts +1 -0
  61. package/src/memory-backend/messages.ts +19 -0
  62. package/src/modes/components/agent-hub.ts +1 -1
  63. package/src/modes/components/custom-editor.ts +1 -1
  64. package/src/modes/components/model-browser.ts +25 -17
  65. package/src/modes/components/model-picker.ts +10 -6
  66. package/src/modes/components/read-tool-group.ts +26 -0
  67. package/src/modes/components/tool-execution.ts +77 -20
  68. package/src/modes/controllers/command-controller.ts +2 -2
  69. package/src/modes/controllers/event-controller.ts +182 -10
  70. package/src/modes/controllers/selector-controller.ts +27 -6
  71. package/src/modes/types.ts +5 -1
  72. package/src/prompts/tools/bash.md +1 -1
  73. package/src/registry/agent-lifecycle.ts +36 -11
  74. package/src/registry/agent-registry.ts +12 -5
  75. package/src/sdk.ts +68 -29
  76. package/src/secrets/index.ts +15 -17
  77. package/src/session/agent-session.ts +10 -1
  78. package/src/session/agent-storage.ts +6 -4
  79. package/src/session/history-storage.ts +4 -2
  80. package/src/session/messages.ts +31 -7
  81. package/src/session/model-controls.ts +0 -1
  82. package/src/session/session-advisors.ts +19 -1
  83. package/src/session/session-maintenance.ts +3 -0
  84. package/src/session/session-manager.ts +128 -76
  85. package/src/session/session-tools.ts +25 -23
  86. package/src/slash-commands/builtin-registry.ts +2 -2
  87. package/src/system-prompt.ts +6 -1
  88. package/src/task/executor.ts +8 -1
  89. package/src/tiny/text.ts +12 -0
  90. package/src/tools/default-renderer.ts +19 -4
  91. package/src/tools/index.ts +10 -6
  92. package/src/web/search/providers/anthropic.ts +3 -1
@@ -2,7 +2,7 @@
2
2
  * Registry read/write operations for the marketplace plugin system.
3
3
  *
4
4
  * Two registries:
5
- * - marketplaces.json under getConfigRootDir() — which catalogs the user has added
5
+ * - marketplaces.json at getMarketplacesRegistryPath() — which catalogs the user has added
6
6
  * - installed_plugins.json under getPluginsDir() — which plugins are installed
7
7
  *
8
8
  * Read/write functions accept explicit file paths so callers control the
@@ -15,7 +15,9 @@
15
15
  import * as fs from "node:fs/promises";
16
16
  import * as path from "node:path";
17
17
 
18
- import { getConfigRootDir, getPluginsDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
18
+ import { getPluginsDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
19
+
20
+ export { getMarketplacesRegistryPath } from "@oh-my-pi/pi-utils";
19
21
 
20
22
  import type {
21
23
  InstalledPluginEntry,
@@ -24,12 +26,6 @@ import type {
24
26
  MarketplacesRegistry,
25
27
  } from "./types";
26
28
 
27
- // ── Path helpers ─────────────────────────────────────────────────────
28
-
29
- export function getMarketplacesRegistryPath(): string {
30
- return path.join(getConfigRootDir(), "marketplaces.json");
31
- }
32
-
33
29
  export function getInstalledPluginsRegistryPath(): string {
34
30
  return path.join(getPluginsDir(), "installed_plugins.json");
35
31
  }
@@ -1,11 +1,8 @@
1
1
  import * as path from "node:path";
2
- import { getConfigRootDir } from "@oh-my-pi/pi-utils";
2
+ import { getDaemonRuntimeDir } from "@oh-my-pi/pi-utils";
3
3
 
4
4
  /** Resolve the private runtime directory shared by omp processes in one project directory. */
5
- export function daemonRuntimeDir(projectDir: string, configRoot: string = getConfigRootDir()): string {
6
- const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
7
- return path.join(configRoot, "run", "daemons", key);
8
- }
5
+ export { getDaemonRuntimeDir as daemonRuntimeDir };
9
6
 
10
7
  /** Resolve the Unix socket or Windows named pipe used by one project broker. */
11
8
  export function daemonBrokerEndpoint(projectDir: string, runtimeDir: string): string {
package/src/main.ts CHANGED
@@ -1110,14 +1110,13 @@ export async function buildSessionOptions(
1110
1110
  }
1111
1111
 
1112
1112
  // Additional extension paths from CLI
1113
- const cliExtensionPaths = parsed.noExtensions ? [] : [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])];
1113
+ const cliExtensionPaths = [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])];
1114
1114
  if (cliExtensionPaths.length > 0) {
1115
1115
  options.additionalExtensionPaths = cliExtensionPaths;
1116
1116
  }
1117
1117
 
1118
1118
  if (parsed.noExtensions) {
1119
1119
  options.disableExtensionDiscovery = true;
1120
- options.additionalExtensionPaths = [];
1121
1120
  }
1122
1121
 
1123
1122
  return options;
@@ -1151,10 +1150,6 @@ export async function runRootCommand(
1151
1150
 
1152
1151
  const notifs: (InteractiveModeNotify | null)[] = [];
1153
1152
 
1154
- // Create AuthStorage and ModelRegistry upfront
1155
- const authStorage = await logger.time("discoverAuthStorage", deps.discoverAuthStorage ?? discoverAuthStorage);
1156
- const modelRegistry = logger.time("modelRegistry:init", () => new ModelRegistry(authStorage));
1157
-
1158
1153
  if (parsedArgs.version) {
1159
1154
  writeStartupNotice(parsedArgs, `${VERSION}\n`);
1160
1155
  process.exit(0);
@@ -1197,15 +1192,31 @@ export async function runRootCommand(
1197
1192
  // Register CLI-provided extension package paths (`--extension`, `--hook`) so
1198
1193
  // the `omp-plugins` discovery provider can surface their `skills/`, `hooks/`,
1199
1194
  // `tools/`, `commands/`, `rules/`, `prompts/`, and `.mcp.json` sub-trees.
1200
- // `--no-extensions` short-circuits both the factory load and the sub-discovery.
1201
- if (!parsedArgs.noExtensions) {
1202
- const cliExtensions = [...(parsedArgs.extensions ?? []), ...(parsedArgs.hooks ?? [])];
1203
- if (cliExtensions.length > 0) {
1204
- injectOmpExtensionCliRoots(cliExtensions, home, getProjectDir());
1205
- }
1206
- }
1195
+ // Explicit roots remain authorized under `--no-extensions`; only ambient
1196
+ // extension discovery is disabled.
1197
+ const cliExtensions = [...(parsedArgs.extensions ?? []), ...(parsedArgs.hooks ?? [])];
1198
+ injectOmpExtensionCliRoots(cliExtensions, home, getProjectDir(), {
1199
+ mode: parsedArgs.noExtensions ? "explicit-only" : "merge",
1200
+ replace: true,
1201
+ });
1207
1202
 
1208
1203
  let cwd = getProjectDir();
1204
+ // Classify the host before opening auth or settings storage so every
1205
+ // session-critical database connection picks the right busy timeout.
1206
+ // See getDbBusyTimeoutMs().
1207
+ const isProtocolMode = mode === "rpc" || mode === "rpc-ui" || mode === "acp";
1208
+ // Protocol modes own stdin; treating it as prompt text would consume JSON-RPC frames before their transports start.
1209
+ const pipedInput = isProtocolMode ? undefined : await logger.time("readPipedInput", readPipedInput);
1210
+ const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
1211
+ const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
1212
+ // Only the interactive host renders a focusable Agent Hub / subagent session
1213
+ // tree; declare it so headless subagent optimizations (e.g. skipping replan
1214
+ // title refresh) can tell a focusable process from a print/RPC/eval one.
1215
+ setInteractiveHost(isInteractive);
1216
+ // Create AuthStorage and ModelRegistry upfront
1217
+ const authStorage = await logger.time("discoverAuthStorage", deps.discoverAuthStorage ?? discoverAuthStorage);
1218
+ const modelRegistry = logger.time("modelRegistry:init", () => new ModelRegistry(authStorage));
1219
+
1209
1220
  const settingsInstance =
1210
1221
  deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd, configFiles: parsedArgs.config }));
1211
1222
  if (parsedArgs.approvalMode) {
@@ -1228,15 +1239,6 @@ export async function runRootCommand(
1228
1239
  if (parsedArgs.noTitle || parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") {
1229
1240
  Bun.env.PI_NO_TITLE = "1";
1230
1241
  }
1231
- const isProtocolMode = mode === "rpc" || mode === "rpc-ui" || mode === "acp";
1232
- // Protocol modes own stdin; treating it as prompt text would consume JSON-RPC frames before their transports start.
1233
- const pipedInput = isProtocolMode ? undefined : await logger.time("readPipedInput", readPipedInput);
1234
- const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
1235
- const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
1236
- // Only the interactive host renders a focusable Agent Hub / subagent session
1237
- // tree; declare it so headless subagent optimizations (e.g. skipping replan
1238
- // title refresh) can tell a focusable process from a print/RPC/eval one.
1239
- setInteractiveHost(isInteractive);
1240
1242
 
1241
1243
  // Initialize discovery system with settings for provider persistence
1242
1244
  logger.time("initializeWithSettings", initializeWithSettings, settingsInstance);
package/src/mcp/config.ts CHANGED
@@ -41,6 +41,7 @@ function convertToLegacyConfig(server: MCPServer): MCPServerConfig {
41
41
  const shared = {
42
42
  enabled: server.enabled,
43
43
  timeout: server.timeout,
44
+ requestIdFormat: server.requestIdFormat,
44
45
  auth: server.auth,
45
46
  oauth: server.oauth,
46
47
  };
@@ -0,0 +1,24 @@
1
+ import { Snowflake } from "@oh-my-pi/pi-utils";
2
+ import type { MCPRequestIdFormat } from "./types";
3
+
4
+ /**
5
+ * Per-transport allocator for outgoing JSON-RPC request ids.
6
+ *
7
+ * JSON-RPC 2.0 permits String and Number ids equally, but the wider MCP
8
+ * ecosystem (reference SDKs included) issues incrementing integers, so servers
9
+ * are best tested against numbers — Apple's `xcrun mcpbridge` even logs
10
+ * `mcpbridge.DecodeError Code=1` and never answers a string id. Integers are
11
+ * therefore the default. `"string"` opts a server back into collision-resistant
12
+ * snowflake ids.
13
+ *
14
+ * The counter is per instance and never reset, so ids stay unique for the life
15
+ * of the transport even though a reconnect rejects and clears every pending
16
+ * request.
17
+ */
18
+ export class RequestIdAllocator {
19
+ #previousNumeric = 0;
20
+
21
+ next(format: MCPRequestIdFormat | undefined): string | number {
22
+ return format === "string" ? Snowflake.next() : ++this.#previousNumeric;
23
+ }
24
+ }
@@ -5,7 +5,7 @@
5
5
  * Based on MCP spec 2025-03-26.
6
6
  */
7
7
  import * as AIError from "@oh-my-pi/pi-ai/error";
8
- import { logger, readSseJson, Snowflake } from "@oh-my-pi/pi-utils";
8
+ import { logger, readSseJson } from "@oh-my-pi/pi-utils";
9
9
  import type {
10
10
  JsonRpcError,
11
11
  JsonRpcMessage,
@@ -17,6 +17,7 @@ import type {
17
17
  MCPTransport,
18
18
  } from "../../mcp/types";
19
19
  import { toJsonRpcError } from "../../mcp/types";
20
+ import { RequestIdAllocator } from "../request-id";
20
21
  import { createMCPTimeout, getNeverAbortSignal, isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
21
22
 
22
23
  const HTTP_SSE_CONNECT_TIMEOUT_MS = 1_000;
@@ -42,6 +43,7 @@ export class HttpTransport implements MCPTransport {
42
43
  #connected = false;
43
44
  #sessionId: string | null = null;
44
45
  #sseConnection: AbortController | null = null;
46
+ readonly #requestIds = new RequestIdAllocator();
45
47
 
46
48
  onClose?: () => void;
47
49
  onError?: (error: Error) => void;
@@ -209,7 +211,7 @@ export class HttpTransport implements MCPTransport {
209
211
  throw new Error("Transport not connected");
210
212
  }
211
213
 
212
- const id = Snowflake.next();
214
+ const id = this.#requestIds.next(this.config.requestIdFormat);
213
215
  const body = {
214
216
  jsonrpc: "2.0" as const,
215
217
  id,
@@ -1,5 +1,5 @@
1
1
  import * as AIError from "@oh-my-pi/pi-ai/error";
2
- import { logger, readSseEvents, Snowflake } from "@oh-my-pi/pi-utils";
2
+ import { logger, readSseEvents } from "@oh-my-pi/pi-utils";
3
3
  import type {
4
4
  JsonRpcError,
5
5
  JsonRpcMessage,
@@ -10,6 +10,7 @@ import type {
10
10
  MCPTransport,
11
11
  } from "../../mcp/types";
12
12
  import { toJsonRpcError } from "../../mcp/types";
13
+ import { RequestIdAllocator } from "../request-id";
13
14
  import { createMCPTimeout, getNeverAbortSignal, resolveMCPTimeoutMs } from "../timeout";
14
15
 
15
16
  interface MCPTimeoutOperation {
@@ -32,6 +33,7 @@ export class LegacySseTransport implements MCPTransport {
32
33
  #sseConnection: AbortController | null = null;
33
34
  #pending = new Map<string | number, PendingLegacySseRequest>();
34
35
  #config: MCPSseServerConfig;
36
+ readonly #requestIds = new RequestIdAllocator();
35
37
 
36
38
  onClose?: () => void;
37
39
  onError?: (error: Error) => void;
@@ -194,7 +196,7 @@ export class LegacySseTransport implements MCPTransport {
194
196
  throw new Error("Transport not connected");
195
197
  }
196
198
 
197
- const id = Snowflake.next();
199
+ const id = this.#requestIds.next(this.#config.requestIdFormat);
198
200
  const body = {
199
201
  jsonrpc: "2.0" as const,
200
202
  id,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import * as fs from "node:fs/promises";
9
9
  import * as path from "node:path";
10
- import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
10
+ import { getProjectDir, readJsonl } from "@oh-my-pi/pi-utils";
11
11
  import type { Subprocess } from "bun";
12
12
  import { hostHasInheritableConsole } from "../../eval/py/spawn-options";
13
13
  import type {
@@ -20,6 +20,7 @@ import type {
20
20
  MCPTransport,
21
21
  } from "../../mcp/types";
22
22
  import { toJsonRpcError } from "../../mcp/types";
23
+ import { RequestIdAllocator } from "../request-id";
23
24
  import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
24
25
 
25
26
  /** Subprocess argv and platform-derived spawn flags for an MCP stdio server. */
@@ -551,6 +552,7 @@ export class StdioTransport implements MCPTransport {
551
552
  * actually spawned into its own session may target it.
552
553
  */
553
554
  #detached = false;
555
+ readonly #requestIds = new RequestIdAllocator();
554
556
 
555
557
  onClose?: () => void;
556
558
  onError?: (error: Error) => void;
@@ -734,7 +736,7 @@ export class StdioTransport implements MCPTransport {
734
736
  throw new Error("Transport not connected");
735
737
  }
736
738
 
737
- const id = Snowflake.next();
739
+ const id = this.#requestIds.next(this.config.requestIdFormat);
738
740
  const request = {
739
741
  jsonrpc: "2.0" as const,
740
742
  id,
package/src/mcp/types.ts CHANGED
@@ -59,12 +59,27 @@ export interface MCPAuthConfig {
59
59
  resource?: string;
60
60
  }
61
61
 
62
+ /** Encoding used for outgoing JSON-RPC request ids. */
63
+ export type MCPRequestIdFormat = "string" | "number";
64
+
62
65
  /** Base server config with shared options */
63
66
  interface MCPServerConfigBase {
64
67
  /** Whether this server is enabled (default: true) */
65
68
  enabled?: boolean;
66
69
  /** MCP request timeout in milliseconds (default: 30000, 0 to disable) */
67
70
  timeout?: number;
71
+ /**
72
+ * Encoding for outgoing JSON-RPC request ids (default: `"number"`).
73
+ *
74
+ * Set `"string"` for servers that need collision-resistant snowflake string
75
+ * ids instead of per-transport integers. See `RequestIdAllocator` in
76
+ * `./request-id`.
77
+ *
78
+ * OMP-specific, so only the OMP-owned discovery providers parse it (native,
79
+ * standalone `mcp.json`, OMP plugins). Providers that translate another
80
+ * tool's config do not, since the key is not part of those formats.
81
+ */
82
+ requestIdFormat?: MCPRequestIdFormat;
68
83
  /** Authentication configuration (optional) */
69
84
  auth?: MCPAuthConfig;
70
85
  /** OAuth configuration for servers requiring explicit client credentials */
@@ -12,6 +12,7 @@ export type {
12
12
  MnemopiSessionStateOptions,
13
13
  } from "../mnemopi/state";
14
14
  export * from "./local-backend";
15
+ export * from "./messages";
15
16
  export * from "./off-backend";
16
17
  export * from "./resolve";
17
18
  export * from "./runtime";
@@ -0,0 +1,19 @@
1
+ import type { MemoryBackendId } from "./types";
2
+
3
+ /**
4
+ * Fallback text for `/memory stats` and `/memory diagnose` when the active
5
+ * backend's hook is missing or returns nothing.
6
+ *
7
+ * The `off` backend isn't a real backend a user picked among several
8
+ * stats-capable options — it's the no-op state memory falls back to by
9
+ * default — so it gets its own message instead of the generic
10
+ * "not available for the X backend" template, which would otherwise read as
11
+ * the self-contradictory "not available for the off backend".
12
+ *
13
+ * Shared by both the TUI (`CommandController.handleMemoryCommand`) and the
14
+ * ACP/RPC slash-command handler so the two surfaces stay consistent.
15
+ */
16
+ export function memoryStatsUnavailableMessage(backendId: MemoryBackendId, action: "stats" | "diagnose"): string {
17
+ if (backendId === "off") return "Memory backend is off — there is nothing to show.";
18
+ return `Memory ${action} is not available for the ${backendId} backend.`;
19
+ }
@@ -636,7 +636,7 @@ export class AgentHubOverlayComponent extends Container {
636
636
  if (ref.status === "running" && ref.session) {
637
637
  await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
638
638
  }
639
- await this.#lifecycle().release(ref.id, ref);
639
+ await this.#lifecycle().release(ref.id, ref, { tombstone: true });
640
640
  } catch (error) {
641
641
  logger.warn("Agent hub: kill failed", { id: ref.id, error: String(error) });
642
642
  this.#notice = error instanceof Error ? error.message : String(error);
@@ -56,7 +56,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
56
56
  "app.thinking.toggle": ["ctrl+t"],
57
57
  "app.editor.external": ["ctrl+g"],
58
58
  "app.history.search": ["ctrl+r"],
59
- "app.message.dequeue": ["alt+up"],
59
+ "app.message.dequeue": ["alt+up", "shift+up"],
60
60
  "app.retry": ["alt+r"],
61
61
  "app.clipboard.pasteImage": ["ctrl+v"],
62
62
  "app.clipboard.pasteTextRaw": ["ctrl+shift+v", "alt+shift+v"],
@@ -314,10 +314,10 @@ function padLeftVisible(text: string, width: number): string {
314
314
  export interface ModelBrowserOptions {
315
315
  /** Render the dim `provider/` prefix before model ids. Default true. */
316
316
  showProvider?: boolean;
317
- /** Session token count used to disable models whose context window is exceeded. */
317
+ /** Session token count used to flag models whose context window is exceeded. */
318
318
  currentContextTokens?: number;
319
- /** When true, rows over the current context are unselectable (session-switch mode). */
320
- disableOverContext?: boolean;
319
+ /** When true, over-context rows render grayed; picking one compacts first (session-switch mode). */
320
+ markOverContext?: boolean;
321
321
  /** Host-provided empty-state text (e.g. provider discovery status). */
322
322
  emptyText?: () => string | undefined;
323
323
  }
@@ -351,7 +351,7 @@ export class ModelBrowser implements Component {
351
351
  #maxVisible = 10;
352
352
  #showProvider: boolean;
353
353
  #currentContextTokens: number;
354
- #disableOverContext: boolean;
354
+ #markOverContext: boolean;
355
355
  #emptyText?: () => string | undefined;
356
356
  /** Keep role-like virtual rows in their host-defined order during search. */
357
357
  #preserveQueryOrder = false;
@@ -375,7 +375,7 @@ export class ModelBrowser implements Component {
375
375
  this.#showProvider = options.showProvider ?? true;
376
376
  const tokens = options.currentContextTokens ?? 0;
377
377
  this.#currentContextTokens = Number.isFinite(tokens) && tokens > 0 ? Math.floor(tokens) : 0;
378
- this.#disableOverContext = options.disableOverContext ?? false;
378
+ this.#markOverContext = options.markOverContext ?? false;
379
379
  this.#emptyText = options.emptyText;
380
380
  }
381
381
 
@@ -420,9 +420,9 @@ export class ModelBrowser implements Component {
420
420
  setPreserveQueryOrder(preserve: boolean): void {
421
421
  this.#preserveQueryOrder = preserve;
422
422
  }
423
- /** Allow hosts to toggle context-window eligibility between browser modes. */
424
- setDisableOverContext(disable: boolean): void {
425
- this.#disableOverContext = disable;
423
+ /** Allow hosts to toggle context-window flagging between browser modes. */
424
+ setMarkOverContext(mark: boolean): void {
425
+ this.#markOverContext = mark;
426
426
  }
427
427
  /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
428
428
  setFocused(focused: boolean): void {
@@ -461,8 +461,13 @@ export class ModelBrowser implements Component {
461
461
  }
462
462
 
463
463
  #isDisabled(item: ModelBrowserItem): boolean {
464
- if (item.id === "separator") return true;
465
- if (!this.#disableOverContext || this.#currentContextTokens <= 0) return false;
464
+ return item.id === "separator";
465
+ }
466
+
467
+ /** True when `item`'s context window is smaller than the live session token count (grayed row; hosts compact before switching). */
468
+ isOverContext(item: ModelBrowserItem): boolean {
469
+ if (item.id === "separator") return false;
470
+ if (!this.#markOverContext || this.#currentContextTokens <= 0) return false;
466
471
  const contextWindow = item.model.contextWindow ?? 0;
467
472
  return contextWindow > 0 && this.#currentContextTokens > contextWindow;
468
473
  }
@@ -718,7 +723,7 @@ export class ModelBrowser implements Component {
718
723
  const line = theme.fg("muted", "─".repeat(dashCount));
719
724
  return ` ${line} `;
720
725
  }
721
- const disabled = this.#isDisabled(item);
726
+ const overContext = this.isOverContext(item);
722
727
  const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
723
728
  const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
724
729
  const name = item.labelColor
@@ -728,7 +733,7 @@ export class ModelBrowser implements Component {
728
733
  : item.id;
729
734
  const currentMark =
730
735
  item.selector === this.#currentSelector ? ` ${theme.fg("success", theme.status.enabled)}` : "";
731
- const overLimit = disabled
736
+ const overLimit = overContext
732
737
  ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
733
738
  : "";
734
739
  let left = `${prefix}${providerPrefix}${name}${currentMark}${overLimit}`;
@@ -743,12 +748,15 @@ export class ModelBrowser implements Component {
743
748
  const gap = Math.max(0, available - visibleWidth(left));
744
749
 
745
750
  let line = `${left}${" ".repeat(gap)} ${meta}`;
746
- if (disabled) {
747
- line = theme.fg("dim", Bun.stripANSI(line));
751
+ if (overContext) {
752
+ // Gray the whole row but keep the selection cursor visible: over-context
753
+ // models stay selectable (the host compacts before switching).
754
+ const plainPrefix = Bun.stripANSI(prefix);
755
+ line = `${prefix}${theme.fg("dim", Bun.stripANSI(line).slice(plainPrefix.length))}`;
748
756
  }
749
757
  // The bg band is reserved for the mouse: it marks hover, nothing else.
750
758
  // Keyboard selection is the cursor glyph + accent name.
751
- if (hovered && !disabled) {
759
+ if (hovered) {
752
760
  line = theme.bg("selectedBg", line);
753
761
  }
754
762
  return line;
@@ -772,8 +780,8 @@ export class ModelBrowser implements Component {
772
780
  }
773
781
  const line1 = truncateToWidth(theme.fg("muted", ` ${facts.join(" · ")}`), width);
774
782
 
775
- if (this.#isDisabled(selected)) {
776
- const warning = ` ${theme.status.disabled} current context ${formatNumber(this.#currentContextTokens).toLowerCase()} exceeds ${formatNumber(model.contextWindow ?? 0).toLowerCase()} limit`;
783
+ if (this.isOverContext(selected)) {
784
+ const warning = ` ${theme.status.disabled} context ${formatNumber(this.#currentContextTokens).toLowerCase()} exceeds ${formatNumber(model.contextWindow ?? 0).toLowerCase()} limit · compacts with current model, then switches`;
777
785
  return [line1, truncateToWidth(theme.fg("warning", warning), width)];
778
786
  }
779
787
 
@@ -22,8 +22,12 @@ import { bottomBorder, row, topBorder } from "./overlay-box";
22
22
  import { resolveSegmentPalette } from "./segment-track";
23
23
 
24
24
  export interface ModelPickerCallbacks {
25
- /** A model was chosen for a session-only switch. `selector` is `provider/id`. */
26
- onPick: (model: Model, selector: string) => void;
25
+ /**
26
+ * A model was chosen for a session-only switch. `selector` is `provider/id`.
27
+ * `overContext` is true when the session transcript exceeds the model's
28
+ * context window — the host must compact before switching.
29
+ */
30
+ onPick: (model: Model, selector: string, meta: { overContext: boolean }) => void;
27
31
  /** A configured ctrl+p quick role was chosen. */
28
32
  onPickRole?: (entry: ResolvedRoleModel) => void;
29
33
  /** The picker was dismissed. */
@@ -31,7 +35,7 @@ export interface ModelPickerCallbacks {
31
35
  }
32
36
 
33
37
  export interface ModelPickerOptions {
34
- /** Session token count; models with smaller context windows are disabled. */
38
+ /** Session token count; models with smaller context windows are grayed and compact-first on pick. */
35
39
  currentContextTokens?: number;
36
40
  /** `provider/id` of the session's active model; highlighted and preselected. */
37
41
  currentSelector?: string;
@@ -97,7 +101,7 @@ export class ModelPickerComponent implements Component {
97
101
 
98
102
  this.#browser = new ModelBrowser(settings, {
99
103
  currentContextTokens: options.currentContextTokens,
100
- disableOverContext: true,
104
+ markOverContext: true,
101
105
  emptyText: () => (this.#roleMode ? " No quick roles in the Ctrl+P cycle" : undefined),
102
106
  });
103
107
  this.#browser.onActivate = item => {
@@ -106,7 +110,7 @@ export class ModelPickerComponent implements Component {
106
110
  callbacks.onPickRole?.(quickRole);
107
111
  return;
108
112
  }
109
- callbacks.onPick(item.model, item.selector);
113
+ callbacks.onPick(item.model, item.selector, { overContext: this.#browser.isOverContext(item) });
110
114
  };
111
115
  this.#browser.onCancel = () => callbacks.onCancel();
112
116
  this.#browser.onQueryChange = query => this.#syncItemsForQuery(query);
@@ -193,7 +197,7 @@ export class ModelPickerComponent implements Component {
193
197
 
194
198
  this.#roleMode = roleMode;
195
199
  this.#browser.setShowProvider(!roleMode);
196
- this.#browser.setDisableOverContext(!roleMode);
200
+ this.#browser.setMarkOverContext(!roleMode);
197
201
  this.#browser.setPreserveQueryOrder(roleMode);
198
202
  const currentSelector = roleMode ? this.#currentQuickRoleSelector : this.#currentSelector;
199
203
  this.#browser.setCurrentSelector(currentSelector);
@@ -397,6 +397,32 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
397
397
  this.#updateDisplay();
398
398
  }
399
399
 
400
+ /**
401
+ * Re-key an entry whose streamed tool-call id changed mid-stream (a provider
402
+ * rewriting the id across deltas; see EventController's
403
+ * `#streamedToolCallIdByIndex`). Preserves row order so a sibling read run is
404
+ * not visibly reshuffled, and no-ops when the rename would collide.
405
+ */
406
+ renameEntry(oldId: string, newId: string): void {
407
+ if (oldId === newId || !newId) return;
408
+ const entry = this.#entries.get(oldId);
409
+ if (!entry || this.#entries.has(newId)) return;
410
+ entry.toolCallId = newId;
411
+ const reordered = [...this.#entries].map(([key, value]): [string, ReadEntry] => [
412
+ key === oldId ? newId : key,
413
+ value,
414
+ ]);
415
+ this.#entries.clear();
416
+ for (const [key, value] of reordered) this.#entries.set(key, value);
417
+ this.#updateDisplay();
418
+ }
419
+ /** Remove one call without discarding successful siblings in the shared group. */
420
+ removeEntry(toolCallId: string): boolean {
421
+ if (!this.#entries.delete(toolCallId)) return this.#entries.size === 0;
422
+ this.#updateDisplay();
423
+ return this.#entries.size === 0;
424
+ }
425
+
400
426
  updateResult(
401
427
  result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
402
428
  isPartial = false,