@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.3

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 (163) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3855 -3798
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +20 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/discovery/helpers.d.ts +2 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  11. package/dist/types/edit/hashline/execute.d.ts +1 -1
  12. package/dist/types/edit/hashline/params.d.ts +3 -6
  13. package/dist/types/edit/renderer.d.ts +1 -0
  14. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  15. package/dist/types/hindsight/client.d.ts +15 -11
  16. package/dist/types/hindsight/client.test.d.ts +1 -0
  17. package/dist/types/irc/bus.d.ts +5 -4
  18. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  19. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  20. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  21. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  22. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  23. package/dist/types/modes/theme/theme.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +13 -13
  25. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  26. package/dist/types/session/session-storage.d.ts +31 -3
  27. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  28. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  29. package/dist/types/ssh/connection-manager.d.ts +19 -0
  30. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  31. package/dist/types/subprocess/worker-client.d.ts +20 -6
  32. package/dist/types/task/discovery.d.ts +5 -2
  33. package/dist/types/task/renderer.d.ts +1 -0
  34. package/dist/types/tools/ast-grep.d.ts +4 -2
  35. package/dist/types/tools/bash.d.ts +27 -0
  36. package/dist/types/tools/browser/render.d.ts +2 -0
  37. package/dist/types/tools/debug.d.ts +1 -0
  38. package/dist/types/tools/eval-render.d.ts +2 -0
  39. package/dist/types/tools/glob.d.ts +4 -2
  40. package/dist/types/tools/grep.d.ts +4 -3
  41. package/dist/types/tools/path-utils.d.ts +7 -0
  42. package/dist/types/tools/renderers.d.ts +24 -0
  43. package/dist/types/tools/ssh.d.ts +4 -1
  44. package/dist/types/tts/index.d.ts +2 -0
  45. package/dist/types/tts/speakable.d.ts +47 -0
  46. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  47. package/dist/types/tts/streaming-player.d.ts +1 -2
  48. package/dist/types/tts/tts-client.d.ts +11 -10
  49. package/dist/types/tts/tts-protocol.d.ts +7 -0
  50. package/dist/types/tts/vocalizer.d.ts +15 -8
  51. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  52. package/dist/types/utils/git.d.ts +2 -0
  53. package/dist/types/web/search/providers/base.d.ts +1 -0
  54. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  55. package/package.json +12 -12
  56. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  57. package/src/advisor/__tests__/advisor.test.ts +1 -1
  58. package/src/cli/gallery-fixtures/fs.ts +2 -2
  59. package/src/cli/gallery-fixtures/search.ts +2 -2
  60. package/src/cli/models-cli.ts +19 -0
  61. package/src/cli/update-cli.test.ts +28 -0
  62. package/src/cli/update-cli.ts +35 -8
  63. package/src/cli.ts +27 -5
  64. package/src/commands/update.ts +8 -2
  65. package/src/config/config-file.ts +6 -6
  66. package/src/config/model-resolver.ts +31 -10
  67. package/src/config/settings-schema.ts +21 -0
  68. package/src/cursor.ts +1 -1
  69. package/src/dap/client.ts +134 -36
  70. package/src/discovery/helpers.ts +8 -0
  71. package/src/edit/file-snapshot-store.ts +12 -1
  72. package/src/edit/hashline/diff.ts +4 -13
  73. package/src/edit/hashline/execute.ts +1 -1
  74. package/src/edit/hashline/params.ts +5 -12
  75. package/src/edit/renderer.ts +4 -2
  76. package/src/edit/streaming.ts +15 -5
  77. package/src/export/html/tool-views.generated.js +34 -34
  78. package/src/extensibility/custom-tools/loader.ts +3 -3
  79. package/src/extensibility/extensions/loader.ts +10 -3
  80. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  81. package/src/extensibility/plugins/installer.ts +12 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  83. package/src/extensibility/plugins/loader.ts +30 -1
  84. package/src/extensibility/plugins/manager.ts +32 -8
  85. package/src/extensibility/plugins/parser.ts +7 -5
  86. package/src/extensibility/tool-event-input.ts +1 -1
  87. package/src/hindsight/client.test.ts +33 -0
  88. package/src/hindsight/client.ts +42 -22
  89. package/src/internal-urls/docs-index.generated.txt +1 -1
  90. package/src/irc/bus.ts +5 -4
  91. package/src/main.ts +7 -1
  92. package/src/mcp/oauth-flow.ts +93 -4
  93. package/src/mcp/smithery-auth.ts +3 -0
  94. package/src/mcp/smithery-connect.ts +9 -0
  95. package/src/mcp/smithery-registry.test.ts +51 -0
  96. package/src/mcp/smithery-registry.ts +27 -4
  97. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  98. package/src/modes/components/advisor-message.ts +13 -10
  99. package/src/modes/components/assistant-message.ts +1 -1
  100. package/src/modes/components/move-overlay.ts +35 -23
  101. package/src/modes/components/status-line/component.ts +11 -4
  102. package/src/modes/components/tool-execution.ts +119 -20
  103. package/src/modes/components/transcript-container.ts +26 -0
  104. package/src/modes/components/tree-selector.ts +10 -3
  105. package/src/modes/controllers/event-controller.ts +23 -3
  106. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  107. package/src/modes/interactive-mode.ts +1 -0
  108. package/src/modes/rpc/rpc-client.ts +29 -16
  109. package/src/modes/theme/shimmer.ts +49 -15
  110. package/src/modes/theme/theme.ts +7 -0
  111. package/src/prompts/goals/goal-mode-context.md +4 -0
  112. package/src/prompts/goals/goal-todo-context.md +10 -2
  113. package/src/prompts/system/speech-rewrite.md +15 -0
  114. package/src/prompts/system/tiny-title-system.md +1 -1
  115. package/src/prompts/system/title-system-marker.md +2 -1
  116. package/src/prompts/system/title-system.md +2 -1
  117. package/src/prompts/tools/ast-grep.md +3 -3
  118. package/src/prompts/tools/glob.md +1 -1
  119. package/src/prompts/tools/grep.md +1 -1
  120. package/src/sdk.ts +8 -5
  121. package/src/session/agent-session.ts +329 -79
  122. package/src/session/indexed-session-storage.ts +40 -3
  123. package/src/session/session-history-format.ts +6 -2
  124. package/src/session/session-manager.ts +83 -14
  125. package/src/session/session-storage.ts +112 -26
  126. package/src/slash-commands/helpers/usage-report.ts +6 -1
  127. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  128. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  129. package/src/ssh/connection-manager.ts +44 -11
  130. package/src/ssh/sshfs-mount.ts +27 -4
  131. package/src/subprocess/worker-client.ts +161 -10
  132. package/src/task/discovery.ts +25 -2
  133. package/src/task/executor.ts +24 -4
  134. package/src/task/render.ts +26 -12
  135. package/src/task/renderer.ts +1 -0
  136. package/src/task/worktree.ts +144 -16
  137. package/src/tiny/text.ts +109 -17
  138. package/src/tools/ast-grep.ts +20 -17
  139. package/src/tools/bash.ts +46 -9
  140. package/src/tools/browser/render.ts +2 -0
  141. package/src/tools/debug.ts +1 -0
  142. package/src/tools/eval-render.ts +5 -2
  143. package/src/tools/gh-renderer.ts +3 -0
  144. package/src/tools/glob.ts +24 -20
  145. package/src/tools/grep.ts +39 -37
  146. package/src/tools/path-utils.ts +55 -10
  147. package/src/tools/read.ts +23 -6
  148. package/src/tools/renderers.ts +24 -0
  149. package/src/tools/ssh.ts +25 -7
  150. package/src/tts/index.ts +2 -0
  151. package/src/tts/speakable.ts +382 -0
  152. package/src/tts/speech-enhancer.ts +204 -0
  153. package/src/tts/streaming-player.ts +71 -16
  154. package/src/tts/tts-client.ts +11 -10
  155. package/src/tts/tts-protocol.ts +14 -5
  156. package/src/tts/tts-worker.ts +52 -49
  157. package/src/tts/vocalizer.ts +277 -46
  158. package/src/utils/clipboard.ts +49 -12
  159. package/src/utils/fetch-timeout.ts +10 -0
  160. package/src/utils/git.ts +8 -0
  161. package/src/web/search/index.ts +8 -0
  162. package/src/web/search/providers/base.ts +1 -0
  163. package/src/web/search/providers/gemini.ts +23 -6
