@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17

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 (143) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli.js +3531 -3812
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/model-discovery.d.ts +1 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/settings-schema.d.ts +12 -1
  11. package/dist/types/eval/agent-bridge.d.ts +19 -0
  12. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  13. package/dist/types/eval/js/shared/types.d.ts +1 -1
  14. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  15. package/dist/types/eval/py/executor.d.ts +1 -1
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  17. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  18. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  19. package/dist/types/hindsight/content.d.ts +2 -10
  20. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  21. package/dist/types/main.d.ts +2 -0
  22. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  25. package/dist/types/modes/components/session-selector.d.ts +25 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  30. package/dist/types/session/agent-session.d.ts +6 -6
  31. package/dist/types/session/provider-image-budget.d.ts +3 -0
  32. package/dist/types/session/session-context.d.ts +6 -5
  33. package/dist/types/task/isolation-runner.d.ts +128 -0
  34. package/dist/types/task/parallel.d.ts +4 -0
  35. package/dist/types/task/worktree.d.ts +14 -1
  36. package/dist/types/thinking.d.ts +23 -1
  37. package/dist/types/tiny/title-client.d.ts +2 -2
  38. package/dist/types/tools/eval-render.d.ts +3 -0
  39. package/dist/types/tools/eval.d.ts +11 -17
  40. package/dist/types/tools/todo.d.ts +26 -28
  41. package/dist/types/tui/output-block.d.ts +8 -0
  42. package/dist/types/utils/image-resize.d.ts +2 -0
  43. package/dist/types/utils/tools-manager.d.ts +2 -0
  44. package/dist/types/web/search/providers/exa.d.ts +2 -0
  45. package/package.json +12 -12
  46. package/scripts/build-binary.ts +18 -4
  47. package/src/auto-thinking/classifier.ts +7 -2
  48. package/src/cli/args.ts +4 -5
  49. package/src/cli/flag-tables.ts +3 -3
  50. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  51. package/src/cli/gallery-fixtures/shell.ts +15 -23
  52. package/src/cli/profile-alias.ts +38 -7
  53. package/src/cli/session-picker.ts +17 -3
  54. package/src/cli/usage-cli.ts +5 -1
  55. package/src/commands/launch.ts +3 -3
  56. package/src/config/model-discovery.ts +59 -8
  57. package/src/config/model-registry.ts +74 -3
  58. package/src/config/settings-schema.ts +13 -1
  59. package/src/discovery/omp-extension-roots.ts +1 -3
  60. package/src/edit/renderer.ts +34 -12
  61. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  62. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  63. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  64. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  65. package/src/eval/agent-bridge.ts +301 -71
  66. package/src/eval/jl/prelude.jl +32 -227
  67. package/src/eval/jl/runner.jl +38 -12
  68. package/src/eval/js/shared/helpers.ts +1 -114
  69. package/src/eval/js/shared/prelude.txt +13 -27
  70. package/src/eval/js/shared/runtime.ts +0 -6
  71. package/src/eval/js/shared/types.ts +1 -1
  72. package/src/eval/js/worker-protocol.ts +1 -1
  73. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  74. package/src/eval/py/executor.ts +1 -1
  75. package/src/eval/py/prelude.py +47 -105
  76. package/src/eval/py/runner.py +0 -6
  77. package/src/eval/rb/prelude.rb +21 -189
  78. package/src/eval/rb/runner.rb +116 -9
  79. package/src/export/html/tool-views.generated.js +29 -29
  80. package/src/extensibility/extensions/wrapper.ts +3 -2
  81. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  83. package/src/extensibility/plugins/loader.ts +71 -23
  84. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  85. package/src/extensibility/tool-event-input.ts +57 -0
  86. package/src/hindsight/content.ts +21 -12
  87. package/src/internal-urls/docs-index.generated.txt +1 -1
  88. package/src/internal-urls/local-protocol.ts +100 -53
  89. package/src/main.ts +15 -4
  90. package/src/mcp/tool-bridge.ts +27 -2
  91. package/src/mnemopi/state.ts +5 -2
  92. package/src/modes/acp/acp-event-mapper.ts +7 -2
  93. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  94. package/src/modes/components/chat-transcript-builder.ts +6 -0
  95. package/src/modes/components/custom-editor.test.ts +18 -1
  96. package/src/modes/components/custom-editor.ts +77 -45
  97. package/src/modes/components/hook-editor.ts +15 -2
  98. package/src/modes/components/plugin-settings.ts +7 -1
  99. package/src/modes/components/session-selector.ts +143 -29
  100. package/src/modes/components/settings-selector.ts +2 -2
  101. package/src/modes/components/status-line/component.ts +52 -8
  102. package/src/modes/components/status-line/segments.ts +5 -1
  103. package/src/modes/components/status-line/types.ts +1 -0
  104. package/src/modes/components/welcome.ts +12 -14
  105. package/src/modes/controllers/command-controller.ts +21 -5
  106. package/src/modes/controllers/input-controller.ts +115 -3
  107. package/src/modes/controllers/selector-controller.ts +19 -1
  108. package/src/modes/interactive-mode.ts +3 -3
  109. package/src/modes/rpc/rpc-mode.ts +6 -0
  110. package/src/modes/utils/copy-targets.ts +7 -2
  111. package/src/modes/utils/ui-helpers.ts +3 -3
  112. package/src/prompts/system/system-prompt.md +3 -3
  113. package/src/prompts/system/workflow-notice.md +3 -3
  114. package/src/prompts/tools/bash.md +16 -0
  115. package/src/prompts/tools/eval.md +19 -19
  116. package/src/prompts/tools/todo.md +1 -1
  117. package/src/sdk.ts +8 -10
  118. package/src/session/agent-session.ts +422 -97
  119. package/src/session/provider-image-budget.ts +86 -0
  120. package/src/session/session-context.ts +14 -7
  121. package/src/session/session-storage.ts +24 -2
  122. package/src/session/snapcompact-inline.ts +19 -3
  123. package/src/slash-commands/builtin-registry.ts +0 -22
  124. package/src/slash-commands/helpers/usage-report.ts +9 -1
  125. package/src/task/index.ts +61 -207
  126. package/src/task/isolation-runner.ts +354 -0
  127. package/src/task/parallel.ts +6 -1
  128. package/src/task/worktree.ts +46 -9
  129. package/src/thinking.ts +29 -2
  130. package/src/tiny/title-client.ts +75 -21
  131. package/src/tools/ask.ts +44 -38
  132. package/src/tools/bash.ts +9 -2
  133. package/src/tools/browser/tab-worker.ts +1 -1
  134. package/src/tools/eval-render.ts +34 -27
  135. package/src/tools/eval.ts +100 -103
  136. package/src/tools/index.ts +8 -1
  137. package/src/tools/read.ts +136 -60
  138. package/src/tools/todo.ts +60 -64
  139. package/src/tui/code-cell.ts +1 -1
  140. package/src/tui/output-block.ts +11 -0
  141. package/src/utils/image-resize.ts +30 -0
  142. package/src/utils/tools-manager.ts +67 -10
  143. package/src/web/search/providers/exa.ts +85 -1
