@oh-my-pi/pi-coding-agent 15.5.10 → 15.5.12

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 (42) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli-commands.d.ts +19 -0
  3. package/dist/types/commands/install.d.ts +51 -0
  4. package/dist/types/discovery/index.d.ts +1 -0
  5. package/dist/types/discovery/omp-extension-roots.d.ts +43 -0
  6. package/dist/types/discovery/omp-plugins.d.ts +1 -0
  7. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +14 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  9. package/dist/types/extensibility/plugins/loader.d.ts +12 -2
  10. package/dist/types/index.d.ts +3 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  12. package/dist/types/modes/ultrathink.d.ts +10 -0
  13. package/dist/types/session/redis-session-storage.d.ts +124 -0
  14. package/dist/types/session/sql-session-storage.d.ts +141 -0
  15. package/dist/types/tools/todo-write.d.ts +30 -0
  16. package/examples/sdk/12-redis-sessions.ts +54 -0
  17. package/examples/sdk/13-sql-sessions.ts +61 -0
  18. package/package.json +8 -8
  19. package/scripts/build-binary.ts +14 -9
  20. package/src/cli-commands.ts +44 -0
  21. package/src/cli.ts +2 -32
  22. package/src/commands/install.ts +107 -0
  23. package/src/discovery/index.ts +1 -0
  24. package/src/discovery/omp-extension-roots.ts +190 -0
  25. package/src/discovery/omp-plugins.ts +383 -0
  26. package/src/extensibility/legacy-pi-coding-agent-shim.ts +15 -0
  27. package/src/extensibility/plugins/legacy-pi-compat.ts +63 -22
  28. package/src/extensibility/plugins/loader.ts +43 -18
  29. package/src/index.ts +3 -0
  30. package/src/internal-urls/docs-index.generated.ts +2 -2
  31. package/src/main.ts +12 -0
  32. package/src/memories/index.ts +8 -3
  33. package/src/modes/components/custom-editor.ts +3 -0
  34. package/src/modes/interactive-mode.ts +243 -12
  35. package/src/modes/ultrathink.ts +79 -0
  36. package/src/prompts/system/ultrathink-notice.md +3 -0
  37. package/src/session/agent-session.ts +28 -0
  38. package/src/session/redis-session-storage.ts +481 -0
  39. package/src/session/sql-session-storage.ts +565 -0
  40. package/src/tools/read.ts +23 -6
  41. package/src/tools/todo-write.ts +64 -0
  42. package/src/tools/write.ts +40 -6