@@ -18,7 +18,7 @@ import { getAllPluginToolPaths } from "../../extensibility/plugins/loader";
18
18
  // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
19
19
  import * as PiCodingAgent from "../../index";
20
20
  import * as typebox from "../typebox";
21
- import { createNoOpUIContext, resolvePath } from "../utils";
21
+ import { createNoOpUIContext, resolvePath, withExitGuard } from "../utils";
22
22
  import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types";
23
23
 
24
24
  /**
@@ -45,14 +45,14 @@ async function loadTool(
45
45
  }
46
46
 
47
47
  try {
48
- const module = await import(resolvedPath);
48
+ const module = await withExitGuard(() => import(resolvedPath));
49
49
  const factory = (module.default ?? module) as CustomToolFactory;
50
50
 
51
51
  if (typeof factory !== "function") {
52
52
  return { tools: null, error: { path: toolPath, error: "Tool must export a default function", source } };
53
53
  }
54
54
 
55
- const toolResult = await factory(sharedApi);
55
+ const toolResult = await withExitGuard(async () => factory(sharedApi));
56
56
  const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult];
57
57
 
58
58
  const loadedTools: LoadedCustomTool[] = toolsArray.map(tool => ({
@@ -521,10 +521,17 @@ export async function discoverExtensionPaths(
521
521
  }
522
522
  };
523
523
 
524
- // 1. Discover extension modules via capability API (native .omp/.pi only)
525
- const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, loadOptions);
524
+ // 1. Discover extension modules via capability API (native .omp/.pi only).
525
+ // Scope the load to the native provider — the extension-module capability
526
+ // also has claude/codex/gemini/opencode providers, and their items were
527
+ // discarded here anyway (see #4198). The provider filter skips the walk
528
+ // entirely instead of running four foreign directory scans and dropping
529
+ // the results.
530
+ const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, {
531
+ ...loadOptions,
532
+ providers: ["native"],
533
+ });
526
534
  for (const ext of discovered.items) {
527
- if (ext._source.provider !== "native") continue;
528
535
  addPath(ext.path);
529
536
  }
530
537
 
@@ -500,7 +500,7 @@ export function createGrepToolDefinition(cwd: string, options?: GrepToolOptions)
500
500
  toolCallId,
501
501
  {
502
502
  pattern,
503
- paths: glob ? joinLegacyGlob(searchPath, glob) : searchPath,
503
+ path: glob ? joinLegacyGlob(searchPath, glob) : searchPath,
504
504
  case: booleanField(params, "ignoreCase") ? false : undefined,
505
505
  },
506
506
  signal,
@@ -558,7 +558,7 @@ export function createFindToolDefinition(cwd: string, options?: FindToolOptions)
558
558
  }
559
559
  return tool.execute(
560
560
  toolCallId,
561
- { paths: [joinLegacyGlob(searchPath, pattern)], hidden: true, gitignore: true, limit },
561
+ { path: joinLegacyGlob(searchPath, pattern), hidden: true, gitignore: true, limit },
562
562
  signal,
563
563
  onUpdate,
564
564
  );
@@ -53,9 +53,14 @@ export async function installPlugin(packageName: string): Promise<InstalledPlugi
53
53
  windowsHide: true,
54
54
  });
55
55
 
56
- const exitCode = await proc.exited;
56
+ // Drain both pipes concurrently with proc.exited to avoid a pipe-buffer
57
+ // deadlock if bun install floods stdout/stderr.
58
+ const [exitCode, , stderr] = await Promise.all([
59
+ proc.exited,
60
+ new Response(proc.stdout).text(),
61
+ new Response(proc.stderr).text(),
62
+ ]);
57
63
  if (exitCode !== 0) {
58
- const stderr = await new Response(proc.stderr).text();
59
64
  throw new Error(`Failed to install ${packageName}: ${stderr}`);
60
65
  }
61
66
 
@@ -95,7 +100,11 @@ export async function uninstallPlugin(name: string): Promise<void> {
95
100
  windowsHide: true,
96
101
  });
97
102
 
98
- const exitCode = await proc.exited;
103
+ const [exitCode] = await Promise.all([
104
+ proc.exited,
105
+ new Response(proc.stdout).text(),
106
+ new Response(proc.stderr).text(),
107
+ ]);
99
108
  if (exitCode !== 0) {
100
109
  throw new Error(`Failed to uninstall ${name}`);
101
110
  }
@@ -3,6 +3,7 @@ import { isBuiltin } from "node:module";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { isCompiledBinary, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
6
+ import { registerPluginCacheInvalidator } from "../../discovery/helpers";
6
7
  import { BUNDLED_PI_REGISTRY_KEYS } from "./legacy-pi-bundled-keys";
7
8
 
8
9
  const IS_COMPILED_BINARY = isCompiledBinary();
@@ -202,6 +203,22 @@ const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx",
202
203
  const SUPPORTED_PACKAGE_IMPORT_CONDITIONS = new Set(["bun", "node", "import", "default"]);
203
204
  const packageRootCache = new Map<string, string | null>();
204
205
  const packageImportsCache = new Map<string, Record<string, unknown> | null>();
206
+ const nodePackageRootCache = new Map<string, Promise<string | null>>();
207
+ const packageManifestCache = new Map<string, Promise<Record<string, unknown> | null>>();
208
+ const bareDependencyResolutionCache = new Map<string, Promise<string | null>>();
209
+ const realpathCache = new Map<string, Promise<string>>();
210
+
211
+ function clearLegacyPiResolutionCaches(): void {
212
+ resolvedSpecifierFallbacks.clear();
213
+ packageRootCache.clear();
214
+ packageImportsCache.clear();
215
+ nodePackageRootCache.clear();
216
+ packageManifestCache.clear();
217
+ bareDependencyResolutionCache.clear();
218
+ realpathCache.clear();
219
+ }
220
+
221
+ registerPluginCacheInvalidator(clearLegacyPiResolutionCaches);
205
222
  const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded");
206
223
 
207
224
  // Extensions that imported TypeBox directly used to resolve against a real
@@ -730,6 +747,16 @@ function splitBarePackageSpecifier(specifier: string): BarePackageSpecifier | nu
730
747
  }
731
748
 
732
749
  async function findNodePackageRoot(packageName: string, importerPath: string): Promise<string | null> {
750
+ const cacheKey = `${packageName}\0${path.resolve(path.dirname(importerPath))}`;
751
+ const cached = nodePackageRootCache.get(cacheKey);
752
+ if (cached) return cached;
753
+
754
+ const promise = findNodePackageRootUncached(packageName, importerPath);
755
+ nodePackageRootCache.set(cacheKey, promise);
756
+ return promise;
757
+ }
758
+
759
+ async function findNodePackageRootUncached(packageName: string, importerPath: string): Promise<string | null> {
733
760
  let dir = path.dirname(importerPath);
734
761
  while (true) {
735
762
  const candidate = path.join(dir, "node_modules", packageName);
@@ -745,6 +772,15 @@ async function findNodePackageRoot(packageName: string, importerPath: string): P
745
772
  }
746
773
 
747
774
  async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
775
+ const cached = packageManifestCache.get(packageRoot);
776
+ if (cached) return cached;
777
+
778
+ const promise = readPackageManifestUncached(packageRoot);
779
+ packageManifestCache.set(packageRoot, promise);
780
+ return promise;
781
+ }
782
+
783
+ async function readPackageManifestUncached(packageRoot: string): Promise<Record<string, unknown> | null> {
748
784
  try {
749
785
  const manifest = await Bun.file(path.join(packageRoot, "package.json")).json();
750
786
  return isRecord(manifest) ? manifest : null;
@@ -841,6 +877,17 @@ async function resolveExtensionBareDependency(specifier: string, importerPath: s
841
877
  if (!isBareExtensionDependencySpecifier(specifier)) {
842
878
  return null;
843
879
  }
880
+
881
+ const cacheKey = `${specifier}\0${path.resolve(path.dirname(importerPath))}`;
882
+ const cached = bareDependencyResolutionCache.get(cacheKey);
883
+ if (cached) return cached;
884
+
885
+ const promise = resolveExtensionBareDependencyUncached(specifier, importerPath);
886
+ bareDependencyResolutionCache.set(cacheKey, promise);
887
+ return promise;
888
+ }
889
+
890
+ async function resolveExtensionBareDependencyUncached(specifier: string, importerPath: string): Promise<string | null> {
844
891
  try {
845
892
  const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
846
893
  if (resolved && resolved !== specifier && !resolved.startsWith("node:") && !resolved.startsWith("bun:")) {
@@ -892,6 +939,15 @@ const hookedExtensionEntries = new Set<string>();
892
939
 
893
940
  /** Resolve symlinks in a path, falling back to the input if realpath fails. */