@@ -6,6 +6,7 @@ import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai
6
6
  import type { Settings } from "../../config/settings";
7
7
  import type { Theme } from "../../modes/theme/theme";
8
8
  import { type ApprovalMode, formatApprovalPrompt, requiresApproval } from "../../tools/approval";
9
+ import { normalizeToolEventInput } from "../tool-event-input";
9
10
  import { applyToolProxy } from "../tool-proxy";
10
11
  import type { ExtensionRunner } from "./runner";
11
12
  import type { RegisteredTool, ToolCallEventResult } from "./types";
@@ -185,7 +186,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
185
186
  type: "tool_call",
186
187
  toolName: this.tool.name,
187
188
  toolCallId,
188
- input: params as Record<string, unknown>,
189
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
189
190
  })) as ToolCallEventResult | undefined;
190
191
 
191
192
  if (callResult?.block) {
@@ -220,7 +221,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
220
221
  type: "tool_result",
221
222
  toolName: this.tool.name,
222
223
  toolCallId,
223
- input: params as Record<string, unknown>,
224
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
224
225
  content: result.content,
225
226
  details: result.details,
226
227
  isError: !!executionError,
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Static, TSchema } from "@oh-my-pi/pi-ai";
6
+ import { normalizeToolEventInput } from "../tool-event-input";
6
7
  import { applyToolProxy } from "../tool-proxy";
7
8
  import type { HookRunner } from "./runner";
8
9
  import type { ToolCallEventResult, ToolResultEventResult } from "./types";
@@ -46,7 +47,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
46
47
  type: "tool_call",
47
48
  toolName: this.tool.name,
