@oh-my-pi/pi-coding-agent 17.2.2 → 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 (104) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/{CHANGELOG-xfpkakrn.md → CHANGELOG-bpmhv26t.md} +55 -0
  3. package/dist/cli.js +3294 -3294
  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/ask-dialog.d.ts +0 -1
  20. package/dist/types/modes/components/model-browser.d.ts +7 -5
  21. package/dist/types/modes/components/model-picker.d.ts +9 -3
  22. package/dist/types/modes/components/read-tool-group.d.ts +9 -0
  23. package/dist/types/modes/types.d.ts +1 -1
  24. package/dist/types/registry/agent-lifecycle.d.ts +14 -5
  25. package/dist/types/registry/agent-registry.d.ts +4 -3
  26. package/dist/types/sdk.d.ts +1 -1
  27. package/dist/types/secrets/index.d.ts +6 -8
  28. package/dist/types/session/agent-session.d.ts +6 -1
  29. package/dist/types/session/model-controls.d.ts +0 -1
  30. package/dist/types/session/session-advisors.d.ts +7 -0
  31. package/dist/types/session/session-maintenance.d.ts +1 -0
  32. package/dist/types/session/session-manager.d.ts +11 -5
  33. package/dist/types/system-prompt.d.ts +2 -0
  34. package/dist/types/tools/browser/launch.d.ts +30 -0
  35. package/dist/types/tools/browser/registry.d.ts +6 -1
  36. package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
  37. package/dist/types/tools/default-renderer.d.ts +3 -0
  38. package/package.json +12 -12
  39. package/scripts/legacy-pi-virtual-module.ts +12 -2
  40. package/src/advisor/advise-tool.ts +18 -0
  41. package/src/advisor/runtime.ts +6 -6
  42. package/src/capability/mcp.ts +7 -0
  43. package/src/cli/models-cli.ts +13 -16
  44. package/src/collab/host.ts +1 -1
  45. package/src/config/keybindings.ts +14 -9
  46. package/src/config/mcp-schema.json +5 -0
  47. package/src/config/model-registry.ts +7 -1
  48. package/src/config/settings-schema.ts +1 -1
  49. package/src/discovery/builtin.ts +10 -0
  50. package/src/discovery/helpers.ts +10 -0
  51. package/src/discovery/mcp-json.ts +11 -1
  52. package/src/discovery/omp-extension-roots.ts +79 -20
  53. package/src/discovery/omp-plugins.ts +10 -1
  54. package/src/extensibility/extensions/loader.ts +66 -28
  55. package/src/extensibility/plugins/marketplace/registry.ts +4 -8
  56. package/src/launch/broker.ts +59 -41
  57. package/src/launch/paths.ts +2 -5
  58. package/src/main.ts +24 -22
  59. package/src/mcp/config.ts +1 -0
  60. package/src/mcp/request-id.ts +24 -0
  61. package/src/mcp/transports/http.ts +4 -2
  62. package/src/mcp/transports/sse.ts +4 -2
  63. package/src/mcp/transports/stdio.ts +4 -2
  64. package/src/mcp/types.ts +15 -0
  65. package/src/memory-backend/index.ts +1 -0
  66. package/src/memory-backend/messages.ts +19 -0
  67. package/src/modes/components/agent-hub.ts +1 -1
  68. package/src/modes/components/ask-dialog.ts +65 -23
  69. package/src/modes/components/custom-editor.ts +1 -1
  70. package/src/modes/components/model-browser.ts +25 -17
  71. package/src/modes/components/model-picker.ts +10 -6
  72. package/src/modes/components/read-tool-group.ts +26 -0
  73. package/src/modes/components/tool-execution.ts +77 -20
  74. package/src/modes/controllers/command-controller.ts +2 -2
  75. package/src/modes/controllers/event-controller.ts +182 -10
  76. package/src/modes/controllers/selector-controller.ts +27 -6
  77. package/src/modes/types.ts +5 -1
  78. package/src/prompts/tools/bash.md +1 -1
  79. package/src/registry/agent-lifecycle.ts +36 -11
  80. package/src/registry/agent-registry.ts +12 -5
  81. package/src/sdk.ts +68 -29
  82. package/src/secrets/index.ts +15 -17
  83. package/src/session/agent-session.ts +11 -2
  84. package/src/session/agent-storage.ts +6 -4
  85. package/src/session/history-storage.ts +4 -2
  86. package/src/session/messages.ts +31 -7
  87. package/src/session/model-controls.ts +0 -1
  88. package/src/session/session-advisors.ts +19 -1
  89. package/src/session/session-maintenance.ts +5 -2
  90. package/src/session/session-manager.ts +128 -76
  91. package/src/session/session-tools.ts +25 -23
  92. package/src/slash-commands/builtin-registry.ts +2 -2
  93. package/src/system-prompt.ts +6 -1
  94. package/src/task/executor.ts +8 -1
  95. package/src/tiny/text.ts +12 -0
  96. package/src/tools/bash.ts +11 -5
  97. package/src/tools/browser/launch.ts +54 -9
  98. package/src/tools/browser/registry.ts +123 -30
  99. package/src/tools/browser/shared-daemon.ts +190 -0
  100. package/src/tools/browser.ts +1 -1
  101. package/src/tools/default-renderer.ts +19 -4
  102. package/src/tools/index.ts +10 -6
  103. package/src/web/search/providers/anthropic.ts +3 -1
  104. package/src/web/search/providers/codex.ts +31 -6
