@oh-my-pi/pi-coding-agent 15.10.8 → 15.10.9

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.9] - 2026-06-09
6
+
7
+ ### Fixed
8
+
9
+ - Fixed streaming thinking (and other styled assistant content) vanishing from native scrollback once it scrolled past the viewport top during a foreground turn. The transcript's append-only commit detector compared raw row bytes, so a styled paragraph wrapping onto a new row (the span-closing SGR and width padding move while the visible cells stay identical) or a streamed token pushing the last word down a line flagged the block as permanently volatile — the commit boundary froze and every later row that crossed the viewport top was committed nowhere. Rows are now compared by visible content, a wrap-shrink of the in-flight bottom line counts as append-only, and a genuine one-off interior rewrite only suspends commits until the block re-earns append-only (30 clean frames), after which the pinned emitter backfills the stalled gap contiguously. Periodically rewriting blocks (spinners, collapsing tool previews) never re-earn and stay deferred.
10
+
11
+ - Fixed bracketed pastes containing multiple image file paths so each image is attached in order instead of treating the whole paste as one unreadable path.
12
+
13
+ - Fixed MCP OAuth fallback prompts so the "Click here to authorize" label emits an auth-safe terminal hyperlink even when hyperlink auto-detection is unavailable, keeping non-browser MCP setup usable ([#2196](https://github.com/can1357/oh-my-pi/issues/2196)).
14
+ - Fixed `task`-spawned subagents repeating filesystem scans the parent had already completed. `ExecutorOptions` and the `createAgentSession()` call inside `runSubprocess()` did not forward `rules`, the discovered extension paths, or the discovered `.omp/tools/` paths, so each subagent re-ran `loadCapability<Rule>()`, `discoverAndLoadExtensions()`, and the full `.omp/tools/` walk. The toolsession now caches `session.rules`, `session.extensionPaths`, and `session.customToolPaths`; `runSubprocess()` threads them through; and `createAgentSession()` accepts new `preloadedExtensionPaths` and `preloadedCustomToolPaths` options backed by new exported `discoverExtensionPaths()` and `discoverCustomToolPaths()` helpers. Crucially, only path lists are forwarded — never loaded instances. Each session rebuilds its own `Extension` and `LoadedCustomTool` objects so the per-session `ExtensionAPI`/`CustomToolAPI` (cwd, eventBus, runtime, exec, pushPendingAction, UI) targets the right session; forwarding loaded instances would have routed extension handlers and custom-tool execution back through the parent. The CLI's `preloadedExtensions` short-circuit is preserved for same-process reuse and now shallow-clones the caller's `extensions` array so inline-extension augmentation (autoresearch + custom-tools wrapper) cannot bleed back into it ([#2190](https://github.com/can1357/oh-my-pi/issues/2190)).
15
+ - Fixed SSH tool cancellation hanging behind OpenSSH ControlMaster streams that stayed open after an Esc/user interrupt ([#2180](https://github.com/can1357/oh-my-pi/issues/2180)).
16
+ - Fixed Windows stdio MCP servers launched through PATH shims such as `codegraph.cmd` so bare commands like `codegraph` resolve via `PATHEXT` before spawn ([#2174](https://github.com/can1357/oh-my-pi/issues/2174)).
17
+ - Fixed compiled-binary extensions failing to load `@oh-my-pi/pi-*` packages when `bun --compile` quietly dropped one of the extra entrypoints (observed on macOS arm64 release builds): the legacy-pi compat shim's package-root override branch returned the bunfs path without checking the target was present, so the rewrite emitted a `file://` URL to a missing module and the #1216 fallback (scoped to the throwing `getResolvedSpecifier` path) never ran. Override targets are now validated against the on-disk filesystem at module init, missing entries are dropped, and resolution falls through to canonical lookup so Bun resolves the import from the extension's own `node_modules` ([#2168](https://github.com/can1357/oh-my-pi/issues/2168)).
18
+
5
19
  ## [15.10.8] - 2026-06-09
6
20
 
7
21
  ### Added
@@ -9796,4 +9810,4 @@ Initial public release.
9796
9810
  - Git branch display in footer
9797
9811
  - Message queueing during streaming responses
9798
9812
  - OAuth integration for Gmail and Google Calendar access
9799
- - HTML export with syntax highlighting and collapsible sections
9813
+ - HTML export with syntax highlighting and collapsible sections
@@ -1,8 +1,10 @@
1
1
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import type { HookUIContext } from "../../extensibility/hooks/types";
3
3
  import type { LoadedCustomTool, ToolLoadError } from "./types";
4
- /** Tool path with optional source metadata */
5
- interface ToolPathWithSource {
4
+ /** Tool path with optional source metadata, suitable for forwarding from a
5
+ * parent session to a subagent so the subagent can re-bind tools to its own
6
+ * `CustomToolAPI` without redoing the filesystem scan. */
7
+ export interface ToolPathWithSource {
6
8
  path: string;
7
9
  source?: {
8
10
  provider: string;
@@ -46,12 +48,30 @@ export declare function loadCustomTools(pathsWithSources: ToolPathWithSource[],
46
48
  errors: ToolLoadError[];
47
49
  setUIContext: (uiContext: HookUIContext, hasUI: boolean) => void;
48
50
  }>;
51
+ /**
52
+ * Collect the absolute tool-source paths to load, without importing or
53
+ * binding factories. Hot path on session startup — the scan walks
54
+ * `.omp/tools/`, `.claude/tools/`, the plugin tree, and any configured paths.
55
+ *
56
+ * Subagents reuse the parent's collected paths via the SDK's
57
+ * `preloadedCustomToolPaths` option, then call `loadCustomTools` themselves
58
+ * so each session re-binds factories with its own session-scoped
59
+ * `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
60
+ *
61
+ * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
62
+ * @param cwd - Current working directory
63
+ */
64
+ export declare function discoverCustomToolPaths(configuredPaths: string[], cwd: string): Promise<ToolPathWithSource[]>;
49
65
  /**
50
66
  * Discover and load tools from standard locations via capability system:
51
67
  * 1. User and project tools discovered by capability providers
52
68
  * 2. Installed plugins (~/.omp/plugins/node_modules/*)
53
69
  * 3. Explicitly configured paths from settings or CLI
54
70
  *
71
+ * Composed of {@link discoverCustomToolPaths} (FS scan) + {@link loadCustomTools}
72
+ * (per-session binding). Subagents skip the first step and just call
73
+ * `loadCustomTools` against the parent's collected paths.
74
+ *
55
75
  * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
56
76
  * @param cwd - Current working directory
57
77
  * @param builtInToolNames - Names of built-in tools to check for conflicts
@@ -66,4 +86,3 @@ export declare function discoverAndLoadCustomTools(configuredPaths: string[], cw
66
86
  errors: ToolLoadError[];
67
87
  setUIContext: (uiContext: HookUIContext, hasUI: boolean) => void;
68
88
  }>;
69
- export {};
@@ -2,7 +2,7 @@
2
2
  * Extension system for lifecycle events and custom tools.
3
3
  */
4
4
  export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands";
5
- export { discoverAndLoadExtensions, ExtensionRuntimeNotInitializedError, loadExtensionFromFactory, loadExtensions, } from "./loader";
5
+ export { discoverAndLoadExtensions, discoverExtensionPaths, ExtensionRuntimeNotInitializedError, loadExtensionFromFactory, loadExtensions, } from "./loader";
6
6
  export * from "./runner";
7
7
  export * from "./types";
8
8
  export * from "./wrapper";
@@ -38,6 +38,22 @@ export declare function loadExtensionFromFactory(factory: ExtensionFactory, cwd:
38
38
  */
39
39
  export declare function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult>;
40
40
  /**
41
- * Discover and load extensions from standard locations.
41
+ * Discover absolute paths of extensions to load, without importing or
42
+ * binding factories. Hot path on session startup — the scan walks native
43
+ * `.omp`/`.pi` extension capabilities, the installed-plugin tree, and any
44
+ * configured paths.
45
+ *
46
+ * Subagents reuse the parent's collected paths via the SDK's
47
+ * `preloadedExtensionPaths` option, then call {@link loadExtensions} themselves
48
+ * so each session rebuilds Extension instances bound to its OWN
49
+ * `ExtensionAPI` (cwd, eventBus, runtime). Forwarding the parent's
50
+ * `LoadExtensionsResult` directly would reuse handlers/tools/commands that
51
+ * closed over the parent's `cwd` and event bus.
52
+ */
53
+ export declare function discoverExtensionPaths(configuredPaths: string[], cwd: string, disabledExtensionIds?: string[]): Promise<string[]>;
54
+ /**
55
+ * Discover and load extensions from standard locations. Composed of
56
+ * {@link discoverExtensionPaths} (FS scan) + {@link loadExtensions}
57
+ * (per-session binding).
42
58
  */
43
59
  export declare function discoverAndLoadExtensions(configuredPaths: string[], cwd: string, eventBus?: EventBus, disabledExtensionIds?: string[]): Promise<LoadExtensionsResult>;
@@ -13,6 +13,14 @@ import * as path from "node:path";
13
13
  * Exported for tests; production callers use `BUNFS_PACKAGE_ROOT` below.
14
14
  */
15
15
  export declare function __computeBunfsPackageRoot(metaDir: string, pathImpl?: typeof path): string;
16
+ /**
17
+ * Drop overrides whose targets are missing on disk so they can fall through to
18
+ * the canonical-resolution path. Exported for the test seam in #2168.
19
+ *
20
+ * `pathExistsSync` defaults to `fs.existsSync`; the tests inject a stub to
21
+ * simulate the missing-entrypoint failure mode without touching the real FS.
22
+ */
23
+ export declare function __validateLegacyPiPackageRootOverrides(candidates: Record<string, string>, pathExistsSync?: (p: string) => boolean): Record<string, string>;
16
24
  /**
17
25
  * Load a legacy Pi extension module from its real on-disk location.
18
26
  *
@@ -5,6 +5,18 @@
5
5
  * Messages are newline-delimited JSON.
6
6
  */
7
7
  import type { MCPRequestOptions, MCPStdioServerConfig, MCPTransport } from "../../mcp/types";
8
+ /** Subprocess argv for launching an MCP stdio server. */
9
+ export interface StdioSpawnCommand {
10
+ cmd: string[];
11
+ }
12
+ /** Inputs used to resolve platform-specific stdio spawn behavior. */
13
+ export interface ResolveStdioSpawnOptions {
14
+ cwd: string;
15
+ env: Record<string, string | undefined>;
16
+ platform?: NodeJS.Platform;
17
+ }
18
+ /** Resolve the subprocess argv used to launch an MCP stdio server. */
19
+ export declare function resolveStdioSpawnCommand(config: MCPStdioServerConfig, options: ResolveStdioSpawnOptions): Promise<StdioSpawnCommand>;
8
20
  /** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
9
21
  interface FrameSink {
10
22
  write(chunk: string): unknown;
@@ -1,6 +1,7 @@
1
1
  import { Editor, type KeyId } from "@oh-my-pi/pi-tui";
2
2
  import type { AppKeybinding } from "../../config/keybindings";
3
3
  type ConfigurableEditorAction = Extract<AppKeybinding, "app.interrupt" | "app.clear" | "app.exit" | "app.suspend" | "app.display.reset" | "app.thinking.cycle" | "app.model.cycleForward" | "app.model.cycleBackward" | "app.model.select" | "app.model.selectTemporary" | "app.tools.expand" | "app.thinking.toggle" | "app.editor.external" | "app.history.search" | "app.message.dequeue" | "app.clipboard.pasteImage" | "app.clipboard.pasteTextRaw" | "app.clipboard.copyPrompt">;
4
+ export declare function extractBracketedImagePastePaths(data: string): string[] | undefined;
4
5
  export declare function extractBracketedImagePastePath(data: string): string | undefined;
5
6
  /**
6
7
  * Custom editor that handles configurable app-level shortcuts for coding-agent.
@@ -33,8 +34,8 @@ export declare class CustomEditor extends Editor {
33
34
  onCopyPrompt?: () => void;
34
35
  /** Called when the configured image-paste shortcut is pressed. */
35
36
  onPasteImage?: () => Promise<boolean>;
36
- /** Called when a bracketed paste contains exactly one image-file path. */
37
- onPasteImagePath?: (path: string) => void;
37
+ /** Called when a bracketed paste contains one or more image-file paths. */
38
+ onPasteImagePath?: (path: string) => void | Promise<void>;
38
39
  /** Called when the configured raw text-paste shortcut is pressed. */
39
40
  onPasteTextRaw?: () => void;
40
41
  /** Called when the configured dequeue shortcut is pressed. */
@@ -6,6 +6,7 @@ import { type PromptTemplate } from "./config/prompt-templates";
6
6
  import { Settings, type SkillsSettings } from "./config/settings";
7
7
  import "./discovery";
8
8
  import { type CustomCommandsLoadResult } from "./extensibility/custom-commands";
9
+ import { type ToolPathWithSource } from "./extensibility/custom-tools";
9
10
  import type { CustomTool } from "./extensibility/custom-tools/types";
10
11
  import { type ExtensionFactory, type ExtensionUIContext, type LoadExtensionsResult, type ToolDefinition } from "./extensibility/extensions";
11
12
  import { type Skill, type SkillWarning } from "./extensibility/skills";
@@ -62,10 +63,41 @@ export interface CreateAgentSessionOptions {
62
63
  /** Disable extension discovery (explicit paths still load). */
63
64
  disableExtensionDiscovery?: boolean;
64
65
  /**
65
- * Pre-loaded extensions (skips file discovery).
66
- * @internal Used by CLI when extensions are loaded early to parse custom flags.
66
+ * Pre-loaded extensions (skips file discovery and the per-session factory
67
+ * call). Used by the CLI when extensions are loaded early to parse custom
68
+ * flags — the same process owns the returned instances, so reusing them is
69
+ * safe.
70
+ *
71
+ * NEVER pass this across session boundaries (e.g. parent → subagent).
72
+ * `Extension` instances close over a parent-bound `ExtensionAPI` (cwd,
73
+ * eventBus, runtime), and reusing them would route tools/handlers/commands
74
+ * back through the parent. For subagents, forward
75
+ * {@link preloadedExtensionPaths} instead.
76
+ *
77
+ * @internal
67
78
  */
68
79
  preloadedExtensions?: LoadExtensionsResult;
80
+ /**
81
+ * Pre-discovered extension source paths. When provided, the filesystem-scan
82
+ * inside `discoverExtensionPaths()` is skipped — the session still calls
83
+ * `loadExtensions()` itself so each `Extension` is bound to THIS session's
84
+ * `ExtensionAPI` (cwd, eventBus, runtime).
85
+ *
86
+ * This is the safe pass-through for parent → subagent forwarding.
87
+ */
88
+ preloadedExtensionPaths?: string[];
89
+ /**
90
+ * Pre-discovered custom-tool source paths from `.omp/tools/`, `.claude/tools/`,
91
+ * plugins, etc. When provided, the filesystem-scan inside
92
+ * `discoverCustomToolPaths()` is skipped — subagents inherit the parent's
93
+ * scan result and call `loadCustomTools()` themselves so each session binds
94
+ * tools to its OWN `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
95
+ *
96
+ * Forwarding the loaded `LoadedCustomTool[]` instances directly would reuse
97
+ * the parent's session-bound API and route tool execution back through the
98
+ * parent — wrong for isolated tasks and for pending-action routing.
99
+ */
100
+ preloadedCustomToolPaths?: ToolPathWithSource[];
69
101
  /** Shared event bus for tool/extension communication. Default: creates new bus. */
70
102
  eventBus?: EventBus;
71
103
  /** Skills. Default: discovered from multiple locations */
@@ -178,6 +210,14 @@ export declare function discoverAuthStorage(agentDir?: string): Promise<AuthStor
178
210
  * Discover extensions from cwd.
179
211
  */
180
212
  export declare function discoverExtensions(cwd?: string): Promise<LoadExtensionsResult>;
213
+ /**
214
+ * Path-only counterpart of {@link loadSessionExtensions}: the FS-heavy scan
215
+ * without the per-session module load. Subagents reuse the parent's path list
216
+ * (cached on {@link ToolSession.extensionPaths}) and rebuild Extension
217
+ * instances themselves so each session's `ExtensionAPI` (cwd, eventBus,
218
+ * runtime) is its own.
219
+ */
220
+ export declare function discoverSessionExtensionPaths(options: Pick<CreateAgentSessionOptions, "disableExtensionDiscovery" | "additionalExtensionPaths">, cwd: string, settings: Settings): Promise<string[]>;
181
221
  /**
182
222
  * Load the discovered/configured extensions for a session — everything {@link
183
223
  * createAgentSession} would load except the inline factory extensions it appends
@@ -4,10 +4,12 @@
4
4
  * Runs each subagent on the main thread and forwards AgentEvents for progress tracking.
5
5
  */
6
6
  import type { AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
+ import type { Rule } from "../capability/rule";
7
8
  import { ModelRegistry } from "../config/model-registry";
8
9
  import type { PromptTemplate } from "../config/prompt-templates";
9
10
  import { Settings } from "../config/settings";
10
11
  import { type SettingPath } from "../config/settings-schema";
12
+ import type { ToolPathWithSource } from "../extensibility/custom-tools";
11
13
  import type { CustomTool } from "../extensibility/custom-tools/types";
12
14
  import { type Skill } from "../extensibility/skills";
13
15
  import type { HindsightSessionState } from "../hindsight/state";
@@ -70,6 +72,20 @@ export interface ExecutorOptions {
70
72
  skills?: Skill[];
71
73
  promptTemplates?: PromptTemplate[];
72
74
  workspaceTree?: WorkspaceTree;
75
+ /** Parent-discovered rules, forwarded to skip rule discovery in the subagent. */
76
+ rules?: Rule[];
77
+ /**
78
+ * Parent's discovered extension source paths. Forwarded to skip the
79
+ * extension FS scan in the subagent; the subagent then re-binds each
80
+ * extension against its own `ExtensionAPI` (cwd, eventBus, runtime).
81
+ */
82
+ preloadedExtensionPaths?: string[];
83
+ /**
84
+ * Parent's discovered custom-tool source paths. Forwarded to skip the
85
+ * `.omp/tools/` FS scan in the subagent; the subagent then re-binds each
86
+ * tool against its own `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
87
+ */
88
+ preloadedCustomToolPaths?: ToolPathWithSource[];
73
89
  mcpManager?: MCPManager;
74
90
  authStorage?: AuthStorage;
75
91
  modelRegistry?: ModelRegistry;
@@ -2,8 +2,10 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { FetchImpl, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import type { AsyncJobManager } from "../async/job-manager";
5
+ import type { Rule } from "../capability/rule";
5
6
  import type { PromptTemplate } from "../config/prompt-templates";
6
7
  import type { Settings } from "../config/settings";
8
+ import type { ToolPathWithSource } from "../extensibility/custom-tools";
7
9
  import type { Skill } from "../extensibility/skills";
8
10
  import type { GoalModeState, GoalRuntime } from "../goals";
9
11
  import type { HindsightSessionState } from "../hindsight/state";
@@ -107,6 +109,21 @@ export interface ToolSession {
107
109
  skills?: Skill[];
108
110
  /** Pre-loaded prompt templates */
109
111
  promptTemplates?: PromptTemplate[];
112
+ /** Pre-loaded rules (forwarded to subagents to skip re-discovery). */
113
+ rules?: Rule[];
114
+ /**
115
+ * Pre-discovered extension source paths. Forwarded to subagents so they
116
+ * skip the FS scan but still re-bind extensions to their own session-scoped
117
+ * `ExtensionAPI` (cwd, eventBus, runtime). Inline extension factories
118
+ * (`<inline-N>`) are NOT included — those are session-local.
119
+ */
120
+ extensionPaths?: string[];
121
+ /**
122
+ * Pre-discovered custom-tool source paths from `.omp/tools/`, `.claude/tools/`,
123
+ * plugins, etc. Forwarded to subagents so they skip the FS scan but still
124
+ * re-bind tools to their own session-scoped `CustomToolAPI`.
125
+ */
126
+ customToolPaths?: ToolPathWithSource[];
110
127
  /** Whether LSP integrations are enabled */
111
128
  enableLsp?: boolean;
112
129
  /** Whether an edit-capable tool is available in this session (controls hashline output) */
@@ -19,6 +19,14 @@ export declare function uriHyperlink(uri: string, displayText: string): string;
19
19
  * `www.example.com` inputs are linked as `https://www.example.com`.
20
20
  */
21
21
  export declare function urlHyperlink(url: string, displayText: string): string;
22
+ /**
23
+ * Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL,
24
+ * bypassing terminal capability auto-detection. Used for auth prompts where
25
+ * an inert "click" label blocks login on terminals whose capabilities are
26
+ * not advertised. Still returns plain text when the user has explicitly
27
+ * opted out via `tui.hyperlinks=off`.
28
+ */
29
+ export declare function urlHyperlinkAlways(url: string, displayText: string): string;
22
30
  /**
23
31
  * Wrap `displayText` in an OSC 8 hyperlink pointing at a filesystem path.
24
32
  *
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": "15.10.8",
4
+ "version": "15.10.9",
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",
@@ -47,14 +47,14 @@
47
47
  "@agentclientprotocol/sdk": "0.22.1",
48
48
  "@babel/parser": "^7.29.7",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.10.8",
51
- "@oh-my-pi/omp-stats": "15.10.8",
52
- "@oh-my-pi/pi-agent-core": "15.10.8",
53
- "@oh-my-pi/pi-ai": "15.10.8",
54
- "@oh-my-pi/pi-mnemopi": "15.10.8",
55
- "@oh-my-pi/pi-natives": "15.10.8",
56
- "@oh-my-pi/pi-tui": "15.10.8",
57
- "@oh-my-pi/pi-utils": "15.10.8",
50
+ "@oh-my-pi/hashline": "15.10.9",
51
+ "@oh-my-pi/omp-stats": "15.10.9",
52
+ "@oh-my-pi/pi-agent-core": "15.10.9",
53
+ "@oh-my-pi/pi-ai": "15.10.9",
54
+ "@oh-my-pi/pi-mnemopi": "15.10.9",
55
+ "@oh-my-pi/pi-natives": "15.10.9",
56
+ "@oh-my-pi/pi-tui": "15.10.9",
57
+ "@oh-my-pi/pi-utils": "15.10.9",
58
58
  "@opentelemetry/api": "^1.9.1",
59
59
  "@opentelemetry/context-async-hooks": "^2.7.1",
60
60
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -66,8 +66,10 @@ async function loadTool(
66
66
  }
67
67
  }
68
68
 
69
- /** Tool path with optional source metadata */
70
- interface ToolPathWithSource {
69
+ /** Tool path with optional source metadata, suitable for forwarding from a
70
+ * parent session to a subagent so the subagent can re-bind tools to its own
71
+ * `CustomToolAPI` without redoing the filesystem scan. */
72
+ export interface ToolPathWithSource {
71
73
  path: string;
72
74
  source?: { provider: string; providerName: string; level: "user" | "project" };
73
75
  }
@@ -189,26 +191,19 @@ export async function loadCustomTools(
189
191
  }
190
192
 
191
193
  /**
192
- * Discover and load tools from standard locations via capability system:
193
- * 1. User and project tools discovered by capability providers
194
- * 2. Installed plugins (~/.omp/plugins/node_modules/*)
195
- * 3. Explicitly configured paths from settings or CLI
194
+ * Collect the absolute tool-source paths to load, without importing or
195
+ * binding factories. Hot path on session startup the scan walks
196
+ * `.omp/tools/`, `.claude/tools/`, the plugin tree, and any configured paths.
197
+ *
198
+ * Subagents reuse the parent's collected paths via the SDK's
199
+ * `preloadedCustomToolPaths` option, then call `loadCustomTools` themselves
200
+ * so each session re-binds factories with its own session-scoped
201
+ * `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
196
202
  *
197
203
  * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
198
204
  * @param cwd - Current working directory
199
- * @param builtInToolNames - Names of built-in tools to check for conflicts
200
205
  */
201
- export async function discoverAndLoadCustomTools(
202
- configuredPaths: string[],
203
- cwd: string,
204
- builtInToolNames: string[],
205
- pushPendingAction?: (action: {
206
- label: string;
207
- sourceToolName: string;
208
- apply(reason: string): Promise<AgentToolResult<unknown>>;
209
- reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
210
- }) => void,
211
- ) {
206
+ export async function discoverCustomToolPaths(configuredPaths: string[], cwd: string): Promise<ToolPathWithSource[]> {
212
207
  const allPathsWithSources: ToolPathWithSource[] = [];
213
208
  const seen = new Set<string>();
214
209
 
@@ -241,5 +236,34 @@ export async function discoverAndLoadCustomTools(
241
236
  addPath(resolvePath(configPath, cwd), { provider: "config", providerName: "Config", level: "project" });
242
237
  }
243
238
 
244
- return loadCustomTools(allPathsWithSources, cwd, builtInToolNames, pushPendingAction);
239
+ return allPathsWithSources;
240
+ }
241
+
242
+ /**
243
+ * Discover and load tools from standard locations via capability system:
244
+ * 1. User and project tools discovered by capability providers
245
+ * 2. Installed plugins (~/.omp/plugins/node_modules/*)
246
+ * 3. Explicitly configured paths from settings or CLI
247
+ *
248
+ * Composed of {@link discoverCustomToolPaths} (FS scan) + {@link loadCustomTools}
249
+ * (per-session binding). Subagents skip the first step and just call
250
+ * `loadCustomTools` against the parent's collected paths.
251
+ *
252
+ * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
253
+ * @param cwd - Current working directory
254
+ * @param builtInToolNames - Names of built-in tools to check for conflicts
255
+ */
256
+ export async function discoverAndLoadCustomTools(
257
+ configuredPaths: string[],
258
+ cwd: string,
259
+ builtInToolNames: string[],
260
+ pushPendingAction?: (action: {
261
+ label: string;
262
+ sourceToolName: string;
263
+ apply(reason: string): Promise<AgentToolResult<unknown>>;
264
+ reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
265
+ }) => void,
266
+ ) {
267
+ const pathsWithSources = await discoverCustomToolPaths(configuredPaths, cwd);
268
+ return loadCustomTools(pathsWithSources, cwd, builtInToolNames, pushPendingAction);
245
269
  }
@@ -5,6 +5,7 @@
5
5
  export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands";
6
6
  export {
7
7
  discoverAndLoadExtensions,
8
+ discoverExtensionPaths,
8
9
  ExtensionRuntimeNotInitializedError,
9
10
  loadExtensionFromFactory,
10
11
  loadExtensions,
@@ -475,16 +475,24 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
475
475
 
476
476
  return discovered;
477
477
  }
478
-
479
478
  /**
480
- * Discover and load extensions from standard locations.
479
+ * Discover absolute paths of extensions to load, without importing or
480
+ * binding factories. Hot path on session startup — the scan walks native
481
+ * `.omp`/`.pi` extension capabilities, the installed-plugin tree, and any
482
+ * configured paths.
483
+ *
484
+ * Subagents reuse the parent's collected paths via the SDK's
485
+ * `preloadedExtensionPaths` option, then call {@link loadExtensions} themselves
486
+ * so each session rebuilds Extension instances bound to its OWN
487
+ * `ExtensionAPI` (cwd, eventBus, runtime). Forwarding the parent's
488
+ * `LoadExtensionsResult` directly would reuse handlers/tools/commands that
489
+ * closed over the parent's `cwd` and event bus.
481
490
  */
482
- export async function discoverAndLoadExtensions(
491
+ export async function discoverExtensionPaths(
483
492
  configuredPaths: string[],
484
493
  cwd: string,
485
- eventBus?: EventBus,
486
494
  disabledExtensionIds: string[] = [],
487
- ): Promise<LoadExtensionsResult> {
495
+ ): Promise<string[]> {
488
496
  const allPaths: string[] = [];
489
497
  const seen = new Set<string>();
490
498
  const disabled = new Set(disabledExtensionIds);
@@ -545,5 +553,20 @@ export async function discoverAndLoadExtensions(
545
553
  addPath(resolved);
546
554
  }
547
555
 
548
- return loadExtensions(allPaths, cwd, eventBus);
556
+ return allPaths;
557
+ }
558
+
559
+ /**
560
+ * Discover and load extensions from standard locations. Composed of
561
+ * {@link discoverExtensionPaths} (FS scan) + {@link loadExtensions}
562
+ * (per-session binding).
563
+ */
564
+ export async function discoverAndLoadExtensions(
565
+ configuredPaths: string[],
566
+ cwd: string,
567
+ eventBus?: EventBus,
568
+ disabledExtensionIds: string[] = [],
569
+ ): Promise<LoadExtensionsResult> {
570
+ const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds);
571
+ return loadExtensions(paths, cwd, eventBus);
549
572
  }
@@ -1,4 +1,4 @@
1
- import * as fs from "node:fs/promises";
1
+ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as url from "node:url";
4
4
  import { isCompiledBinary } from "@oh-my-pi/pi-utils";
@@ -142,7 +142,31 @@ const LEGACY_PI_CODING_AGENT_SHIM_PATH = BUNFS_PACKAGE_ROOT
142
142
  // `Bun.resolveSync`, and hardcoding a relative source-tree path would break
143
143
  // installs where the bundled packages live at `node_modules/@oh-my-pi/pi-*`
144
144
  // rather than `packages/*`.
145
- const LEGACY_PI_PACKAGE_ROOT_OVERRIDES: Record<string, string> = {
145
+ //
146
+ // Every override target is validated against the on-disk filesystem at module
147
+ // init: any entry whose file is missing (e.g. a compiled binary where Bun's
148
+ // `--compile` quietly dropped an additional entrypoint — issue #2168) is left
149
+ // out so `resolveCanonicalPiSpecifier` falls through to `getResolvedSpecifier`,
150
+ // which throws under bunfs and triggers the catch in `rewriteLegacyPiImports`.
151
+ // That catch leaves the specifier untouched so Bun resolves the canonical
152
+ // `@oh-my-pi/pi-*` import from the extension's own `node_modules` instead of
153
+ // emitting a bunfs `file://` URL to a module that isn't actually present.
154
+
155
+ /**
156
+ * Drop overrides whose targets are missing on disk so they can fall through to
157
+ * the canonical-resolution path. Exported for the test seam in #2168.
158
+ *
159
+ * `pathExistsSync` defaults to `fs.existsSync`; the tests inject a stub to
160
+ * simulate the missing-entrypoint failure mode without touching the real FS.
161
+ */
162
+ export function __validateLegacyPiPackageRootOverrides(
163
+ candidates: Record<string, string>,
164
+ pathExistsSync: (p: string) => boolean = fs.existsSync,
165
+ ): Record<string, string> {
166
+ return Object.fromEntries(Object.entries(candidates).filter(([, candidate]) => pathExistsSync(candidate)));
167
+ }
168
+
169
+ const LEGACY_PI_PACKAGE_ROOT_OVERRIDES = __validateLegacyPiPackageRootOverrides({
146
170
  [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
147
171
  [`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
148
172
  ...(BUNFS_PACKAGE_ROOT
@@ -153,7 +177,7 @@ const LEGACY_PI_PACKAGE_ROOT_OVERRIDES: Record<string, string> = {
153
177
  [`${CANONICAL_PI_SCOPE}/pi-utils`]: bunfsPath("utils", "src", "index.js"),
154
178
  }
155
179
  : {}),
156
- };
180
+ });
157
181
 
158
182
  let isLegacyPiSpecifierShimInstalled = false;
159
183
 
@@ -253,7 +277,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
253
277
 
254
278
  async function pathExists(p: string): Promise<boolean> {
255
279
  try {
256
- await fs.stat(p);
280
+ await fs.promises.stat(p);
257
281
  return true;
258
282
  } catch {
259
283
  return false;
@@ -267,7 +291,7 @@ function hasSourceModuleExtension(p: string): boolean {
267
291
 
268
292
  async function resolveSourceModuleFile(basePath: string): Promise<string | null> {
269
293
  try {
270
- const stats = await fs.stat(basePath);
294
+ const stats = await fs.promises.stat(basePath);
271
295
  if (stats.isFile()) {
272
296
  // Non-source files (JSON, WASM, text assets, etc.) bypass the on-load
273
297
  // rewrite hook so Bun's native loaders handle them; our hook would
@@ -475,7 +499,7 @@ const hookedExtensionEntries = new Set<string>();
475
499
  /** Resolve symlinks in a path, falling back to the input if realpath fails. */
476
500
  async function realpathOrSelf(p: string): Promise<string> {
477
501
  try {
478
- return await fs.realpath(p);
502
+ return await fs.promises.realpath(p);
479
503
  } catch {
480
504
  return p;
481
505
  }