@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "17.2.2",
4
+ "version": "17.2.4",
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",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "17.2.2",
56
- "@oh-my-pi/omp-stats": "17.2.2",
57
- "@oh-my-pi/pi-agent-core": "17.2.2",
58
- "@oh-my-pi/pi-ai": "17.2.2",
59
- "@oh-my-pi/pi-catalog": "17.2.2",
60
- "@oh-my-pi/pi-mnemopi": "17.2.2",
61
- "@oh-my-pi/pi-natives": "17.2.2",
62
- "@oh-my-pi/pi-tui": "17.2.2",
63
- "@oh-my-pi/pi-utils": "17.2.2",
64
- "@oh-my-pi/pi-wire": "17.2.2",
65
- "@oh-my-pi/snapcompact": "17.2.2",
55
+ "@oh-my-pi/hashline": "17.2.4",
56
+ "@oh-my-pi/omp-stats": "17.2.4",
57
+ "@oh-my-pi/pi-agent-core": "17.2.4",
58
+ "@oh-my-pi/pi-ai": "17.2.4",
59
+ "@oh-my-pi/pi-catalog": "17.2.4",
60
+ "@oh-my-pi/pi-mnemopi": "17.2.4",
61
+ "@oh-my-pi/pi-natives": "17.2.4",
62
+ "@oh-my-pi/pi-tui": "17.2.4",
63
+ "@oh-my-pi/pi-utils": "17.2.4",
64
+ "@oh-my-pi/pi-wire": "17.2.4",
65
+ "@oh-my-pi/snapcompact": "17.2.4",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/api-logs": "^0.220.0",
68
68
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -142,7 +142,13 @@ export async function collectBundledPiEntries(): Promise<BundledPiEntry[]> {
142
142
 
143
143
  const sourceDir = path.join(packageRoot, pattern.sourcePrefix);
144
144
  try {
145
- const glob = new Bun.Glob(`*${pattern.sourceSuffix}`);
145
+ // Recursive on purpose: Node matches `*` in an `exports` pattern across
146
+ // `/`, so `./slash-commands/*` genuinely serves
147
+ // `slash-commands/helpers/active-oauth-account`. Enumerating only the
148
+ // top level left every nested key out of the compiled registry, where
149
+ // it fell through to `Bun.resolveSync` and died under bunfs — so such
150
+ // an import worked from source and failed inside a binary.
151
+ const glob = new Bun.Glob(`**/*${pattern.sourceSuffix}`);
146
152
  const matches: string[] = [];
147
153
  for await (const match of glob.scan({ cwd: sourceDir, onlyFiles: true })) {
148
154
  matches.push(match);
@@ -151,7 +157,11 @@ export async function collectBundledPiEntries(): Promise<BundledPiEntry[]> {
151
157
  for (const match of matches) {
152
158
  if (!match.endsWith(pattern.sourceSuffix)) continue;
153
159
  const basename = match.slice(0, match.length - pattern.sourceSuffix.length);
154
- if (!isSafeWildcardBasename(basename) || basename.includes("/")) continue;
160
+ const segments = basename.split("/");
161
+ // Every directory on the way has to be importable too: a private or
162
+ // hidden folder is no more exported than a private file.
163
+ if (segments.some(segment => segment.startsWith(".") || segment.startsWith("_"))) continue;
164
+ if (!isSafeWildcardBasename(segments.at(-1) ?? "")) continue;
155
165
  const subpath = `${pattern.exportPrefix}${basename}${pattern.exportSuffix}`;
156
166
  const key = `${manifest.name}/${subpath}`;
157
167
  addEntry(key, bindingForSubpath(pkg.identifier, subpath), key);
@@ -180,12 +180,23 @@ export class AdviseTool implements AgentTool<typeof adviseSchema, AdviseDetails>
180
180
  * escalation: nit → concern → blocker), so an advisor cannot bypass dedupe
181
181
  * by retagging the same text at a lower or equal severity. */
182
182
  #deliveredNoteSeverities = new Map<string, number>();
183
+ #inProgressUpdate = false;
183
184
 
184
185
  constructor(private readonly onAdvice: (note: string, severity?: AdviseDetails["severity"]) => void) {}
185
186
 
187
+ /**
188
+ * Mark whether the next advisor prompt reviews an in-progress primary turn.
189
+ * Non-blockers are withheld until a completed update so partial work does
190
+ * not interrupt the primary before it can finish its planned steps.
191
+ */
192
+ beginUpdate(inProgress: boolean): void {
193
+ this.#inProgressUpdate = inProgress;
194
+ }
195
+
186
196
  /** Clear delivered-note memory when the advisor starts a fresh conversation. */
187
197
  resetDeliveredNotes(): void {
188
198
  this.#deliveredNoteSeverities.clear();
199
+ this.#inProgressUpdate = false;
189
200
  }
190
201
 
191
202
  async execute(
@@ -195,6 +206,13 @@ export class AdviseTool implements AgentTool<typeof adviseSchema, AdviseDetails>
195
206
  _onUpdate?: AgentToolUpdateCallback<AdviseDetails>,
196
207
  _context?: AgentToolContext,
197
208
  ): Promise<AgentToolResult<AdviseDetails>> {
209
+ if (this.#inProgressUpdate && args.severity !== "blocker") {
210
+ return {
211
+ content: [{ type: "text", text: "Recorded." }],
212
+ details: { note: args.note, severity: args.severity },
213
+ useless: true,
214
+ };
215
+ }
198
216
  const key = advisorNoteDedupeKey(args.note);
199
217
  const rank = advisorSeverityRank(args.severity);
200
218
  const previousRank = this.#deliveredNoteSeverities.get(key) ?? 0;
@@ -51,11 +51,11 @@ export interface AdvisorRuntimeHost {
51
51
  maintainContext?(incomingTokens: number, signal: AbortSignal): Promise<boolean>;
52
52
  /**
53
53
  * Called immediately before each `agent.prompt(batch)` cycle. Lets the host
54
- * clear per-update advisor state currently the one-advise-per-update gate
55
- * in {@link AdvisorEmissionGuard}, which the host owns because it is the
56
- * one that routes `advise()` results back to the primary.
54
+ * clear per-update advisor state and apply the in-progress delivery policy.
55
+ * The host owns these gates because it routes `advise()` results back to the
56
+ * primary.
57
57
  */
58
- beginAdvisorUpdate?(): void;
58
+ beginAdvisorUpdate?(inProgress: boolean): void;
59
59
  /**
60
60
  * Called with the error of every failed advisor turn, before the retry sleep
61
61
  * or the dropped-after-3 path. Lets the host apply credential-level remedies
@@ -909,8 +909,8 @@ export class AdvisorRuntime {
909
909
  const contextWasFresh = resetContext || recoveringOverflow || messageSnapshot === 0;
910
910
  try {
911
911
  // Reset the host's per-update advisor state (one-advise-per-update
912
- // gate) before each model cycle so the new batch starts fresh.
913
- this.host.beginAdvisorUpdate?.();
912
+ // gate) and pass through whether this batch reviews partial work.
913
+ this.host.beginAdvisorUpdate?.(wip);
914
914
  const prompt = this.agent.prompt(batch);
915
915
  this.#promptInFlight = prompt;
916
916
  try {
@@ -4,6 +4,8 @@
4
4
  * Canonical shape for MCP server configurations, regardless of source format.
5
5
  * All providers translate their native format to this shape.
6
6
  */
7
+
8
+ import type { MCPRequestIdFormat } from "../mcp/types";
7
9
  import { defineCapability } from ".";
8
10
  import type { SourceMeta } from "./types";
9
11
 
@@ -17,6 +19,8 @@ export interface MCPServer {
17
19
  enabled?: boolean;
18
20
  /** Connection timeout in milliseconds */
19
21
  timeout?: number;
22
+ /** Encoding for outgoing JSON-RPC request ids (default: `"number"`) */
23
+ requestIdFormat?: MCPRequestIdFormat;
20
24
  /** Command to run (for stdio transport) */
21
25
  command?: string;
22
26
  /** Command arguments */
@@ -56,6 +60,9 @@ export interface MCPServer {
56
60
  /** Compare the transport inputs that determine which MCP endpoint gets connected. */
57
61
  function isSameMCPConnection(left: MCPServer, right: MCPServer): boolean {
58
62
  if (!Bun.deepEquals(left.auth, right.auth) || !Bun.deepEquals(left.oauth, right.oauth)) return false;
63
+ // Normalize against the allocator's own default so an explicit "number" is
64
+ // equivalent to leaving the option unset, not a distinct connection.
65
+ if ((left.requestIdFormat ?? "number") !== (right.requestIdFormat ?? "number")) return false;
59
66
 
60
67
  const leftTransport = left.transport ?? (left.command ? "stdio" : left.url ? "http" : "stdio");
61
68
  const rightTransport = right.transport ?? (right.command ? "stdio" : right.url ? "http" : "stdio");
@@ -17,12 +17,7 @@ import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
17
17
  import chalk from "chalk";
18
18
  import { ModelRegistry } from "../config/model-registry";
19
19
  import { Settings } from "../config/settings";
20
- import {
21
- discoverAndLoadExtensions,
22
- ExtensionRunner,
23
- emitSessionShutdownEvent,
24
- loadExtensions,
25
- } from "../extensibility/extensions";
20
+ import { discoverAndLoadExtensions, ExtensionRunner, emitSessionShutdownEvent } from "../extensibility/extensions";
26
21
  import { discoverAuthStorage } from "../sdk";
27
22
  import { SessionManager } from "../session/session-manager";
28
23
  import { EventBus } from "../utils/event-bus";
@@ -283,7 +278,7 @@ export interface RunModelsListingOptions {
283
278
  settingsExtensions?: string[];
284
279
  /** Disabled extension ids from settings (`disabledExtensions`). */
285
280
  disabledExtensionIds?: string[];
286
- /** When true, skip discovery and only load `additionalExtensionPaths`. */
281
+ /** When true, exclude ambient factories and resolve only `additionalExtensionPaths`. */
287
282
  disableExtensionDiscovery?: boolean;
288
283
  }
289
284
 
@@ -301,14 +296,16 @@ export async function runModelsListing(options: RunModelsListingOptions): Promis
301
296
  } = options;
302
297
 
303
298
  const eventBus = new EventBus();
304
- const extensionsResult = disableExtensionDiscovery
305
- ? await loadExtensions(additionalExtensionPaths, cwd, eventBus)
306
- : await discoverAndLoadExtensions(
307
- [...additionalExtensionPaths, ...settingsExtensions],
308
- cwd,
309
- eventBus,
310
- disabledExtensionIds,
311
- );
299
+ const configuredPaths = disableExtensionDiscovery
300
+ ? additionalExtensionPaths
301
+ : [...additionalExtensionPaths, ...settingsExtensions];
302
+ const extensionsResult = await discoverAndLoadExtensions(
303
+ configuredPaths,
304
+ cwd,
305
+ eventBus,
306
+ disableExtensionDiscovery ? undefined : disabledExtensionIds,
307
+ { ambient: !disableExtensionDiscovery },
308
+ );
312
309
  const extensionRunner =
313
310
  extensionsResult.extensions.length > 0
314
311
  ? new ExtensionRunner(
@@ -370,7 +367,7 @@ export async function runModelsCommand(command: ModelsCommandArgs): Promise<void
370
367
  }
371
368
  await modelRegistry.refresh(action === "refresh" ? "online" : "online-if-uncached");
372
369
 
373
- const cliExtensionPaths = command.flags.noExtensions ? [] : (command.flags.extensions ?? []);
370
+ const cliExtensionPaths = command.flags.extensions ?? [];
374
371
  await runModelsListing({
375
372
  modelRegistry,
376
373
  cwd,
@@ -619,7 +619,7 @@ export class CollabHost {
619
619
  if (ref.status === "running" && ref.session) {
620
620
  await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
621
621
  }
622
- await AgentLifecycleManager.global().release(agentId, ref);
622
+ await AgentLifecycleManager.global().release(agentId, ref, { tombstone: true });
623
623
  };
624
624
  kill().catch(fail);
625
625
  break;
@@ -140,7 +140,9 @@ export const KEYBINDINGS = {
140
140
  description: "Retry last failed assistant turn",
141
141
  },
142
142
  "app.message.dequeue": {
143
- defaultKeys: "alt+up",
143
+ // Shift+Up is listed alongside Alt+Up because macOS Terminal.app consumes Option
144
+ // for character composition, leaving Alt+Up unreachable there.
145
+ defaultKeys: ["alt+up", "shift+up"],
144
146
  description: "Dequeue message",
145
147
  },
146
148
  "app.clipboard.pasteImage": {
@@ -516,6 +518,13 @@ function migrateKeybindingsConfigFile(agentDir: string): void {
516
518
 
517
519
  const FOLLOW_UP_KEYBINDING: AppKeybinding = "app.message.followUp";
518
520
  const WINDOWS_FOLLOW_UP_FALLBACK_KEY: KeyId = "ctrl+q";
521
+ const DEQUEUE_KEYBINDING: AppKeybinding = "app.message.dequeue";
522
+ const MACOS_DEQUEUE_FALLBACK_KEY: KeyId = "shift+up";
523
+ function getFallbackKey(keybinding: Keybinding): KeyId | undefined {
524
+ if (keybinding === FOLLOW_UP_KEYBINDING) return WINDOWS_FOLLOW_UP_FALLBACK_KEY;
525
+ if (keybinding === DEQUEUE_KEYBINDING) return MACOS_DEQUEUE_FALLBACK_KEY;
526
+ return undefined;
527
+ }
519
528
  function keyListIncludes(keys: KeyId | KeyId[] | undefined, target: KeyId): boolean {
520
529
  if (keys === undefined) return false;
521
530
  const keyList = Array.isArray(keys) ? keys : [keys];
@@ -600,14 +609,10 @@ export class KeybindingsManager extends TuiKeybindingsManager {
600
609
 
601
610
  getKeys(keybinding: Keybinding): KeyId[] {
602
611
  const keys = super.getKeys(keybinding);
603
- if (keybinding === FOLLOW_UP_KEYBINDING) {
604
- if (this.#userBindings[FOLLOW_UP_KEYBINDING] !== undefined) return keys;
605
- if (!userBindingClaimsKey(this.#userBindings, WINDOWS_FOLLOW_UP_FALLBACK_KEY, FOLLOW_UP_KEYBINDING)) {
606
- return keys;
607
- }
608
- return removeKey(keys, WINDOWS_FOLLOW_UP_FALLBACK_KEY);
609
- }
610
- return keys;
612
+ const fallbackKey = getFallbackKey(keybinding);
613
+ if (fallbackKey === undefined || this.#userBindings[keybinding] !== undefined) return keys;
614
+ if (!userBindingClaimsKey(this.#userBindings, fallbackKey, keybinding)) return keys;
615
+ return removeKey(keys, fallbackKey);
611
616
  }
612
617
 
613
618
  getResolvedBindings(): KeybindingsConfig {
@@ -118,6 +118,11 @@
118
118
  "minimum": 0,
119
119
  "description": "MCP request timeout in milliseconds. Set to 0 to disable client-side MCP timeouts."
120
120
  },
121
+ "requestIdFormat": {
122
+ "type": "string",
123
+ "enum": ["string", "number"],
124
+ "description": "Encoding for outgoing JSON-RPC request ids (default: \"number\"). Use \"string\" for servers that need collision-resistant snowflake string ids instead of per-transport integers. OMP-specific: set it in an OMP-owned config (`.omp/mcp.json`, `~/.omp/agent/mcp.json`, a project `mcp.json`/`.mcp.json`, or an OMP plugin). Servers imported from another tool's config (Claude, Cursor, VS Code, Gemini, OpenCode, Windsurf) ignore it, since that key is not part of those formats."
125
+ },
121
126
  "auth": {
122
127
  "$ref": "#/$defs/authConfig"
123
128
  },
@@ -580,7 +580,13 @@ function applyModelPatch(base: Model<Api>, patch: ModelPatch, transport: ModelTr
580
580
  result.headers = patch.headers;
581
581
  compat = patch.compat;
582
582
  }
583
- return buildModel({ ...result, compat } as ModelSpec<Api>);
583
+ const built = buildModel({ ...result, compat } as ModelSpec<Api>);
584
+ if (patch.thinking !== undefined && built.thinking !== undefined) {
585
+ // Config-authored capability metadata owns the explicit surface; build
586
+ // first so non-reasoning and wire-disabled models still suppress it.
587
+ built.thinking = patch.thinking;
588
+ }
589
+ return built;
584
590
  }
585
591
 
586
592
  function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
@@ -4254,7 +4254,7 @@ export const SETTINGS_SCHEMA = {
4254
4254
  group: "Discovery & MCP",
4255
4255
  label: "xd:// Tools",
4256
4256
  description:
4257
- "Mount rarely-used (discoverable) tools under xd:// device URLs driven via read/write instead of shipping their schemas on every request. Disable to expose every enabled tool top-level.",
4257
+ "Mount rarely-used (discoverable) tools under xd:// device URLs driven via read/write instead of shipping their schemas on every request. Sessions without a granted write tool skip mounting and expose every tool top-level. Disable to expose every enabled tool top-level.",
4258
4258
  },
4259
4259
  },
4260
4260
 
@@ -31,6 +31,7 @@ import {
31
31
  expandEnvVarsDeep,
32
32
  getExtensionNameFromPath,
33
33
  loadFilesFromDir,
34
+ parseRequestIdFormat,
34
35
  SOURCE_PATHS,
35
36
  scanSkillsFromDir,
36
37
  } from "./helpers";
@@ -154,10 +155,19 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
154
155
  timeout = undefined;
155
156
  }
156
157
 
158
+ // Validate requestIdFormat: only the two documented encodings
159
+ const requestIdFormat = parseRequestIdFormat(serverConfig.requestIdFormat);
160
+ if (requestIdFormat === undefined && serverConfig.requestIdFormat != null) {
161
+ logger.warn(
162
+ `MCP server "${serverName}": invalid requestIdFormat ${JSON.stringify(serverConfig.requestIdFormat)}, ignoring`,
163
+ );
164
+ }
165
+
157
166
  result.push({
158
167
  name: serverName,
159
168
  enabled,
160
169
  timeout,
170
+ requestIdFormat,
161
171
  command: serverConfig.command as string | undefined,
162
172
  args: serverConfig.args as string[] | undefined,
163
173
  env: serverConfig.env as Record<string, string> | undefined,
@@ -16,6 +16,7 @@ import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../ca
16
16
  import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
17
17
  import type { Skill, SkillFrontmatter } from "../capability/skill";
18
18
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
19
+ import type { MCPRequestIdFormat } from "../mcp/types";
19
20
  import { type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../thinking";
20
21
  import { normalizeToolNames } from "../tools/builtin-names";
21
22
 
@@ -140,6 +141,15 @@ export function parseBoolean(value: unknown): boolean | undefined {
140
141
  return undefined;
141
142
  }
142
143
 
144
+ /**
145
+ * Parse an MCP `requestIdFormat` value. Unrecognized values are dropped so a typo
146
+ * degrades to the default integer ids rather than reaching a transport.
147
+ */
148
+ export function parseRequestIdFormat(value: unknown): MCPRequestIdFormat | undefined {
149
+ if (value === "string" || value === "number") return value;
150
+ return undefined;
151
+ }
152
+
143
153
  /**
144
154
  * Parse a comma-separated string into an array of trimmed, non-empty strings.
145
155
  */
@@ -12,7 +12,7 @@ import { registerProvider } from "../capability";
12
12
  import { readFile } from "../capability/fs";
13
13
  import { type MCPServer, mcpCapability } from "../capability/mcp";
14
14
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
15
- import { createSourceMeta, expandEnvVarsDeep } from "./helpers";
15
+ import { createSourceMeta, expandEnvVarsDeep, parseRequestIdFormat } from "./helpers";
16
16
 
17
17
  const PROVIDER_ID = "mcp-json";
18
18
  const DISPLAY_NAME = "MCP Config";
@@ -26,6 +26,7 @@ interface MCPConfigFile {
26
26
  {
27
27
  enabled?: boolean;
28
28
  timeout?: number;
29
+ requestIdFormat?: "string" | "number";
29
30
  command?: string;
30
31
  args?: string[];
31
32
  env?: Record<string, string>;
@@ -83,10 +84,19 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe
83
84
  }
84
85
  }
85
86
 
87
+ const requestIdFormat = parseRequestIdFormat(serverConfig.requestIdFormat);
88
+ if (requestIdFormat === undefined && serverConfig.requestIdFormat !== undefined) {
89
+ logger.warn("MCP server has invalid 'requestIdFormat' value, ignoring", {
90
+ name,
91
+ value: serverConfig.requestIdFormat,
92
+ });
93
+ }
94
+
86
95
  const server: MCPServer = {
87
96
  name,
88
97
  enabled,
89
98
  timeout,
99
+ requestIdFormat,
90
100
  command: serverConfig.command,
91
101
  args: serverConfig.args,
92
102
  env: serverConfig.env,
@@ -15,6 +15,7 @@
15
15
  * @see ./omp-plugins.ts
16
16
  * @see ./builtin.ts `loadExtensionModules`
17
17
  */
18
+ import { AsyncLocalStorage } from "node:async_hooks";
18
19
  import * as fs from "node:fs/promises";
19
20
  import * as path from "node:path";
20
21
  import { getAgentDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
@@ -36,10 +37,48 @@ export interface OmpExtensionRoot {
36
37
 
37
38
  interface InjectedRoot {
38
39
  path: string;
40
+ /** Relative CLI spelling, rebound against the active project on discovery. */
41
+ relativePath?: string;
39
42
  level: "user" | "project";
40
43
  }
41
44
 
45
+ export type OmpExtensionRootMode = "merge" | "explicit-only";
46
+
47
+ interface InvocationRootScope {
48
+ /** Raw SDK spellings, resolved against the LoadContext that performs discovery. */
49
+ paths: readonly string[];
50
+ mode: OmpExtensionRootMode;
51
+ }
52
+
53
+ const invocationRootScope = new AsyncLocalStorage<InvocationRootScope>();
54
+
42
55
  let injectedCliRoots: InjectedRoot[] = [];
56
+ let injectedCliRootMode: OmpExtensionRootMode = "merge";
57
+
58
+ export interface InjectOmpExtensionCliRootOptions {
59
+ /**
60
+ * `explicit-only` exposes only roots named by this CLI invocation. Use it
61
+ * with `--no-extensions` so configured and installed packages cannot
62
+ * contribute sibling capabilities through the `omp-plugins` provider.
63
+ */
64
+ mode?: OmpExtensionRootMode;
65
+ /** Replace roots from an earlier invocation instead of extending them. */
66
+ replace?: boolean;
67
+ }
68
+
69
+ /**
70
+ * Run one SDK invocation with its own extension-package roots. Async resources
71
+ * started inside `callback` retain this scope, including discovery deliberately
72
+ * deferred until the end of session startup. Raw relative paths are resolved by
73
+ * {@link listOmpExtensionRoots} against that invocation's active cwd.
74
+ */
75
+ export function withOmpExtensionRootScope<T>(
76
+ paths: readonly string[],
77
+ mode: OmpExtensionRootMode,
78
+ callback: () => T,
79
+ ): T {
80
+ return invocationRootScope.run({ paths: [...paths], mode }, callback);
81
+ }
43
82
 
44
83
  /**
45
84
  * Register CLI-provided extension package paths (e.g. from `--extension`/`-e`)
@@ -50,17 +89,27 @@ let injectedCliRoots: InjectedRoot[] = [];
50
89
  * Call once during startup before any capability load. Repeated calls extend
51
90
  * the registered set; {@link clearOmpExtensionCliRoots} resets for tests.
52
91
  */
53
- export function injectOmpExtensionCliRoots(paths: readonly string[], home: string, cwd: string): void {
92
+ export function injectOmpExtensionCliRoots(
93
+ paths: readonly string[],
94
+ home: string,
95
+ cwd: string,
96
+ options: InjectOmpExtensionCliRootOptions = {},
97
+ ): void {
98
+ if (options.mode) injectedCliRootMode = options.mode;
99
+ if (options.replace) injectedCliRoots = [];
54
100
  if (paths.length === 0) return;
55
101
  const expanded = paths.map(raw => {
56
102
  const tilde = expandTilde(raw, home);
57
- return path.isAbsolute(tilde) ? tilde : path.resolve(cwd, tilde);
103
+ return {
104
+ path: path.isAbsolute(tilde) ? tilde : path.resolve(cwd, tilde),
105
+ relativePath: path.isAbsolute(tilde) ? undefined : tilde,
106
+ };
58
107
  });
59
108
  const merged = new Map<string, InjectedRoot>();
60
109
  for (const root of injectedCliRoots) merged.set(root.path, root);
61
- for (const resolved of expanded) {
110
+ for (const { path: resolved, relativePath } of expanded) {
62
111
  // CLI scope mirrors how `--extension` is treated elsewhere — user-level overrides win.
63
- if (!merged.has(resolved)) merged.set(resolved, { path: resolved, level: "user" });
112
+ if (!merged.has(resolved)) merged.set(resolved, { path: resolved, relativePath, level: "user" });
64
113
  }
65
114
  injectedCliRoots = [...merged.values()];
66
115
  }
@@ -68,6 +117,7 @@ export function injectOmpExtensionCliRoots(paths: readonly string[], home: strin
68
117
  /** Drop every CLI-injected root. Tests use this between cases. */
69
118
  export function clearOmpExtensionCliRoots(): void {
70
119
  injectedCliRoots = [];
120
+ injectedCliRootMode = "merge";
71
121
  }
72
122
 
73
123
  /** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */
@@ -121,7 +171,8 @@ async function isDirectory(p: string): Promise<boolean> {
121
171
  * Sources, in order of precedence (later entries with the same absolute path
122
172
  * are dropped):
123
173
  *
124
- * 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
174
+ * 1. Invocation-scoped SDK roots, when present; otherwise CLI roots injected
175
+ * via {@link injectOmpExtensionCliRoots}
125
176
  * 2. Project `<cwd>/.omp/settings.json#extensions`
126
177
  * 3. User `~/.omp/agent/settings.json#extensions`
127
178
  * 4. Enabled npm/link plugins installed under `<plugins>/node_modules/` (for
@@ -134,21 +185,29 @@ async function isDirectory(p: string): Promise<boolean> {
134
185
  * other sources still surface.
135
186
  */
136
187
  export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtensionRoot[]> {
137
- const { project, user } = scopeDirs(ctx);
138
- const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([
139
- readSettingsExtensions(path.join(project, "settings.json")),
140
- readSettingsExtensions(path.join(user, "settings.json")),
141
- listInstalledPluginRoots(ctx),
142
- ]);
143
-
144
- const candidates: InjectedRoot[] = [
145
- ...injectedCliRoots,
146
- ...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })),
147
- ...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })),
148
- ...installedPlugins,
149
- ];
150
-
151
- // First-seen-wins dedup preserves CLI > project-settings > user-settings > installed precedence.
188
+ const scopedRoots = invocationRootScope.getStore();
189
+ const rootMode = scopedRoots?.mode ?? injectedCliRootMode;
190
+ let candidates: InjectedRoot[] = scopedRoots
191
+ ? scopedRoots.paths.map(raw => ({ path: resolveAgainst(raw, ctx), level: "user" }))
192
+ : injectedCliRoots.map(root =>
193
+ root.relativePath ? { ...root, path: path.resolve(ctx.cwd, root.relativePath) } : root,
194
+ );
195
+ if (rootMode === "merge") {
196
+ const { project, user } = scopeDirs(ctx);
197
+ const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([
198
+ readSettingsExtensions(path.join(project, "settings.json")),
199
+ readSettingsExtensions(path.join(user, "settings.json")),
200
+ listInstalledPluginRoots(ctx),
201
+ ]);
202
+ candidates = [
203
+ ...candidates,
204
+ ...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })),
205
+ ...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })),
206
+ ...installedPlugins,
207
+ ];
208
+ }
209
+
210
+ // First-seen-wins dedup preserves invocation/CLI > project-settings > user-settings > installed precedence.
152
211
  const seen = new Set<string>();
153
212
  const unique: InjectedRoot[] = [];
154
213
  for (const candidate of candidates) {
@@ -28,7 +28,13 @@ import { type Skill, skillCapability } from "../capability/skill";
28
28
  import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
29
29
  import { type CustomTool, toolCapability } from "../capability/tool";
30
30
  import type { LoadContext, LoadResult } from "../capability/types";
31
- import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
31
+ import {
32
+ buildRuleFromMarkdown,
33
+ createSourceMeta,
34
+ loadFilesFromDir,
35
+ parseRequestIdFormat,
36
+ scanSkillsFromDir,
37
+ } from "./helpers";
32
38
  import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots";
33
39
  import { resolvePluginStdioPaths } from "./substitute-plugin-root";
34
40
 
@@ -257,6 +263,7 @@ const MCP_FILENAMES = [".mcp.json", "mcp.json"] as const;
257
263
  interface RawMcpServer {
258
264
  enabled?: boolean;
259
265
  timeout?: number;
266
+ requestIdFormat?: unknown;
260
267
  command?: string;
261
268
  args?: string[];
262
269
  env?: Record<string, string>;
@@ -305,10 +312,12 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
305
312
  // Root relative command/cwd at the plugin's config directory, not the
306
313
  // session cwd (MCP stdio spawning resolves relative values there).
307
314
  const rooted = resolvePluginStdioPaths({ command: cfg.command, cwd: cfg.cwd }, root.path);
315
+ const requestIdFormat = parseRequestIdFormat(cfg.requestIdFormat);
308
316
  items.push({
309
317
  name: serverName,
310
318
  ...(cfg.enabled !== undefined && { enabled: cfg.enabled }),
311
319
  ...(cfg.timeout !== undefined && { timeout: cfg.timeout }),
320
+ ...(requestIdFormat !== undefined && { requestIdFormat }),
312
321
  ...(rooted.command !== undefined && { command: rooted.command }),
313
322
  ...(cfg.args !== undefined && { args: cfg.args }),
314
323
  ...(cfg.env !== undefined && { env: cfg.env }),