48
49
  toolCallId,
49
- input: params as Record<string, unknown>,
50
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
50
51
  })) as ToolCallEventResult | undefined;
51
52
 
52
53
  if (callResult?.block) {
@@ -72,7 +73,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
72
73
  type: "tool_result",
73
74
  toolName: this.tool.name,
74
75
  toolCallId,
75
- input: params as Record<string, unknown>,
76
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
76
77
  content: result.content,
77
78
  details: result.details,
78
79
  isError: false,
@@ -95,7 +96,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
95
96
  type: "tool_result",
96
97
  toolName: this.tool.name,
97
98
  toolCallId,
98
- input: params as Record<string, unknown>,
99
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
99
100
  content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
100
101
  details: undefined,
101
102
  isError: true,
@@ -93,14 +93,22 @@ const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/;
93
93
  // so they keep working when on-disk layout differs from the monorepo tree.
94
94
  /**
95
95
  * Compute the bunfs package root from the compiled binary's `import.meta.dir`
96
- * (or any stand-in supplied by tests). Bun 1.3 reports the bunfs mount root
97
- * (`/$bunfs/root` or `<drive>:\~BUN\root`) for imported modules as well as the
98
- * entrypoint, so the normal path is `<root>/packages`.
96
+ * (or any stand-in supplied by tests). Bun compiled binaries report one of:
99
97
  *
100
- * The suffix branch preserves correctness if a future Bun release switches to
101
- * module-specific `import.meta.dir` values inside compiled binaries, matching
102
- * the source layout:
103
- * `<bunfs>/packages/coding-agent/src/extensibility/plugins`.
98
+ * - the bunfs mount root itself `/$bunfs/root` or `<drive>:\~BUN\root` (Bun
99
+ * 1.2.x and early 1.3.x). Append `packages` for the canonical layout.
100
+ * - the bunfs mount root followed by the binary's basename — `//root/<bin>`
101
+ * on POSIX or `<drive>:\~BUN\root\<bin>.exe` on Windows (observed on Bun
102
+ * 1.3.14 with the cross-compiled `omp-darwin-arm64` release asset — issue
103
+ * #3329). The trailing segment is stripped so the result still lands on
104
+ * `<root>/packages`.
105
+ * - the module's own source directory if a future Bun release switches to
106
+ * module-specific `import.meta.dir` values:
107
+ * `<bunfs>/packages/coding-agent/src/extensibility/plugins`.
108
+ * The bunfs-root-with-binary branch slices the original `metaDir`, and
109
+ * `bunfsPath` uses a matching double-slash-preserving join, so the bunfs-native
110
+ * prefix is preserved verbatim — `path.posix.join` collapses `//root` to
111
+ * `/root`, but Bun's bunfs lookup is keyed on the exact `//root` form.
104
112
  *
105
113
  * Exported for tests; production callers use `BUNFS_PACKAGE_ROOT` below.
106
114
  */
@@ -110,6 +118,10 @@ export function __computeBunfsPackageRoot(metaDir: string, pathImpl: typeof path
110
118
  if (normalizedMetaDir.endsWith(pluginsDirSuffix)) {
111
119
  return pathImpl.resolve(metaDir, "..", "..", "..", "..");
112
120
  }
121
+ const parent = pathImpl.dirname(metaDir);
122
+ if (pathImpl.basename(pathImpl.normalize(parent)) === "root") {
123
+ return `${parent + pathImpl.sep}packages`;
124
+ }
113
125
  return pathImpl.join(metaDir, "packages");
114
126
  }
115
127
 
@@ -137,11 +149,31 @@ export function __computeBundledSelfPackageRoot(metaDir: string, pathImpl: typeo
137
149
 
138
150
  const BUNFS_PACKAGE_ROOT = IS_COMPILED_BINARY ? __computeBunfsPackageRoot(import.meta.dir) : null;
139
151
 
152
+ /**
153
+ * Join a computed bunfs package root with descendants without collapsing
154
+ * Bun's POSIX `//root` mount prefix.
155
+ *
156
+ * Exported for tests; production callers use `bunfsPath` below.
157
+ */
158
+ export function __joinBunfsPath(root: string, segments: readonly string[], pathImpl: typeof path = path): string {
159
+ const joined = pathImpl.join(root, ...segments);
160
+ const doubleRootPrefix = pathImpl.sep + pathImpl.sep;
161
+ const tripleRootPrefix = doubleRootPrefix + pathImpl.sep;
162
+ if (
163
+ root.startsWith(doubleRootPrefix) &&
164
+ !root.startsWith(tripleRootPrefix) &&
165
+ !joined.startsWith(doubleRootPrefix)
166
+ ) {
167
+ return pathImpl.sep + joined;
168
+ }
169
+ return joined;
170
+ }
171
+
140
172
  function bunfsPath(...segments: string[]): string {
141
173
  if (!BUNFS_PACKAGE_ROOT) {
142
174
  throw new Error("bunfsPath is only valid in compiled-binary mode");
143
175
  }
144
- return path.join(BUNFS_PACKAGE_ROOT, ...segments);
176
+ return __joinBunfsPath(BUNFS_PACKAGE_ROOT, segments);
145
177
  }
146
178
 
147
179
  function resolveBundledSelfPackageRoot(): string | undefined {
@@ -6,12 +6,18 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils";
9
+ import { getPluginsDir, getPluginsLockfile, isEnoent } from "@oh-my-pi/pi-utils";
10
10
  import { getConfigDirPaths } from "../../config";
11
+ import { resolveActiveProjectRegistryPath } from "../../discovery/helpers";
11
12
  import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
12
13
  import { normalizePluginRuntimeConfig } from "./runtime-config";
13
14
  import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types";
14
15
 
16
+ /** Installed plugin plus the root scope that supplied its runtime metadata. */
17
+ export interface ScopedInstalledPlugin extends InstalledPlugin {
18
+ scope: "user" | "project";
19
+ }
20
+
15
21
  installLegacyPiSpecifierShim();
16
22
 
17
23
  // =============================================================================
@@ -51,38 +57,38 @@ async function loadProjectOverrides(cwd: string): Promise<ProjectPluginOverrides
51
57
  return {};
52
58
  }
53
59
  /**
54
- * Get list of enabled plugins with their resolved configurations.
55
- *
56
- * Respects both global runtime config and project overrides. Iterates the
57
- * union of `<plugins>/package.json#dependencies` (`bun install`-installed
58
- * packages) and `<plugins>/omp-plugins.lock.json#plugins` (so locally
59
- * `plugin link`-symlinked extensions, which never get a dependency entry,
60
- * are still discovered). The optional `home` parameter pins the plugins
61
- * root for callers that need to enumerate plugins relative to a non-default
62
- * home (tests with a tempdir, discovery loaders threaded with
63
- * `LoadContext.home`).
60
+ * Per-root enumeration of plugins from `<root>/node_modules`,
61
+ * `<root>/package.json#dependencies`, and `<root>/omp-plugins.lock.json#plugins`.
62
+ * Honors `projectOverrides.disabled` and `projectOverrides.features`. Returns an
63
+ * empty array when the root has no `node_modules` yet.
64
64
  */
65
- export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise<InstalledPlugin[]> {
66
- const { home } = opts;
67
-
68
- const nodeModulesPath = getPluginsNodeModules(home);
69
- if (!fs.existsSync(nodeModulesPath)) {
70
- return [];
71
- }
65
+ async function collectPluginsAtRoot(
66
+ root: string,
67
+ projectOverrides: ProjectPluginOverrides,
68
+ scope: ScopedInstalledPlugin["scope"],
69
+ ): Promise<ScopedInstalledPlugin[]> {
70
+ const nodeModulesPath = path.join(root, "node_modules");
71
+ if (!fs.existsSync(nodeModulesPath)) return [];
72
72
 
73
73
  let depsKeys: string[] = [];
74
- const pkgJsonPath = getPluginsPackageJson(home);
74
+ const pkgJsonPath = path.join(root, "package.json");
75
75
  try {
76
76
  const pkg: { dependencies?: Record<string, string> } = await Bun.file(pkgJsonPath).json();
77
77
  depsKeys = Object.keys(pkg.dependencies ?? {});
78
78
  } catch (err) {
79
- // Linked-only setups may have no `<plugins>/package.json` yet — that's
79
+ // Linked-only setups may have no `<root>/package.json` yet — that's
80
80
  // fine, the lockfile still records the link.
81
81
  if (!isEnoent(err)) throw err;
82
82
  }
83
83
 
84
- const runtimeConfig = await loadRuntimeConfig(home);
85
- const projectOverrides = await loadProjectOverrides(cwd);
84
+ const lockPath = path.join(root, "omp-plugins.lock.json");
85
+ let runtimeConfig: PluginRuntimeConfig;
86
+ try {
87
+ runtimeConfig = normalizePluginRuntimeConfig(await Bun.file(lockPath).json());
88
+ } catch (err) {
89
+ if (!isEnoent(err)) throw err;
90
+ runtimeConfig = normalizePluginRuntimeConfig({});
91
+ }
86
92
 
87
93
  // Union: dependencies (npm/marketplace installs) ∪ runtime-config plugins
88
94
  // (links + already-recorded installs). Set preserves first-seen order,
@@ -92,7 +98,7 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
92
98
  names.add(name);
93
99
  }
94
100
 
95
- const plugins: InstalledPlugin[] = [];
101
+ const plugins: ScopedInstalledPlugin[] = [];
96
102
  for (const name of names) {
97
103
  const pluginPkgPath = path.join(nodeModulesPath, name, "package.json");
98
104
  let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest };
@@ -130,6 +136,7 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
130
136
  name,
131
137
  version: pluginPkg.version,
132
138
  path: path.join(nodeModulesPath, name),
139
+ scope,
133
140
  manifest,
134
141
  enabledFeatures,
135
142
  enabled: true,
@@ -139,6 +146,47 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
139
146
  return plugins;
140
147
  }
141
148
 
149
+ /**
150
+ * Get list of enabled plugins with their resolved configurations.
151
+ *
152
+ * Enumerates two plugin roots in order: the user root
153
+ * (`getPluginsDir(home)`) and, when a project anchor (`.omp/` or `.git/`)
154
+ * exists at or above `cwd`, the project root
155
+ * (`<projectAnchor>/.omp/plugins`). Each root contributes the union of its
156
+ * `package.json#dependencies` and `omp-plugins.lock.json#plugins`. Project
157
+ * entries shadow user entries with the same package name, matching the
158
+ * shadow semantics of `MarketplaceManager.listInstalledPlugins`.
159
+ *
160
+ * The optional `home` parameter pins the user plugins root for callers that
161
+ * need to enumerate plugins relative to a non-default home (tests with a
162
+ * tempdir, discovery loaders threaded with `LoadContext.home`).
163
+ */
164
+ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise<ScopedInstalledPlugin[]> {
165
+ const { home } = opts;
166
+ const projectOverrides = await loadProjectOverrides(cwd);
167
+
168
+ const userRoot = getPluginsDir(home);
169
+ const userPlugins = await collectPluginsAtRoot(userRoot, projectOverrides, "user");
170
+
171
+ let projectPlugins: ScopedInstalledPlugin[] = [];
172
+ const projectRegistryPath = await resolveActiveProjectRegistryPath(cwd);
173
+ if (projectRegistryPath) {
174
+ const projectRoot = path.dirname(projectRegistryPath);
175
+ if (projectRoot !== userRoot) {
176
+ projectPlugins = await collectPluginsAtRoot(projectRoot, projectOverrides, "project");
177
+ }
178
+ }
179
+
180
+ if (projectPlugins.length === 0) return userPlugins;
181
+ if (userPlugins.length === 0) return projectPlugins;
182
+
183
+ // Project entries shadow user entries with the same package name.
184
+ const merged = new Map<string, ScopedInstalledPlugin>();
185
+ for (const plugin of userPlugins) merged.set(plugin.name, plugin);
186
+ for (const plugin of projectPlugins) merged.set(plugin.name, plugin);
187
+ return Array.from(merged.values());
188
+ }
189
+
142
190
  // =============================================================================
143
191
  // Path Resolution
144
192
  // =============================================================================
@@ -11,6 +11,8 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
 
13
13
  import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
14
+ import { normalizePluginRuntimeConfig } from "../runtime-config";
15
+ import type { PluginRuntimeConfig } from "../types";
14
16
 
15
17
  import { cachePlugin } from "./cache";
16
18
  import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
@@ -38,6 +40,16 @@ import type {
38
40
  } from "./types";
39
41
  import { buildPluginId, parsePluginId } from "./types";
40
42
 
43
+ const RUNTIME_PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
44
+ const MAX_RUNTIME_PACKAGE_NAME_LENGTH = 214;
45
+
46
+ function assertRuntimePackageName(name: string): string {
47
+ if (name.length > MAX_RUNTIME_PACKAGE_NAME_LENGTH || !RUNTIME_PACKAGE_NAME_RE.test(name)) {
48
+ throw new Error(`Invalid marketplace plugin package name: ${JSON.stringify(name)}`);
49
+ }
50
+ return name;
51
+ }
52
+
41
53
  // ── Options ──────────────────────────────────────────────────────────────────
42
54
 
43
55
  export interface MarketplaceManagerOptions {
@@ -298,6 +310,9 @@ export class MarketplaceManager {
298
310
  }
299
311
  }
300
312
 
313
+ const packageName = await this.#resolvePluginPackageName(cachePath, name);
314
+ const previousPackageNames = await this.#resolveInstalledPackageNames(existing ?? [], name);
315
+
301
316
  // Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
302
317
  if (existing && existing.length > 0) {
303
318
  // Remove from scope-appropriate registry first, then cross-check refs before disk deletion.
@@ -337,6 +352,13 @@ export class MarketplaceManager {
337
352
  const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry);
338
353
  await writeInstalledPluginsRegistry(registryPath, newInstReg);
339
354
 
355
+ for (const previousPackageName of previousPackageNames) {
356
+ if (previousPackageName !== packageName) {
357
+ await this.#removeRuntimePlugin(scope, previousPackageName);
358
+ }
359
+ }
360
+ await this.#registerRuntimePlugin(scope, packageName, cachePath, version, wasDisabled ? false : undefined);
361
+
340
362
  this.#clearCache();
341
363
 
342
364
  logger.debug("Plugin installed", { pluginId, version, cachePath });
@@ -434,6 +456,7 @@ export class MarketplaceManager {
434
456
  const targetEntries = targetScope === "project" ? projectEntries! : userEntries!;
435
457
  const targetReg = targetScope === "project" ? projectReg : userReg;
436
458
  const registryPath = this.#registryPath(targetScope);
459
+ const packageNames = await this.#resolveInstalledPackageNames(targetEntries, parsed.name);
437
460
 
438
461
  const updatedReg = removeInstalledPlugin(targetReg, pluginId);
439
462
  await writeInstalledPluginsRegistry(registryPath, updatedReg);
@@ -453,6 +476,10 @@ export class MarketplaceManager {
453
476
  }
454
477
  }
455
478
 
479
+ for (const packageName of packageNames) {
480
+ await this.#removeRuntimePlugin(targetScope, packageName);
481
+ }
482
+
456
483
  this.#clearCache();
457
484
 
458
485
  logger.debug("Plugin uninstalled", { pluginId, scope: targetScope });
@@ -539,6 +566,12 @@ export class MarketplaceManager {
539
566
  };
540
567
  await writeInstalledPluginsRegistry(registryPath, updated);
541
568
 
569
+ const fallbackName = parsePluginId(pluginId)?.name ?? pluginId;
570
+ const packageNames = await this.#resolveInstalledPackageNames(entries, fallbackName);
571
+ for (const packageName of packageNames) {
572
+ await this.#setRuntimePluginEnabled(targetScope, packageName, enabled);
573
+ }
574
+
542
575
  this.#clearCache();
543
576
 
544
577
  logger.debug("Plugin enabled state changed", { pluginId, enabled, scope: targetScope });
@@ -701,6 +734,107 @@ export class MarketplaceManager {
701
734
 
702
735
  // ── Private helpers ───────────────────────────────────────────────────────
703
736
 
737
+ #runtimeRoot(scope: "user" | "project"): string {
738
+ return path.dirname(this.#registryPath(scope));
739
+ }
740
+
741
+ #nodeModulesPath(scope: "user" | "project"): string {
742
+ return path.join(this.#runtimeRoot(scope), "node_modules");
743
+ }
744
+
745
+ #runtimeLockPath(scope: "user" | "project"): string {
746
+ return path.join(this.#runtimeRoot(scope), "omp-plugins.lock.json");
747
+ }
748
+
749
+ async #loadRuntimeConfig(scope: "user" | "project"): Promise<PluginRuntimeConfig> {
750
+ try {
751
+ return normalizePluginRuntimeConfig(await Bun.file(this.#runtimeLockPath(scope)).json());
752
+ } catch (err) {
753
+ if (isEnoent(err)) return normalizePluginRuntimeConfig({});
754
+ logger.warn("Failed to load marketplace plugin runtime config", {
755
+ path: this.#runtimeLockPath(scope),
756
+ error: String(err),
757
+ });
758
+ return normalizePluginRuntimeConfig({});
759
+ }
760
+ }
761
+
762
+ async #writeRuntimeConfig(scope: "user" | "project", config: PluginRuntimeConfig): Promise<void> {
763
+ await Bun.write(this.#runtimeLockPath(scope), JSON.stringify(config, null, 2));
764
+ }
765
+
766
+ async #resolvePluginPackageName(installPath: string, fallbackName: string): Promise<string> {
767
+ try {
768
+ const pkg: { name?: unknown } = await Bun.file(path.join(installPath, "package.json")).json();
769
+ const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : fallbackName;
770
+ return assertRuntimePackageName(name);
771
+ } catch (err) {
772
+ if (isEnoent(err)) return assertRuntimePackageName(fallbackName);
773
+ throw err;
774
+ }
775
+ }
776
+
777
+ #runtimePackagePath(scope: "user" | "project", packageName: string): string {
778
+ const nodeModules = path.resolve(this.#nodeModulesPath(scope));
779
+ const linkPath = path.resolve(nodeModules, assertRuntimePackageName(packageName));
780
+ const relative = path.relative(nodeModules, linkPath);
781
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
782
+ throw new Error(`Marketplace plugin package path escapes node_modules: ${JSON.stringify(packageName)}`);
783
+ }
784
+ return linkPath;
785
+ }
786
+
787
+ async #resolveInstalledPackageNames(
788
+ entries: readonly InstalledPluginEntry[],
789
+ fallbackName: string,
790
+ ): Promise<Set<string>> {
791
+ const packageNames = new Set<string>();
792
+ for (const entry of entries) {
793
+ packageNames.add(await this.#resolvePluginPackageName(entry.installPath, fallbackName));
794
+ }
795
+ return packageNames;
796
+ }
797
+
798
+ async #registerRuntimePlugin(
799
+ scope: "user" | "project",
800
+ packageName: string,
801
+ cachePath: string,
802
+ version: string,
803
+ enabled: boolean | undefined,
804
+ ): Promise<void> {
805
+ const linkPath = this.#runtimePackagePath(scope, packageName);
806
+ await fs.mkdir(path.dirname(linkPath), { recursive: true });
807
+ await fs.rm(linkPath, { recursive: true, force: true });
808
+ await fs.symlink(cachePath, linkPath, process.platform === "win32" ? "junction" : "dir");
809
+
810
+ const config = await this.#loadRuntimeConfig(scope);
811
+ const previous = config.plugins[packageName];
812
+ config.plugins[packageName] = {
813
+ version,
814
+ enabledFeatures: previous?.enabledFeatures ?? null,
815
+ enabled: enabled ?? previous?.enabled ?? true,
816
+ };
817
+ await this.#writeRuntimeConfig(scope, config);
818
+ }
819
+
820
+ async #removeRuntimePlugin(scope: "user" | "project", packageName: string): Promise<void> {
821
+ await fs.rm(this.#runtimePackagePath(scope, packageName), { recursive: true, force: true });
822
+
823
+ const config = await this.#loadRuntimeConfig(scope);
824
+ delete config.plugins[packageName];
825
+ delete config.settings[packageName];
826
+ await this.#writeRuntimeConfig(scope, config);
827
+ }
828
+
829
+ async #setRuntimePluginEnabled(scope: "user" | "project", packageName: string, enabled: boolean): Promise<void> {
830
+ const config = await this.#loadRuntimeConfig(scope);
831
+ const previous = config.plugins[packageName];
832
+ if (!previous) return;
833
+
834
+ config.plugins[packageName] = { ...previous, enabled };
835
+ await this.#writeRuntimeConfig(scope, config);
836
+ }
837
+
704
838
  #registryPath(scope: "user" | "project"): string {
705
839
  if (scope === "project") {
706
840
  if (!this.#opts.projectInstalledRegistryPath) {
@@ -0,0 +1,57 @@
1
+ const HASHLINE_FILE_PREFIX = "¶";
2
+ const HASHLINE_FILE_TAG_RE = /#[0-9a-fA-F]{4}$/u;
3
+
4
+ function stringField(input: Record<string, unknown>, key: string): string | undefined {
5
+ const value = input[key];
6
+ return typeof value === "string" && value.length > 0 ? value : undefined;
7
+ }
8
+
9
+ function normalizeHashlineHeaderPath(body: string): string | undefined {
10
+ const trimmed = body.trim();
11
+ if (trimmed.length === 0) return undefined;
12
+ const hashStart = HASHLINE_FILE_TAG_RE.exec(trimmed)?.index;
13
+ const rawPath = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
14
+ if (rawPath.length < 2) return rawPath.length > 0 ? rawPath : undefined;
15
+ const first = rawPath[0];
16
+ const last = rawPath[rawPath.length - 1];
17
+ if ((first === '"' || first === "'") && first === last) return rawPath.slice(1, -1);
18
+ return rawPath;
19
+ }
20
+
21
+ function extractHashlinePaths(input: string): string[] {
22
+ const paths: string[] = [];
23
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
24
+ for (const rawLine of stripped.split("\n")) {
25
+ const line = rawLine.replace(/\r$/, "").trimStart();
26
+ if (!line.startsWith(HASHLINE_FILE_PREFIX)) continue;
27
+ let prefixEnd = 0;
28
+ while (prefixEnd < line.length && line[prefixEnd] === HASHLINE_FILE_PREFIX) prefixEnd++;
29
+ const path = normalizeHashlineHeaderPath(line.slice(prefixEnd));
30
+ if (path) paths.push(path);
31
+ }
32
+ return paths;
33
+ }
34
+
35
+ /** Adds derived compatibility fields to tool event input without changing tool execution parameters. */
36
+ export function normalizeToolEventInput(toolName: string, input: Record<string, unknown>): Record<string, unknown> {
37
+ if (toolName !== "edit" || stringField(input, "path")) return input;
38
+
39
+ // Hashline edit mode: the only authoritative target list is the parsed
40
+ // `¶PATH#TAG` headers inside `input`/`_input`. Trusting a passthrough
41
+ // `_path` here would let a model-supplied field override the real edit
42
+ // target and bypass extension gates that allowlist by path.
43
+ const rawInput = stringField(input, "input") ?? stringField(input, "_input");
44
+ if (rawInput !== undefined) {
45
+ const hashlinePaths = extractHashlinePaths(rawInput);
46
+ if (hashlinePaths.length === 0) return input;
47
+ if (hashlinePaths.length === 1) return { ...input, path: hashlinePaths[0], paths: hashlinePaths };
48
+ return { ...input, paths: hashlinePaths };
49
+ }
50
+
51
+ // Replace/patch modes: `path` is the real parameter; some hosts forward
52
+ // it as `_path` after schema normalization, so propagate it for gates.
53
+ const directPath = stringField(input, "_path");
54
+ if (directPath) return { ...input, path: directPath };
55
+
56
+ return input;
57
+ }
@@ -189,6 +189,22 @@ export interface RetentionTranscript {
189
189
  * Messages are tag-stripped before framing to break the recall→retain loop.
190
190
  * Returns `{ transcript: null }` when nothing meaningful survives.
191
191
  */
192
+ function formatRetentionMessages(messages: HindsightMessage[]): RetentionTranscript {
193
+ const parts: string[] = [];
194
+ for (const msg of messages) {
195
+ const content = stripMemoryTags(msg.content).trim();
196
+ if (!hasSubstantiveContent(content)) continue;
197
+ parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
198
+ }
199
+
200
+ if (parts.length === 0) return { transcript: null, messageCount: 0 };
201
+
202
+ const transcript = parts.join("\n\n");
203
+ if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
204
+
205
+ return { transcript, messageCount: parts.length };
206
+ }
207
+
192
208
  export function prepareRetentionTranscript(
193
209
  messages: HindsightMessage[],
194
210
  retainFullWindow = false,
@@ -210,17 +226,10 @@ export function prepareRetentionTranscript(
210
226
  targetMessages = messages.slice(lastUserIdx);
211
227
  }
212
228
 
213
- const parts: string[] = [];
214
- for (const msg of targetMessages) {
215
- const content = stripMemoryTags(msg.content).trim();
216
- if (!hasSubstantiveContent(content)) continue;
217
- parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
218
- }
219
-
220
- if (parts.length === 0) return { transcript: null, messageCount: 0 };
221
-
222
- const transcript = parts.join("\n\n");
223
- if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
229
+ return formatRetentionMessages(targetMessages);
230
+ }
224
231
 
225
- return { transcript, messageCount: parts.length };
232
+ /** Format only user-authored messages for memory fact/entity extraction. */
233
+ export function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
234
+ return formatRetentionMessages(messages.filter(message => message.role === "user"));
226
235
  }