@@ -509,6 +509,26 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
509
509
 
510
510
  return discovered;
511
511
  }
512
+ async function discoverHooksInPackageRoot(root: string): Promise<string[]> {
513
+ const hooks: string[] = [];
514
+ for (const hookType of ["pre", "post"]) {
515
+ const hookDir = path.join(root, "hooks", hookType);
516
+ let entries: fs1.Dirent[];
517
+ try {
518
+ entries = await fs.readdir(hookDir, { withFileTypes: true });
519
+ } catch (err) {
520
+ if (isEnoent(err) || isEacces(err) || hasFsCode(err, "ENOTDIR") || hasFsCode(err, "EPERM")) continue;
521
+ throw err;
522
+ }
523
+ for (const entry of entries) {
524
+ if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
525
+ hooks.push(path.join(hookDir, entry.name));
526
+ }
527
+ }
528
+ }
529
+ return hooks;
530
+ }
531
+
512
532
  /**
513
533
  * Discover absolute paths of extensions to load, without importing or
514
534
  * binding factories. Hot path on session startup — the scan walks native
@@ -522,10 +542,16 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
522
542
  * `LoadExtensionsResult` directly would reuse handlers/tools/commands that
523
543
  * closed over the parent's `cwd` and event bus.
524
544
  */
545
+ export interface DiscoverExtensionPathOptions {
546
+ /** Include ambient native extensions, hooks, and installed plugins. */
547
+ ambient?: boolean;
548
+ }
549
+
525
550
  export async function discoverExtensionPaths(
526
551
  configuredPaths: string[],
527
552
  cwd: string,
528
553
  disabledExtensionIds?: string[],
554
+ options: DiscoverExtensionPathOptions = {},
529
555
  ): Promise<string[]> {
530
556
  const allPaths: string[] = [];
531
557
  const seen = new Set<string>();
@@ -549,33 +575,44 @@ export async function discoverExtensionPaths(
549
575
  }
550
576
  };
551
577
 