@@ -0,0 +1,61 @@
1
+ /**
2
+ * SQL-Backed Sessions (PostgreSQL / MySQL / SQLite)
3
+ *
4
+ * Store session JSONL in a SQL database via `bun:sql`. One table, one row
5
+ * per session file — works against PostgreSQL, MySQL/MariaDB, and SQLite
6
+ * with the dialect picked automatically from the connection URL.
7
+ *
8
+ * Useful when:
9
+ * - sessions need to be queryable from existing analytics infra (just JOIN
10
+ * against the rest of your warehouse);
11
+ * - a managed Postgres/MySQL instance is already in place and adding Redis
12
+ * isn't worth the operational surface;
13
+ * - you want a single durable file at rest (SQLite) without coding directly
14
+ * against `bun:sqlite`.
15
+ *
16
+ * Tool artifacts and image blobs are out of scope: `ArtifactManager` /
17
+ * `BlobStore` keep writing to `~/.omp/agent/...`. Reach for object storage
18
+ * if you need those off-host too.
19
+ */
20
+
21
+ import { createAgentSession, SessionManager, SqlSessionStorage } from "@oh-my-pi/pi-coding-agent";
22
+ import { SQL } from "bun";
23
+
24
+ // Pick one — Bun.SQL auto-detects the dialect from the URL scheme.
25
+ //
26
+ // postgres://user:pass@host:5432/db
27
+ // mysql://user:pass@host:3306/db
28
+ // sqlite:/absolute/path/to/sessions.sqlite
29
+ // sqlite::memory: // ephemeral
30
+ const client = new SQL(process.env.SESSIONS_DB_URL ?? "sqlite::memory:");
31
+
32
+ // `create()` runs `CREATE TABLE IF NOT EXISTS` (with the right DDL for the
33
+ // dialect) and warms the in-memory mirror with every existing row.
34
+ const storage = await SqlSessionStorage.create({
35
+ client,
36
+ table: "omp_session_files", // optional, this is the default
37
+ // createTable: false, // set if migrations are owned elsewhere
38
+ });
39
+
40
+ const sessionDir = "/sessions/my-project";
41
+
42
+ // 1) Fresh persistent session, JSONL backed by SQL.
43
+ const { session } = await createAgentSession({
44
+ sessionManager: SessionManager.create(process.cwd(), sessionDir, storage),
45
+ });
46
+ console.log(`New SQL session (${storage.adapter}):`, session.sessionFile);
47
+
48
+ // 2) Continue the most recent session for this `sessionDir`.
49
+ const { session: continued } = await createAgentSession({
50
+ sessionManager: await SessionManager.continueRecent(process.cwd(), sessionDir, storage),
51
+ });
52
+ console.log("Resumed:", continued.sessionFile);
53
+
54
+ // 3) Enumerate every session row under this directory prefix.
55
+ const sessions = await SessionManager.list(process.cwd(), sessionDir, storage);
56
+ console.log(`Found ${sessions.length} sessions under ${sessionDir}`);
57
+
58
+ // On graceful shutdown, drain any background writes the writer queued and
59
+ // close the connection.
60
+ await storage.drain();
61
+ await client.end?.();
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.5.10",
4
+ "version": "15.5.12",
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,13 +47,13 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.5.10",
51
- "@oh-my-pi/omp-stats": "15.5.10",
52
- "@oh-my-pi/pi-agent-core": "15.5.10",
53
- "@oh-my-pi/pi-ai": "15.5.10",
54
- "@oh-my-pi/pi-natives": "15.5.10",
55
- "@oh-my-pi/pi-tui": "15.5.10",
56
- "@oh-my-pi/pi-utils": "15.5.10",
50
+ "@oh-my-pi/hashline": "15.5.12",
51
+ "@oh-my-pi/omp-stats": "15.5.12",
52
+ "@oh-my-pi/pi-agent-core": "15.5.12",
53
+ "@oh-my-pi/pi-ai": "15.5.12",
54
+ "@oh-my-pi/pi-natives": "15.5.12",
55
+ "@oh-my-pi/pi-tui": "15.5.12",
56
+ "@oh-my-pi/pi-utils": "15.5.12",
57
57
  "@puppeteer/browsers": "^2.13.0",
58
58
  "@types/turndown": "5.0.6",
59
59
  "@xterm/headless": "^6.0.0",
