@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.
- package/CHANGELOG.md +41 -0
- package/dist/{CHANGELOG-c5hpqt9r.md → CHANGELOG-bpmhv26t.md} +41 -0
- package/dist/cli.js +3270 -3270
- package/dist/types/advisor/advise-tool.d.ts +6 -0
- package/dist/types/advisor/runtime.d.ts +4 -4
- package/dist/types/capability/mcp.d.ts +9 -0
- package/dist/types/cli/models-cli.d.ts +1 -1
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/discovery/helpers.d.ts +6 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +21 -2
- package/dist/types/extensibility/extensions/loader.d.ts +6 -2
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +2 -2
- package/dist/types/launch/paths.d.ts +2 -1
- package/dist/types/mcp/request-id.d.ts +19 -0
- package/dist/types/mcp/types.d.ts +14 -0
- package/dist/types/memory-backend/index.d.ts +1 -0
- package/dist/types/memory-backend/messages.d.ts +15 -0
- package/dist/types/modes/components/model-browser.d.ts +7 -5
- package/dist/types/modes/components/model-picker.d.ts +9 -3
- package/dist/types/modes/components/read-tool-group.d.ts +9 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/registry/agent-lifecycle.d.ts +14 -5
- package/dist/types/registry/agent-registry.d.ts +4 -3
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/secrets/index.d.ts +6 -8
- package/dist/types/session/agent-session.d.ts +6 -1
- package/dist/types/session/model-controls.d.ts +0 -1
- package/dist/types/session/session-advisors.d.ts +7 -0
- package/dist/types/session/session-maintenance.d.ts +1 -0
- package/dist/types/session/session-manager.d.ts +11 -5
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tools/default-renderer.d.ts +3 -0
- package/package.json +12 -12
- package/scripts/legacy-pi-virtual-module.ts +12 -2
- package/src/advisor/advise-tool.ts +18 -0
- package/src/advisor/runtime.ts +6 -6
- package/src/capability/mcp.ts +7 -0
- package/src/cli/models-cli.ts +13 -16
- package/src/collab/host.ts +1 -1
- package/src/config/keybindings.ts +14 -9
- package/src/config/mcp-schema.json +5 -0
- package/src/config/model-registry.ts +7 -1
- package/src/config/settings-schema.ts +1 -1
- package/src/discovery/builtin.ts +10 -0
- package/src/discovery/helpers.ts +10 -0
- package/src/discovery/mcp-json.ts +11 -1
- package/src/discovery/omp-extension-roots.ts +79 -20
- package/src/discovery/omp-plugins.ts +10 -1
- package/src/extensibility/extensions/loader.ts +66 -28
- package/src/extensibility/plugins/marketplace/registry.ts +4 -8
- package/src/launch/paths.ts +2 -5
- package/src/main.ts +24 -22
- package/src/mcp/config.ts +1 -0
- package/src/mcp/request-id.ts +24 -0
- package/src/mcp/transports/http.ts +4 -2
- package/src/mcp/transports/sse.ts +4 -2
- package/src/mcp/transports/stdio.ts +4 -2
- package/src/mcp/types.ts +15 -0
- package/src/memory-backend/index.ts +1 -0
- package/src/memory-backend/messages.ts +19 -0
- package/src/modes/components/agent-hub.ts +1 -1
- package/src/modes/components/custom-editor.ts +1 -1
- package/src/modes/components/model-browser.ts +25 -17
- package/src/modes/components/model-picker.ts +10 -6
- package/src/modes/components/read-tool-group.ts +26 -0
- package/src/modes/components/tool-execution.ts +77 -20
- package/src/modes/controllers/command-controller.ts +2 -2
- package/src/modes/controllers/event-controller.ts +182 -10
- package/src/modes/controllers/selector-controller.ts +27 -6
- package/src/modes/types.ts +5 -1
- package/src/prompts/tools/bash.md +1 -1
- package/src/registry/agent-lifecycle.ts +36 -11
- package/src/registry/agent-registry.ts +12 -5
- package/src/sdk.ts +68 -29
- package/src/secrets/index.ts +15 -17
- package/src/session/agent-session.ts +10 -1
- package/src/session/agent-storage.ts +6 -4
- package/src/session/history-storage.ts +4 -2
- package/src/session/messages.ts +31 -7
- package/src/session/model-controls.ts +0 -1
- package/src/session/session-advisors.ts +19 -1
- package/src/session/session-maintenance.ts +3 -0
- package/src/session/session-manager.ts +128 -76
- package/src/session/session-tools.ts +25 -23
- package/src/slash-commands/builtin-registry.ts +2 -2
- package/src/system-prompt.ts +6 -1
- package/src/task/executor.ts +8 -1
- package/src/tiny/text.ts +12 -0
- package/src/tools/default-renderer.ts +19 -4
- package/src/tools/index.ts +10 -6
- package/src/web/search/providers/anthropic.ts +3 -1
|
@@ -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;
|
package/src/advisor/runtime.ts
CHANGED
|
@@ -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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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)
|
|
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 {
|
package/src/capability/mcp.ts
CHANGED
|
@@ -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");
|
package/src/cli/models-cli.ts
CHANGED
|
@@ -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,
|
|
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
|
|
305
|
-
?
|
|
306
|
-
:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
370
|
+
const cliExtensionPaths = command.flags.extensions ?? [];
|
|
374
371
|
await runModelsListing({
|
|
375
372
|
modelRegistry,
|
|
376
373
|
cwd,
|
package/src/collab/host.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -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,
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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.
|
|
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
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 {
|
|
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 }),
|
|
@@ -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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
}
|