552
- // 1. Discover extension modules via capability API (native .omp/.pi only).
553
- // Scope the load to the native provider — the extension-module capability
554
- // also has claude/codex/gemini/opencode providers, and their items were
555
- // discarded here anyway (see #4198). The provider filter skips the walk
556
- // entirely instead of running four foreign directory scans and dropping
557
- // the results.
558
- const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, {
559
- ...loadOptions,
560
- providers: ["native"],
561
- });
562
- for (const ext of discovered.items) {
563
- addPath(ext.path);
564
- }
565
-
566
- // 2. Discover JS/TS hook factories from hookCapability and bind them through
567
- // the extension runner, which owns the current runtime event bus. Hook
568
- // capability loading already applies hook-specific disabled ids; do not also
569
- // filter them through extension-module names.
570
- const hooks = await loadCapability<Hook>(hookCapability.id, loadOptions);
571
- for (const hookPath of hooks.items
572
- .map(hook => hook.path)
573
- .filter(hookPath => isExtensionFile(path.basename(hookPath)))) {
574
- addPath(hookPath);
575
- }
576
-
577
- // 3. Discover extension entry points from installed plugins
578
- addPaths(await getAllPluginExtensionPaths(cwd));
578
+ const ambient = options.ambient !== false;
579
+ if (ambient) {
580
+ // 1. Discover extension modules via capability API (native .omp/.pi only).
581
+ // Scope the load to the native provider the extension-module capability
582
+ // also has claude/codex/gemini/opencode providers, and their items were
583
+ // discarded here anyway (see #4198). The provider filter skips the walk
584
+ // entirely instead of running four foreign directory scans and dropping
585
+ // the results.
586
+ const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, {
587
+ ...loadOptions,
588
+ providers: ["native"],
589
+ });
590
+ for (const ext of discovered.items) {
591
+ addPath(ext.path);
592
+ }
593
+ }
594
+
595
+ // 2. Discover JS/TS hook factories and bind them through the extension
596
+ // runner, which owns the current runtime event bus. Non-ambient discovery
597
+ // scans only this invocation's configured package roots; it must not consult
598
+ // settings, installed packages, or process-global CLI injection state.
599
+ if (ambient) {
600
+ const hooks = await loadCapability<Hook>(hookCapability.id, loadOptions);
601
+ for (const hookPath of hooks.items
602
+ .map(hook => hook.path)
603
+ .filter(hookPath => isExtensionFile(path.basename(hookPath)))) {
604
+ addPath(hookPath);
605
+ }
606
+ } else {
607
+ for (const configuredPath of configuredPaths) {
608
+ addPaths(await discoverHooksInPackageRoot(resolvePath(configuredPath, cwd)));
609
+ }
610
+ }
611
+
612
+ // 3. Discover extension entry points from installed plugins.
613
+ if (ambient) {
614
+ addPaths(await getAllPluginExtensionPaths(cwd));
615
+ }
579
616
 
580
617
  // 4. Explicitly configured paths
581
618
  for (const configuredPath of configuredPaths) {
@@ -618,7 +655,8 @@ export async function discoverAndLoadExtensions(
618
655
  cwd: string,
619
656
  eventBus?: EventBus,
620
657
  disabledExtensionIds?: string[],
658
+ options: DiscoverExtensionPathOptions = {},
621
659
  ): Promise<LoadExtensionsResult> {
622
- const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds);
660
+ const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds, options);
623
661
  return loadExtensions(paths, cwd, eventBus);
624
662
  }
@@ -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
  }