@@ -56,17 +56,22 @@ async function main(): Promise<void> {
56
56
  "../stats/src/sync-worker.ts",
57
57
  "./src/tools/browser/tab-worker-entry.ts",
58
58
  "./src/eval/js/worker-entry.ts",
59
- // Legacy pi-* extension compat shims served by `legacy-pi-compat.ts`.
60
- // Both are reached only via the computed `TYPEBOX_SHIM_PATH` /
61
- // `LEGACY_PI_AI_SHIM_PATH` constants (which `--compile`'s static
62
- // analyzer cannot trace), so each shim must be listed here to land
63
- // in bunfs alongside the workers above. The bunfs entry path is
64
- // `--root`-relative with a `.js` extension, e.g.
65
- // `/$bunfs/root/packages/coding-agent/src/extensibility/typebox.js`,
66
- // which is what the `isCompiledBinary()` branch in
67
- // `legacy-pi-compat.ts` resolves to at runtime.
59
+ // Legacy pi-* extension compat entrypoints served by
60
+ // `legacy-pi-compat.ts`. These are reached via computed bunfs paths
61
+ // (which `--compile`'s static analyzer cannot trace), so each must be
62
+ // listed here to land in bunfs at
63
+ // `/$bunfs/root/packages/<pkg>/<entry>.js`. The coding-agent's own
64
+ // `./src/index.ts` is intentionally NOT listed: bun --compile silently
65
+ // breaks the CLI entry when the same package's barrel appears as an
66
+ // extra entrypoint (issue #1474), so legacy `pi-coding-agent` imports
67
+ // resolve through `legacy-pi-coding-agent-shim.ts` instead.
68
+ "../agent/src/index.ts",
69
+ "../natives/native/index.js",
70
+ "../tui/src/index.ts",
71
+ "../utils/src/index.ts",
68
72
  "./src/extensibility/typebox.ts",
69
73
  "./src/extensibility/legacy-pi-ai-shim.ts",
74
+ "./src/extensibility/legacy-pi-coding-agent-shim.ts",
70
75
  "--outfile",
71
76
  "dist/omp",
72
77
  ],
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Top-level CLI command table.
3
+ *
4
+ * Lives in its own module (importable without side effects) so that tests can
5
+ * inspect the registered subcommands without triggering the side-effectful
6
+ * top-level await in `cli.ts`. Adding a new subcommand here is enough to make
7
+ * `runCli` route to it instead of forwarding the argv as a prompt to
8
+ * `launch` — see #1496 for the original "args silently leak to the LLM"
9
+ * regression that motivated the split.
10
+ */
11
+ import type { CommandEntry } from "@oh-my-pi/pi-utils/cli";
12
+
13
+ export const commands: CommandEntry[] = [
14
+ { name: "launch", load: () => import("./commands/launch").then(m => m.default) },
15
+ { name: "acp", load: () => import("./commands/acp").then(m => m.default) },
16
+ { name: "auth-broker", load: () => import("./commands/auth-broker").then(m => m.default) },
17
+ { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) },
18
+ { name: "agents", load: () => import("./commands/agents").then(m => m.default) },
19
+ { name: "commit", load: () => import("./commands/commit").then(m => m.default) },
20
+ { name: "config", load: () => import("./commands/config").then(m => m.default) },
21
+ { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
22
+ { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
23
+ { name: "install", load: () => import("./commands/install").then(m => m.default) },
24
+ { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
25
+ { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
26
+ { name: "shell", load: () => import("./commands/shell").then(m => m.default) },
27
+ { name: "read", load: () => import("./commands/read").then(m => m.default) },
28
+ { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
29
+ { name: "stats", load: () => import("./commands/stats").then(m => m.default) },
30
+ { name: "update", load: () => import("./commands/update").then(m => m.default) },
31
+ { name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] },
32
+ { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
33
+ ];
34
+
35
+ /**
36
+ * Return true when `first` matches a registered subcommand name or alias.
37
+ *
38
+ * Flags (`-…`) and `@file` arguments are never subcommands; for those the CLI
39
+ * runner skips ahead to the default `launch` command.
40
+ */
41
+ export function isSubcommand(first: string | undefined): boolean {
42
+ if (!first || first.startsWith("-") || first.startsWith("@")) return false;
43
+ return commands.some(entry => entry.name === first || entry.aliases?.includes(first));
44
+ }
package/src/cli.ts CHANGED
@@ -10,7 +10,8 @@ procmgr.scrubProcessEnv();
10
10
  * CLI entry point — registers all commands explicitly and delegates to the
11
11
  * lightweight CLI runner from pi-utils.
12
12
  */
13
- import { type CliConfig, type CommandEntry, run } from "@oh-my-pi/pi-utils/cli";
13
+ import { type CliConfig, run } from "@oh-my-pi/pi-utils/cli";
14
+ import { commands, isSubcommand } from "./cli-commands";
14
15
 
15
16
  if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
16
17
  process.stderr.write(
@@ -21,27 +22,6 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
21
22
 
22
23
  process.title = APP_NAME;
23
24
 
24
- const commands: CommandEntry[] = [
25
- { name: "launch", load: () => import("./commands/launch").then(m => m.default) },
26
- { name: "acp", load: () => import("./commands/acp").then(m => m.default) },
27
- { name: "auth-broker", load: () => import("./commands/auth-broker").then(m => m.default) },
28
- { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) },
29
- { name: "agents", load: () => import("./commands/agents").then(m => m.default) },
30
- { name: "commit", load: () => import("./commands/commit").then(m => m.default) },
31
- { name: "config", load: () => import("./commands/config").then(m => m.default) },
32
- { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
33
- { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
34
- { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
35
- { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
36
- { name: "shell", load: () => import("./commands/shell").then(m => m.default) },
37
- { name: "read", load: () => import("./commands/read").then(m => m.default) },
38
- { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
39
- { name: "stats", load: () => import("./commands/stats").then(m => m.default) },
40
- { name: "update", load: () => import("./commands/update").then(m => m.default) },
41
- { name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] },
42
- { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
43
- ];
44
-
45
25
  async function showHelp(config: CliConfig): Promise<void> {
46
26
  const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli");
47
27
  const { getExtraHelpText } = await import("./cli/args");
@@ -51,16 +31,6 @@ async function showHelp(config: CliConfig): Promise<void> {
51
31
  process.stdout.write(`\n${extra}\n`);
52
32
  }
53
33
  }
54
-
55
- /**
56
- * Determine whether argv[0] is a known subcommand name.
57
- * If not, the entire argv is treated as args to the default "launch" command.
58
- */
59
- function isSubcommand(first: string | undefined): boolean {
60
- if (!first || first.startsWith("-") || first.startsWith("@")) return false;
61
- return commands.some(e => e.name === first || e.aliases?.includes(first));
62
- }
63
-
64
34
  /**
65
35
  * Smoke-test entry. Spawns the stats sync worker, pings it, exits.
66
36
  *
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `omp install <target>` — top-level convenience over `omp plugin install` /
3
+ * `omp plugin link`.
4
+ *
5
+ * The docs (omp.sh/docs/extension-authoring) advertise
6
+ *
7
+ * omp install ./my-extension
8
+ *
9
+ * as a third loading mechanism that "symlinks the directory into the plugin
10
+ * set and watches it for changes". Before this command existed, `install` was
11
+ * not a registered subcommand, so the CLI runner forwarded the argv to the
12
+ * default `launch` command and the model received `install ./my-extension`
13
+ * as an initial prompt — see #1496.
14
+ *
15
+ * Local-path targets (`./foo`, `/abs/foo`, `~/foo`, or an existing directory)
16
+ * route to `plugin link` so they are symlinked into the plugin set, matching
17
+ * the documented behavior. Everything else (`pkg`, `pkg@1.2.3`,
18
+ * `name@marketplace`) routes to `plugin install`.
19
+ */
20
+
21
+ import { existsSync } from "node:fs";
22
+ import * as path from "node:path";
23
+ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
24
+ import { type PluginAction, type PluginCommandArgs, runPluginCommand } from "../cli/plugin-cli";
25
+ import { initTheme } from "../modes/theme/theme";
26
+
27
+ /**
28
+ * Heuristic used to decide whether `omp install <target>` should `link` a
29
+ * local directory or `install` a remote spec. Exported for tests.
30
+ */
31
+ export function looksLikeLocalPath(target: string): boolean {
32
+ if (target.startsWith(".") || target.startsWith("/") || target.startsWith("~")) return true;
33
+ // Windows drive prefix (e.g. `C:\foo`).
34
+ if (/^[a-zA-Z]:[\\/]/.test(target)) return true;
35
+ // Bare names that happen to exist as a local directory.
36
+ try {
37
+ return existsSync(path.resolve(target));
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export default class Install extends Command {
44
+ static description = "Install or link an extension package (alias of `plugin install`/`plugin link`)";
45
+
46
+ static args = {
47
+ targets: Args.string({
48
+ description: "Local path, npm spec, or marketplace ref (e.g. ./my-ext, my-pkg@1.2.3, name@marketplace)",
49
+ required: false,
50
+ multiple: true,
51
+ }),
52
+ };
53
+
54
+ static flags = {
55
+ json: Flags.boolean({ description: "Output JSON" }),
56
+ force: Flags.boolean({ description: "Force install" }),
57
+ "dry-run": Flags.boolean({ description: "Show actions without applying changes" }),
58
+ scope: Flags.string({
59
+ description: 'Install scope: "user" (default) or "project" (marketplace installs only)',
60
+ options: ["user", "project"],
61
+ }),
62
+ };
63
+
64
+ async run(): Promise<void> {
65
+ const { args, flags } = await this.parse(Install);
66
+ const targets = Array.isArray(args.targets) ? args.targets : args.targets ? [args.targets] : [];
67
+
68
+ if (targets.length === 0) {
69
+ process.stderr.write("Usage: omp install <path | npm-spec | name@marketplace> [...]\n");
70
+ process.exit(1);
71
+ }
72
+
73
+ await initTheme();
74
+
75
+ // Split into local-paths (→ link) and remote specs (→ install). Each batch
76
+ // preserves user-supplied order so progress output reads naturally.
77
+ const localPaths: string[] = [];
78
+ const remoteSpecs: string[] = [];
79
+ for (const target of targets) {
80
+ if (looksLikeLocalPath(target)) localPaths.push(target);
81
+ else remoteSpecs.push(target);
82
+ }
83
+
84
+ const baseFlags: PluginCommandArgs["flags"] = {
85
+ json: flags.json,
86
+ force: flags.force,
87
+ dryRun: flags["dry-run"],
88
+ scope: flags.scope as "user" | "project" | undefined,
89
+ };
90
+
91
+ for (const localPath of localPaths) {
92
+ await runPluginCommand({
93
+ action: "link" satisfies PluginAction,
94
+ args: [localPath],
95
+ flags: baseFlags,
96
+ });
97
+ }
98
+
99
+ if (remoteSpecs.length > 0) {
100
+ await runPluginCommand({
101
+ action: "install" satisfies PluginAction,
102
+ args: remoteSpecs,
103
+ flags: baseFlags,
104
+ });
105
+ }
106
+ }
107
+ }
@@ -32,6 +32,7 @@ import "./gemini";
32
32
  import "./opencode";
33
33
  import "./github";
34
34
  import "./mcp-json";
35
+ import "./omp-plugins";
35
36
  import "./ssh";
36
37
  import "./vscode";
37
38
  import "./windsurf";
@@ -0,0 +1,190 @@
1
+ /**
2
+ * OMP extension package roots.
3
+ *
4
+ * An "extension package root" is a directory configured via either
5
+ * `extensions:` in user/project settings or the `--extension`/`-e` CLI flag
6
+ * that points to a packaged extension on disk. The package's standard
7
+ * sub-directories (`skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
8
+ * `prompts/`, `.mcp.json`) are wired into discovery by `omp-plugins.ts`.
9
+ *
10
+ * CLI-provided paths are injected via {@link injectOmpExtensionCliRoots}
11
+ * before discovery runs; settings paths are read lazily from
12
+ * `<scope>/settings.json` in {@link listOmpExtensionRoots} to mirror what
13
+ * `loadExtensionModules` already does.
14
+ *
15
+ * @see ./omp-plugins.ts
16
+ * @see ./builtin.ts `loadExtensionModules`
17
+ */
18
+ import * as fs from "node:fs/promises";
19
+ import * as path from "node:path";
20
+ import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
21
+ import { readDirEntries, readFile } from "../capability/fs";
22
+ import type { LoadContext } from "../capability/types";
23
+ import { getEnabledPlugins } from "../extensibility/plugins/loader";
24
+ import { expandTilde } from "../tools/path-utils";
25
+
26
+ /** A resolved extension package directory wired into the discovery surfaces. */
27
+ export interface OmpExtensionRoot {
28
+ /** Absolute path to the package directory. */
29
+ path: string;
30
+ /** Stable display name (basename of the package directory). */
31
+ name: string;
32
+ /** Scope from which the path was sourced. */
33
+ level: "user" | "project";
34
+ }
35
+
36
+ interface InjectedRoot {
37
+ path: string;
38
+ level: "user" | "project";
39
+ }
40
+
41
+ let injectedCliRoots: InjectedRoot[] = [];
42
+
43
+ /**
44
+ * Register CLI-provided extension package paths (e.g. from `--extension`/`-e`)
45
+ * so the sub-discovery providers can find their sibling `skills/`, `hooks/`,
46
+ * etc. Paths that do not resolve to a directory are silently dropped — file
47
+ * entrypoints have no package sub-tree to scan.
48
+ *
49
+ * Call once during startup before any capability load. Repeated calls extend
50
+ * the registered set; {@link clearOmpExtensionCliRoots} resets for tests.
51
+ */
52
+ export function injectOmpExtensionCliRoots(paths: readonly string[], home: string, cwd: string): void {
53
+ if (paths.length === 0) return;
54
+ const expanded = paths.map(raw => {
55
+ const tilde = expandTilde(raw, home);
56
+ return path.isAbsolute(tilde) ? tilde : path.resolve(cwd, tilde);
57
+ });
58
+ const merged = new Map<string, InjectedRoot>();
59
+ for (const root of injectedCliRoots) merged.set(root.path, root);
60
+ for (const resolved of expanded) {
61
+ // CLI scope mirrors how `--extension` is treated elsewhere — user-level overrides win.
62
+ if (!merged.has(resolved)) merged.set(resolved, { path: resolved, level: "user" });
63
+ }
64
+ injectedCliRoots = [...merged.values()];
65
+ }
66
+
67
+ /** Drop every CLI-injected root. Tests use this between cases. */
68
+ export function clearOmpExtensionCliRoots(): void {
69
+ injectedCliRoots = [];
70
+ }
71
+
72
+ /** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */
73
+ export function getInjectedOmpExtensionCliRoots(): readonly OmpExtensionRoot[] {
74
+ return injectedCliRoots.map(({ path: p, level }) => ({ path: p, level, name: path.basename(p) }));
75
+ }
76
+
77
+ interface ScopeDirs {
78
+ project: string;
79
+ user: string;
80
+ }
81
+
82
+ function scopeDirs(ctx: LoadContext): ScopeDirs {
83
+ return {
84
+ project: path.join(ctx.cwd, ".omp"),
85
+ user: path.join(ctx.home, ".omp", "agent"),
86
+ };
87
+ }
88
+
89
+ async function readSettingsExtensions(settingsPath: string): Promise<string[]> {
90
+ const content = await readFile(settingsPath);
91
+ if (!content) return [];
92
+ const parsed = tryParseJson<{ extensions?: unknown }>(content);
93
+ const raw = parsed?.extensions;
94
+ if (!Array.isArray(raw)) return [];
95
+ return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
96
+ }
97
+
98
+ function resolveAgainst(raw: string, ctx: LoadContext): string {
99
+ const tilde = expandTilde(raw, ctx.home);
100
+ return path.isAbsolute(tilde) ? tilde : path.resolve(ctx.cwd, tilde);
101
+ }
102
+
103
+ async function isDirectory(p: string): Promise<boolean> {
104
+ const entries = await readDirEntries(p);
105
+ if (entries.length > 0) return true;
106
+ // Empty directory still counts; cache returns [] for both empty and missing.
107
+ // Disambiguate with a single stat — only hit when the cached listing is empty.
108
+ try {
109
+ const stat = await fs.stat(p);
110
+ return stat.isDirectory();
111
+ } catch (err) {
112
+ if (isEnoent(err)) return false;
113
+ throw err;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Resolve every configured extension package directory for the given context.
119
+ *
120
+ * Sources, in order of precedence (later entries with the same absolute path
121
+ * are dropped):
122
+ *
123
+ * 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
124
+ * 2. Project `<cwd>/.omp/settings.json#extensions`
125
+ * 3. User `~/.omp/agent/settings.json#extensions`
126
+ * 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
127
+ * `omp install <pkg>` / `omp plugin install` / `omp plugin link`)
128
+ *
129
+ * Only entries that resolve to a directory on disk are returned; file
130
+ * entrypoints contribute zero sub-discovery surface and are filtered out.
131
+ * Installed-plugin enumeration failures (missing lockfile, unreadable
132
+ * `package.json`, etc.) are logged at `debug` and degrade gracefully — the
133
+ * other sources still surface.
134
+ */
135
+ export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtensionRoot[]> {
136
+ const { project, user } = scopeDirs(ctx);
137
+ const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([
138
+ readSettingsExtensions(path.join(project, "settings.json")),
139
+ readSettingsExtensions(path.join(user, "settings.json")),
140
+ listInstalledPluginRoots(ctx),
141
+ ]);
142
+
143
+ const candidates: InjectedRoot[] = [
144
+ ...injectedCliRoots,
145
+ ...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })),
146
+ ...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })),
147
+ ...installedPlugins,
148
+ ];
149
+
150
+ // First-seen-wins dedup preserves CLI > project-settings > user-settings > installed precedence.
151
+ const seen = new Set<string>();
152
+ const unique: InjectedRoot[] = [];
153
+ for (const candidate of candidates) {
154
+ if (seen.has(candidate.path)) continue;
155
+ seen.add(candidate.path);
156
+ unique.push(candidate);
157
+ }
158
+
159
+ const directoryFlags = await Promise.all(unique.map(c => isDirectory(c.path)));
160
+ const roots: OmpExtensionRoot[] = [];
161
+ for (let i = 0; i < unique.length; i++) {
162
+ if (!directoryFlags[i]) continue;
163
+ const { path: p, level } = unique[i];
164
+ roots.push({ path: p, level, name: path.basename(p) });
165
+ }
166
+ return roots;
167
+ }
168
+
169
+ /**
170
+ * Enumerate every enabled installed plugin's package directory so its
171
+ * conventional `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
172
+ * `prompts/`, and `.mcp.json` are wired into discovery — mirrors how
173
+ * `getAllPluginExtensionPaths` already feeds the extension factory loader.
174
+ *
175
+ * Marketplace and `omp plugin link` installs write to the plugin manager's
176
+ * `node_modules` (or symlink into it) rather than to `extensions:` in
177
+ * settings; without this branch the sub-discovery provider would still miss
178
+ * everything those install paths produce.
179
+ */
180
+ async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
181
+ try {
182
+ const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home });
183
+ // Installed plugins are always user-scope; project disablement is already
184
+ // honored by `getEnabledPlugins` via `loadProjectOverrides`.
185
+ return plugins.map(({ path: p }) => ({ path: p, level: "user" }));
186
+ } catch (err) {
187
+ logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
188
+ return [];
189
+ }
190
+ }