894
941
  async function realpathOrSelf(p: string): Promise<string> {
942
+ const cached = realpathCache.get(p);
943
+ if (cached) return cached;
944
+
945
+ const promise = realpathOrSelfUncached(p);
946
+ realpathCache.set(p, promise);
947
+ return promise;
948
+ }
949
+
950
+ async function realpathOrSelfUncached(p: string): Promise<string> {
895
951
  try {
896
952
  return await fs.promises.realpath(p);
897
953
  } catch {
@@ -907,8 +963,8 @@ async function realpathOrSelf(p: string): Promise<string> {
907
963
  * wherever it physically lives (a `../src` sibling, a symlinked sub-tree, …).
908
964
  * This mirrors the module set the old temp-dir mirror tracked, minus the copy.
909
965
  */
910
- async function collectExtensionModules(entryRealPath: string): Promise<Set<string>> {
911
- const modules = new Set<string>();
966
+ async function collectExtensionModules(entryRealPath: string): Promise<Map<string, string>> {
967
+ const modules = new Map<string, string>();
912
968
  const queue = [entryRealPath];
913
969
  while (queue.length > 0) {
914
970
  const file = queue.pop();
@@ -921,7 +977,7 @@ async function collectExtensionModules(entryRealPath: string): Promise<Set<strin
921
977
  } catch {
922
978
  continue;
923
979
  }
924
- modules.add(file);
980
+ modules.set(file, source);
925
981
  const dir = path.dirname(file);
926
982
  for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) {
927
983
  const specifier = match[1];
@@ -949,25 +1005,38 @@ async function collectExtensionModules(entryRealPath: string): Promise<Set<strin
949
1005
  * so the filter is an exact-path alternation of the graph's realpaths — it
950
1006
  * never matches the host, other extensions, `node_modules` deps, or unrelated
951
1007
  * project source.
1008
+ *
1009
+ * Returns the collected path→source map on first install so the caller can
1010
+ * drop entries the initial import never consumed; `undefined` when the hook
1011
+ * was already installed.
952
1012
  */
953
- async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
1013
+ async function ensureExtensionGraphHook(entryRealPath: string): Promise<Map<string, string> | undefined> {
954
1014
  if (hookedExtensionEntries.has(entryRealPath)) {
955
- return;
1015
+ return undefined;
956
1016
  }
957
1017
  hookedExtensionEntries.add(entryRealPath);
958
1018
 
959
1019
  const modules = await collectExtensionModules(entryRealPath);
960
- const alternation = [...modules].map(escapeRegExp).join("|");
1020
+ const alternation = [...modules.keys()].map(escapeRegExp).join("|");
961
1021
  const filter = new RegExp(`^(?:${alternation})$`);
962
1022
  Bun.plugin({
963
1023
  name: `omp:legacy-pi-ext:${Bun.hash(entryRealPath).toString(36)}`,
964
1024
  setup(build) {
965
1025
  build.onLoad({ filter, namespace: "file" }, async args => {
966
- const raw = await Bun.file(args.path).text();
1026
+ const cached = modules.get(args.path);
1027
+ let raw: string;
1028
+ if (cached !== undefined) {
1029
+ // consume-once: preserves ?mtime edit-pickup for the re-imported entry
1030
+ modules.delete(args.path);
1031
+ raw = cached;
1032
+ } else {
1033
+ raw = await Bun.file(args.path).text();
1034
+ }
967
1035
  return { contents: await rewriteLegacyExtensionSource(raw, args.path), loader: getLoader(args.path) };
968
1036
  });
969
1037
  },
970
1038
  });
1039
+ return modules;
971
1040
  }
972
1041
 
973
1042
  /**
@@ -986,9 +1055,16 @@ export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown>
986
1055
  // `bun link`/pnpm installs) so the rewrite filter matches the path Bun
987
1056
  // actually hands the hook.
988
1057
  const entryRealPath = await realpathOrSelf(path.resolve(resolvedPath));
989
- await ensureExtensionGraphHook(entryRealPath);
990
- // `?mtime` busts Bun's module cache so repeat loads pick up edited source.
991
- return import(`${toImportSpecifier(entryRealPath)}?mtime=${Date.now()}`);
1058
+ const pendingSources = await ensureExtensionGraphHook(entryRealPath);
1059
+ try {
1060
+ // `?mtime` busts Bun's module cache so repeat loads pick up edited source.
1061
+ return await import(`${toImportSpecifier(entryRealPath)}?mtime=${Date.now()}`);
1062
+ } finally {
1063
+ // Drop whatever the initial import didn't consume: graph modules only
1064
+ // reached by lazy dynamic imports must be read from disk at their actual
1065
+ // import time, not served from this load-time snapshot.
1066
+ pendingSources?.clear();
1067
+ }
992
1068
  }
993
1069
 
994
1070
  function getLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
@@ -1061,5 +1137,5 @@ export function installLegacyPiSpecifierShim(): void {
1061
1137
 
1062
1138
  /** Test seam: clears the memoized canonical specifier resolutions. */
1063
1139
  export function __resetLegacyPiResolutionCache(): void {
1064
- resolvedSpecifierFallbacks.clear();
1140
+ clearLegacyPiResolutionCaches();
1065
1141
  }
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { getPluginsDir, getPluginsLockfile, isEnoent } from "@oh-my-pi/pi-utils";
10
10
  import { getConfigDirPaths } from "../../config";
11
- import { resolveActiveProjectRegistryPath } from "../../discovery/helpers";
11
+ import { registerPluginCacheInvalidator, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
12
12
  import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
13
13
  import { normalizePluginRuntimeConfig } from "./runtime-config";
14
14
  import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types";
@@ -20,6 +20,18 @@ export interface ScopedInstalledPlugin extends InstalledPlugin {
20
20
 
21
21
  installLegacyPiSpecifierShim();
22
22
 
23
+ const enabledPluginsCache = new Map<string, Promise<ScopedInstalledPlugin[]>>();
24
+
25
+ function enabledPluginsCacheKey(cwd: string, home?: string): string {
26
+ return `${path.resolve(cwd)}\0${home === undefined ? "" : path.resolve(home)}`;
27
+ }
28
+
29
+ function clearEnabledPluginsCache(): void {
30
+ enabledPluginsCache.clear();
31
+ }
32
+
33
+ registerPluginCacheInvalidator(clearEnabledPluginsCache);
34
+
23
35
  // =============================================================================
24
36
  // Runtime Config Loading
25
37
  // =============================================================================
@@ -163,6 +175,23 @@ async function collectPluginsAtRoot(
163
175
  */
164
176
  export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise<ScopedInstalledPlugin[]> {
165
177
  const { home } = opts;
178
+ const cacheKey = enabledPluginsCacheKey(cwd, home);
179
+ const cached = enabledPluginsCache.get(cacheKey);
180
+ if (cached) return cached;
181
+
182
+ const loadPromise = loadEnabledPlugins(cwd, home);
183
+ enabledPluginsCache.set(cacheKey, loadPromise);
184
+ try {
185
+ return await loadPromise;
186
+ } catch (err) {
187
+ if (enabledPluginsCache.get(cacheKey) === loadPromise) {
188
+ enabledPluginsCache.delete(cacheKey);
189
+ }
190
+ throw err;
191
+ }
192
+ }
193
+
194
+ async function loadEnabledPlugins(cwd: string, home?: string): Promise<ScopedInstalledPlugin[]> {
166
195
  const projectOverrides = await loadProjectOverrides(cwd);
167
196
 
168
197
  const userRoot = getPluginsDir(home);
@@ -458,10 +458,17 @@ export class PluginManager {
458
458
  stderr: "pipe",
459
459
  windowsHide: true,
460
460
  });
461
- const installExit = await installProc.exited;
461
+ // Drain stdout+stderr concurrently with proc.exited. Awaiting exited
462
+ // before reading either pipe risks a >64 KiB OS-pipe-buffer deadlock
463
+ // once bun install prints enough progress; even where Bun currently
464
+ // buffers eagerly, doing this leaks unbounded memory.
465
+ const [installExit, , installStderr] = await Promise.all([
466
+ installProc.exited,
467
+ new Response(installProc.stdout).text(),
468
+ new Response(installProc.stderr).text(),
469
+ ]);
462
470
  if (installExit !== 0) {
463
- const stderr = await new Response(installProc.stderr).text();
464
- throw new Error(`bun install failed: ${stderr}`);
471
+ throw new Error(`bun install failed: ${installStderr}`);
465
472
  }
466
473
  // Resolve actual package name. npm specs encode the name (strip version);
467
474
  // git specs do not, so diff plugins/package.json deps to find the new entry.
@@ -508,10 +515,14 @@ export class PluginManager {
508
515
  stderr: "pipe",
509
516
  windowsHide: true,
510
517
  });
511
- const updateExit = await updateProc.exited;
518
+ // Same drain-concurrent-with-exit pattern as the bun install above.
519
+ const [updateExit, , updateStderr] = await Promise.all([
520
+ updateProc.exited,
521
+ new Response(updateProc.stdout).text(),
522
+ new Response(updateProc.stderr).text(),
523
+ ]);
512
524
  if (updateExit !== 0) {
513
- const stderr = await new Response(updateProc.stderr).text();
514
- throw new Error(`bun update ${actualName} failed: ${stderr}`);
525
+ throw new Error(`bun update ${actualName} failed: ${updateStderr}`);
515
526
  }
516
527
  }
517
528
 
@@ -608,7 +619,13 @@ export class PluginManager {
608
619
  windowsHide: true,
609
620
  });
610
621
 
611
- const exitCode = await proc.exited;
622
+ // Drain both pipes concurrently with proc.exited to avoid a pipe-buffer
623
+ // deadlock if bun uninstall floods stdout/stderr.
624
+ const [exitCode] = await Promise.all([
625
+ proc.exited,
626
+ new Response(proc.stdout).text(),
627
+ new Response(proc.stderr).text(),
628
+ ]);
612
629
  if (exitCode !== 0) {
613
630
  throw new Error(`npm uninstall failed for ${name}`);
614
631
  }
@@ -1007,7 +1024,14 @@ export class PluginManager {
1007
1024
  stderr: "pipe",
1008
1025
  windowsHide: true,
1009
1026
  });
1010
- return (await proc.exited) === 0;
1027
+ // Drain pipes concurrently with proc.exited; otherwise a chatty
1028
+ // bun install can block on a full OS pipe buffer.
1029
+ const [exit] = await Promise.all([
1030
+ proc.exited,
1031
+ new Response(proc.stdout).text(),
1032
+ new Response(proc.stderr).text(),
1033
+ ]);
1034
+ return exit === 0;
1011
1035
  } catch {
1012
1036
  return false;
1013
1037
  }
@@ -86,20 +86,22 @@ export function formatPluginSpec(spec: ParsedPluginSpec): string {
86
86
  }
87
87
 
88
88
  /**
89
- * Extract the base package name without version specifier.
89
+ * Extract the dependency key from an npm package specifier.
90
90
  * Used for path lookups after npm install.
91
91
  *
92
92
  * @example
93
93
  * extractPackageName("lodash@4.17.21") // "lodash"
94
94
  * extractPackageName("@scope/pkg@1.0.0") // "@scope/pkg"
95
95
  * extractPackageName("@scope/pkg") // "@scope/pkg"
96
+ * extractPackageName("npm:lodash") // "lodash"
96
97
  */
97
98
  export function extractPackageName(specifier: string): string {
99
+ const npmSpecifier = specifier.replace(/^npm:/i, "");
98
100
  // Handle scoped packages: @scope/name@version -> @scope/name
99
- if (specifier.startsWith("@")) {
100
- const match = specifier.match(/^(@[^/]+\/[^@]+)/);
101
- return match ? match[1] : specifier;
101
+ if (npmSpecifier.startsWith("@")) {
102
+ const match = npmSpecifier.match(/^(@[^/]+\/[^@]+)/);
103
+ return match ? match[1] : npmSpecifier;
102
104
  }
103
105
  // Unscoped: name@version -> name
104
- return specifier.replace(/@[^@]+$/, "");
106
+ return npmSpecifier.replace(/@[^@]+$/, "");
105
107
  }
@@ -60,7 +60,7 @@ export function normalizeToolEventInput(toolName: string, input: Record<string,
60
60
  if (toolName !== "edit" || stringField(input, "path")) return input;
61
61
 
62
62
  // Hashline edit mode: the only authoritative target list is the parsed
63
- // `¶PATH#TAG` headers inside `input`/`_input`. Trusting a passthrough
63
+ // `¶PATH#TAG` headers inside the patch. Trusting a passthrough
64
64
  // `_path` here would let a model-supplied field override the real edit
65
65
  // target and bypass extension gates that allowlist by path.
66
66
  const rawInput = stringField(input, "input") ?? stringField(input, "_input");
@@ -0,0 +1,33 @@
1
+ import { afterEach, describe, expect, it, vi } from "bun:test";
2
+ import { HindsightApi } from "./client";
3
+
4
+ type FetchInput = string | URL | Request;
5
+ type FetchInit = RequestInit | BunFetchRequestInit;
6
+
7
+ describe("HindsightApi fetch cancellation", () => {
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("combines caller cancellation with the request timeout", async () => {
13
+ let requestSignal: AbortSignal | undefined;
14
+ const fetchStub = Object.assign(
15
+ async (_input: FetchInput, init?: FetchInit) => {
16
+ requestSignal = init?.signal ?? undefined;
17
+ return Response.json({ results: [] });
18
+ },
19
+ { preconnect: globalThis.fetch.preconnect },
20
+ );
21
+ vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
22
+
23
+ const caller = new AbortController();
24
+ const client = new HindsightApi({ baseUrl: "https://hindsight.example" });
25
+ await client.recall("bank", "query", { signal: caller.signal });
26
+
27
+ expect(requestSignal).toBeInstanceOf(AbortSignal);
28
+ expect(requestSignal).not.toBe(caller.signal);
29
+ caller.abort(new Error("caller aborted"));
30
+ expect(requestSignal?.aborted).toBe(true);
31
+ expect(requestSignal?.reason).toBe(caller.signal.reason);
32
+ });
33
+ });