@@ -342,6 +342,15 @@ class DaemonBroker {
342
342
  readonly #token: string;
343
343
  readonly #idleGraceMs: number;
344
344
  readonly #records = new Map<string, ManagedDaemon>();
345
+ /**
346
+ * Names reserved by an in-flight `start` before its record lands in
347
+ * `#records`. Requests dispatch concurrently, and `#start` awaits (cwd stat,
348
+ * log open) between the duplicate check and the record insert; without a
349
+ * synchronous reservation two clients can both pass the check and spawn
350
+ * duplicate processes — one exits on a held resource (e.g. a Chromium
351
+ * profile lock) or keeps running untracked.
352
+ */
353
+ readonly #startingNames = new Set<string>();
345
354
  readonly #clients = new Set<net.Socket>();
346
355
  readonly #finished = Promise.withResolvers<void>();
347
356
  readonly #sockets = new Set<net.Socket>();
@@ -500,50 +509,59 @@ class DaemonBroker {
500
509
  ) {
501
510
  throw new Error('Windows batch files require application "cmd.exe" with the batch path after "/c"');
502
511
  }
503
- const existing = this.#records.get(spec.name);
504
- if (existing) await this.#refreshDetached(existing);
505
- if (existing && !terminalState(existing.snapshot.state)) {
506
- throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
512
+ if (this.#startingNames.has(spec.name)) {
513
+ throw new Error(`Daemon ${spec.name} is already starting`);
507
514
  }
508
- if (spec.ready?.log) {
509
- try {
510
- new RegExp(spec.ready.log, "u");
511
- } catch (error) {
512
- throw new Error(`Invalid readiness regex: ${error instanceof Error ? error.message : String(error)}`);
515
+ this.#startingNames.add(spec.name);
516
+ let record: ManagedDaemon;
517
+ try {
518
+ const existing = this.#records.get(spec.name);
519
+ if (existing) await this.#refreshDetached(existing);
520
+ if (existing && !terminalState(existing.snapshot.state)) {
521
+ throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
522
+ }
523
+ if (spec.ready?.log) {
524
+ try {
525
+ new RegExp(spec.ready.log, "u");
526
+ } catch (error) {
527
+ throw new Error(`Invalid readiness regex: ${error instanceof Error ? error.message : String(error)}`);
528
+ }
513
529
  }
530
+ const stat = await fs.stat(spec.cwd);
531
+ if (!stat.isDirectory()) throw new Error(`Daemon cwd is not a directory: ${spec.cwd}`);
532
+ const dir = path.join(this.#runtimeDir, "daemons", spec.name);
533
+ const now = Date.now();
534
+ record = {
535
+ spec,
536
+ snapshot: {
537
+ name: spec.name,
538
+ id: crypto.randomUUID(),
539
+ state: "starting",
540
+ createdAt: now,
541
+ startedAt: now,
542
+ restartCount: 0,
543
+ outputBytes: 0,
544
+ owner,
545
+ persist: spec.persist,
546
+ detached: spec.detached,
547
+ },
548
+ dir,
549
+ log: await DaemonLog.open(dir),
550
+ generation: 0,
551
+ stopRequested: false,
552
+ logReady: !spec.ready?.log,
553
+ portReady: spec.ready?.port === undefined,
554
+ readinessBuffer: "",
555
+ outputOffset: 0,
556
+ readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
557
+ consecutiveFailures: 0,
558
+ persistQueue: Promise.resolve(),
559
+ };
560
+ syncReadyPending(record);
561
+ this.#records.set(spec.name, record);
562
+ } finally {
563
+ this.#startingNames.delete(spec.name);
514
564
  }
515
- const stat = await fs.stat(spec.cwd);
516
- if (!stat.isDirectory()) throw new Error(`Daemon cwd is not a directory: ${spec.cwd}`);
517
- const dir = path.join(this.#runtimeDir, "daemons", spec.name);
518
- const now = Date.now();
519
- const record: ManagedDaemon = {
520
- spec,
521
- snapshot: {
522
- name: spec.name,
523
- id: crypto.randomUUID(),
524
- state: "starting",
525
- createdAt: now,
526
- startedAt: now,
527
- restartCount: 0,
528
- outputBytes: 0,
529
- owner,
530
- persist: spec.persist,
531
- detached: spec.detached,
532
- },
533
- dir,
534
- log: await DaemonLog.open(dir),
535
- generation: 0,
536
- stopRequested: false,
537
- logReady: !spec.ready?.log,
538
- portReady: spec.ready?.port === undefined,
539
- readinessBuffer: "",
540
- outputOffset: 0,
541
- readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
542
- consecutiveFailures: 0,
543
- persistQueue: Promise.resolve(),
544
- };
545
- syncReadyPending(record);
546
- this.#records.set(spec.name, record);
547
565
  await this.#launch(record);
548
566
  let readyTimedOut = false;
549
567
  if (spec.ready && !terminalState(record.snapshot.state)) {
@@ -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);