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

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 (86) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cli.js +3610 -3566
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/helpers.d.ts +2 -0
  5. package/dist/types/irc/bus.d.ts +5 -4
  6. package/dist/types/session/agent-session.d.ts +13 -13
  7. package/dist/types/task/discovery.d.ts +5 -2
  8. package/dist/types/task/renderer.d.ts +1 -0
  9. package/dist/types/tools/ast-grep.d.ts +4 -2
  10. package/dist/types/tools/browser/render.d.ts +2 -0
  11. package/dist/types/tools/debug.d.ts +1 -0
  12. package/dist/types/tools/eval-render.d.ts +2 -0
  13. package/dist/types/tools/glob.d.ts +4 -2
  14. package/dist/types/tools/grep.d.ts +4 -3
  15. package/dist/types/tools/path-utils.d.ts +7 -0
  16. package/dist/types/tools/renderers.d.ts +11 -0
  17. package/dist/types/tools/ssh.d.ts +2 -1
  18. package/dist/types/tts/index.d.ts +2 -0
  19. package/dist/types/tts/speakable.d.ts +47 -0
  20. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  21. package/dist/types/tts/streaming-player.d.ts +1 -2
  22. package/dist/types/tts/tts-client.d.ts +11 -10
  23. package/dist/types/tts/tts-protocol.d.ts +7 -0
  24. package/dist/types/tts/vocalizer.d.ts +15 -8
  25. package/dist/types/utils/git.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/src/advisor/__tests__/advisor.test.ts +1 -1
  28. package/src/cli/gallery-fixtures/fs.ts +2 -2
  29. package/src/cli/gallery-fixtures/search.ts +2 -2
  30. package/src/cli.ts +27 -5
  31. package/src/config/model-resolver.ts +31 -10
  32. package/src/config/settings-schema.ts +11 -0
  33. package/src/cursor.ts +1 -1
  34. package/src/discovery/helpers.ts +8 -0
  35. package/src/export/html/tool-views.generated.js +34 -34
  36. package/src/extensibility/custom-tools/loader.ts +3 -3
  37. package/src/extensibility/extensions/loader.ts +10 -3
  38. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  39. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  40. package/src/extensibility/plugins/loader.ts +30 -1
  41. package/src/irc/bus.ts +5 -4
  42. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  43. package/src/modes/components/assistant-message.ts +1 -1
  44. package/src/modes/components/move-overlay.ts +35 -23
  45. package/src/modes/components/tool-execution.ts +45 -12
  46. package/src/modes/components/tree-selector.ts +10 -3
  47. package/src/modes/controllers/event-controller.ts +16 -0
  48. package/src/prompts/goals/goal-mode-context.md +4 -0
  49. package/src/prompts/goals/goal-todo-context.md +10 -2
  50. package/src/prompts/system/speech-rewrite.md +15 -0
  51. package/src/prompts/system/tiny-title-system.md +1 -1
  52. package/src/prompts/system/title-system-marker.md +2 -1
  53. package/src/prompts/system/title-system.md +2 -1
  54. package/src/prompts/tools/ast-grep.md +3 -3
  55. package/src/prompts/tools/glob.md +1 -1
  56. package/src/prompts/tools/grep.md +1 -1
  57. package/src/sdk.ts +8 -5
  58. package/src/session/agent-session.ts +163 -49
  59. package/src/session/session-history-format.ts +6 -2
  60. package/src/task/discovery.ts +25 -2
  61. package/src/task/executor.ts +24 -4
  62. package/src/task/render.ts +26 -12
  63. package/src/task/renderer.ts +1 -0
  64. package/src/task/worktree.ts +144 -16
  65. package/src/tiny/text.ts +109 -17
  66. package/src/tools/ast-grep.ts +20 -17
  67. package/src/tools/bash.ts +16 -8
  68. package/src/tools/browser/render.ts +2 -0
  69. package/src/tools/debug.ts +1 -0
  70. package/src/tools/eval-render.ts +5 -2
  71. package/src/tools/gh-renderer.ts +3 -0
  72. package/src/tools/glob.ts +24 -20
  73. package/src/tools/grep.ts +18 -36
  74. package/src/tools/path-utils.ts +55 -10
  75. package/src/tools/read.ts +9 -2
  76. package/src/tools/renderers.ts +11 -0
  77. package/src/tools/ssh.ts +17 -7
  78. package/src/tts/index.ts +2 -0
  79. package/src/tts/speakable.ts +382 -0
  80. package/src/tts/speech-enhancer.ts +204 -0
  81. package/src/tts/streaming-player.ts +71 -16
  82. package/src/tts/tts-client.ts +11 -10
  83. package/src/tts/tts-protocol.ts +14 -5
  84. package/src/tts/tts-worker.ts +52 -49
  85. package/src/tts/vocalizer.ts +277 -46
  86. package/src/utils/git.ts +8 -0
@@ -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
  );
@@ -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);
package/src/irc/bus.ts CHANGED
@@ -8,10 +8,11 @@
8
8
  * agents receive the message as a non-interrupting aside at the next step
9
9
  * boundary (see AgentSession.deliverIrcMessage). Replies are real turns by
10
10
  * the recipient, observed via `wait` — with one exception: when the sender
11
- * awaits a reply and the recipient is mid-turn with async execution
12
- * disabled, the recipient session generates an ephemeral side-channel
13
- * auto-reply (it may be blocked in a synchronous task spawn whose batch
14
- * includes the sender, so a real turn could never happen in time).
11
+ * awaits a reply and the recipient cannot run a real reply turn in time
12
+ * (mid-turn with async execution disabled possibly blocked in a
13
+ * synchronous task spawn whose batch includes the sender or idle in plan
14
+ * mode, where autonomous wake turns are suppressed), the recipient session
15
+ * generates an ephemeral side-channel auto-reply.
15
16
  */
16
17
 
17
18
  import { logger, Snowflake } from "@oh-my-pi/pi-utils";
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
2
  import * as fs from "node:fs";
3
3
  import * as fsp from "node:fs/promises";
4
4
  import * as os from "node:os";
@@ -163,4 +163,75 @@ describe("MoveOverlay", () => {
163
163
  expect(result).toBeDefined();
164
164
  expect(result!.directory).toBe(path.join(cwd, "alpha"));
165
165
  });
166
+
167
+ it("does not stat every directory entry per keystroke", async () => {
168
+ // Regression: `readDirCached` used to cache names and re-`statSync` every
169
+ // entry per keystroke. Populate with mostly files so the old code path
170
+ // would have stat'd all of them; the fix classifies via `Dirent` and only
171
+ // falls back to `statSync` on symlink entries.
172
+ const bulk = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-bulk-"));
173
+ try {
174
+ for (let i = 0; i < 60; i++) {
175
+ fs.writeFileSync(path.join(bulk, `f_${String(i).padStart(3, "0")}.txt`), "x");
176
+ }
177
+ for (let i = 0; i < 10; i++) fs.mkdirSync(path.join(bulk, `sub_${i}`));
178
+
179
+ const statSpy = spyOn(fs, "statSync");
180
+ try {
181
+ const overlay = new MoveOverlay(bulk, () => {});
182
+ statSpy.mockClear();
183
+ overlay.handleInput("s");
184
+ overlay.handleInput("u");
185
+ overlay.handleInput("b");
186
+ // Each keystroke may `statSync` at most once — the
187
+ // `resolveExistingDirectory` probe on the typed prefix. Entries
188
+ // are classified via `Dirent`, not one stat apiece.
189
+ expect(statSpy.mock.calls.length).toBeLessThanOrEqual(3);
190
+ } finally {
191
+ statSpy.mockRestore();
192
+ }
193
+ } finally {
194
+ await fsp.rm(bulk, { recursive: true, force: true });
195
+ }
196
+ });
197
+
198
+ it("classifies unknown-type Dirents via statSync fallback", async () => {
199
+ // Regression: filesystems that report UV_DIRENT_UNKNOWN return Dirents
200
+ // whose isDirectory()/isFile()/isSymbolicLink() all report false. Those
201
+ // entries must fall back to statSync so /move still lists real
202
+ // directories on NFS/FUSE/older SMB.
203
+ const unknownFs = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-unknown-"));
204
+ try {
205
+ const realDir = path.join(unknownFs, "real-dir");
206
+ const realFile = path.join(unknownFs, "real-file.txt");
207
+ fs.mkdirSync(realDir);
208
+ fs.writeFileSync(realFile, "x");
209
+
210
+ const fakeDirent = (name: string): fs.Dirent =>
211
+ ({
212
+ name,
213
+ isDirectory: () => false,
214
+ isFile: () => false,
215
+ isSymbolicLink: () => false,
216
+ isBlockDevice: () => false,
217
+ isCharacterDevice: () => false,
218
+ isFIFO: () => false,
219
+ isSocket: () => false,
220
+ }) as fs.Dirent;
221
+ const readdirSpy = spyOn(fs, "readdirSync").mockReturnValue([
222
+ fakeDirent("real-dir"),
223
+ fakeDirent("real-file.txt"),
224
+ ] as never);
225
+ try {
226
+ const overlay = new MoveOverlay(unknownFs, () => {});
227
+ const text = strip(overlay.render(80));
228
+ expect(text).toContain("real-dir/");
229
+ expect(text).not.toContain("real-file.txt");
230
+ } finally {
231
+ readdirSpy.mockRestore();
232
+ }
233
+ } finally {
234
+ await fsp.rm(unknownFs, { recursive: true, force: true });
235
+ }
236
+ });
166
237
  });
@@ -708,7 +708,7 @@ export class AssistantMessageComponent extends Container {
708
708
  );
709
709
 
710
710
  // Fast path: reuse Markdown children when shape is stable during streaming
711
- if (this.#tryFastPathUpdate(message)) return;
711
+ if (this.#tryFastPathUpdate(message, opts)) return;
712
712
 
713
713
  // Clear content container
714
714
  this.#contentContainer.clear();
@@ -29,14 +29,14 @@ const OVERLAY_WIDTH = 68;
29
29
 
30
30
  /** TTL for the directory listing cache (ms). */
31
31
  const DIR_CACHE_TTL = 500;
32
- const dirCache = new Map<string, { time: number; entries: string[] }>();
32
+ const dirCache = new Map<string, { time: number; entries: fs.Dirent[] }>();
33
33
 
34
- function readDirCached(dir: string): string[] {
34
+ function readDirCached(dir: string): fs.Dirent[] {
35
35
  const now = Date.now();
36
36
  const cached = dirCache.get(dir);
37
37
  if (cached && now - cached.time < DIR_CACHE_TTL) return cached.entries;
38
38
  try {
39
- const entries = fs.readdirSync(dir);
39
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
40
40
  dirCache.set(dir, { time: now, entries });
41
41
  return entries;
42
42
  } catch {
@@ -44,6 +44,27 @@ function readDirCached(dir: string): string[] {
44
44
  }
45
45
  }
46
46
 
47
+ /**
48
+ * `Dirent.isDirectory()` reports the entry type, not the link target, so a
49
+ * `statSync` fallback is still needed for symlinks that point at a directory.
50
+ * Some filesystems (NFS, FUSE, older SMB) report `UV_DIRENT_UNKNOWN` — every
51
+ * `isX()` returns false — so those entries also fall back to `statSync` rather
52
+ * than being silently dropped from the results.
53
+ */
54
+ function entryIsDirectory(dir: string, entry: fs.Dirent): boolean {
55
+ if (entry.isDirectory()) return true;
56
+ // Fast reject only for entry types we can confidently identify as non-directory.
57
+ if (entry.isFile() || entry.isBlockDevice() || entry.isCharacterDevice() || entry.isFIFO() || entry.isSocket()) {
58
+ return false;
59
+ }
60
+ // Symlink (need target type) or unknown (filesystem didn't provide a type) — stat to find out.
61
+ try {
62
+ return fs.statSync(path.join(dir, entry.name)).isDirectory();
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
47
68
  function printableInput(data: string): string {
48
69
  const withoutPasteEnvelope = data.replaceAll("\x1b[200~", "").replaceAll("\x1b[201~", "");
49
70
  if (withoutPasteEnvelope.includes("\x1b")) return "";
@@ -76,17 +97,13 @@ export function resolveExistingDirectory(input: string, cwd: string): string | n
76
97
 
77
98
  function listChildDirectories(dirPath: string, max: number, includeHidden = false): DirEntry[] {
78
99
  const results: DirEntry[] = [];
79
- const names = readDirCached(dirPath);
80
- for (const name of names) {
100
+ const entries = readDirCached(dirPath);
101
+ for (const entry of entries) {
81
102
  if (results.length >= max) break;
103
+ const { name } = entry;
82
104
  if (!includeHidden && name.startsWith(".")) continue;
83
- const full = path.join(dirPath, name);
84
- try {
85
- if (!fs.statSync(full).isDirectory()) continue;
86
- } catch {
87
- continue;
88
- }
89
- results.push({ value: full, label: `${name}/` });
105
+ if (!entryIsDirectory(dirPath, entry)) continue;
106
+ results.push({ value: path.join(dirPath, name), label: `${name}/` });
90
107
  }
91
108
  results.sort((a, b) => a.label.localeCompare(b.label));
92
109
  return results;
@@ -118,19 +135,14 @@ function searchDirectories(prefix: string, cwd: string, max: number): DirEntry[]
118
135
 
119
136
  const lower = query.toLowerCase();
120
137
  const results: DirEntry[] = [];
121
- const names = readDirCached(baseDir);
122
- for (const name of names) {
138
+ const entries = readDirCached(baseDir);
139
+ for (const entry of entries) {
123
140
  if (results.length >= max) break;
141
+ const { name } = entry;
124
142
  if (!includeHidden && name.startsWith(".")) continue;
125
- const full = path.join(baseDir, name);
126
- try {
127
- if (!fs.statSync(full).isDirectory()) continue;
128
- } catch {
129
- continue;
130
- }
131
- if (!query || name.toLowerCase().includes(lower)) {
132
- results.push({ value: full, label: `${name}/` });
133
- }
143
+ if (query && !name.toLowerCase().includes(lower)) continue;
144
+ if (!entryIsDirectory(baseDir, entry)) continue;
145
+ results.push({ value: path.join(baseDir, name), label: `${name}/` });
134
146
  }
135
147
  return results;
136
148
  }
@@ -334,6 +334,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
334
334
  // so keep their horizontal padding even when the user enables tight layout.
335
335
  this.setIgnoreTight(true);
336
336
 
337
+ this.#updateSpinnerAnimation();
337
338
  this.#updateDisplay();
338
339
  this.#schedulePreviewDiff();
339
340
  }
@@ -546,21 +547,47 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
546
547
  }
547
548
 
548
549
  /**
549
- * Start or stop spinner animation for result states that visibly tick.
550
+ * Start or stop spinner animation for live states that visibly tick.
550
551
  */
551
552
  #updateSpinnerAnimation(): void {
552
- // Spinner for: task tool with partial result, edit/write while args
553
- // streaming, or a still-running job poll. Todo snapshots stay live for
554
- // displacement but should remain visually static.
553
+ // Live partial tool blocks stay repaintable until a terminal result seals
554
+ // them. Todo snapshots and detached background tool progress are deliberate
555
+ // static exceptions because their rows can be superseded or committed to
556
+ // scrollback while later updates continue elsewhere.
555
557
  const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
556
558
  const isBackgroundAsyncRunning =
557
559
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
558
- const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
559
- const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
560
- // Detached async task progress rows are static now; progress snapshots
561
- // still call #maybeFreezeBackgroundTask before applying so rows settle
562
- // once the block leaves the live region.
563
- const needsSpinner = isStreamingArgs || isPartialTask || this.#displaceableByToolName === "job";
560
+ const renderer = toolRenderers[this.#toolName] as
561
+ | {
562
+ animatedPendingPreview?: boolean | ((args: unknown) => boolean);
563
+ animatedPartialResult?: boolean | ((args: unknown) => boolean);
564
+ }
565
+ | undefined;
566
+ const pendingAnimation = renderer?.animatedPendingPreview;
567
+ const partialAnimation = renderer?.animatedPartialResult;
568
+ const pendingCallConsumesSpinner =
569
+ this.#result === undefined &&
570
+ (renderer === undefined
571
+ ? // Only the generic #formatToolExecution fallback consumes the frame;
572
+ // a custom renderCall/renderResult pair routes through the custom
573
+ // branch whose pending label is a static tool-name Text.
574
+ !this.#tool?.renderCall && !this.#tool?.renderResult
575
+ : typeof pendingAnimation === "function"
576
+ ? pendingAnimation(this.#args)
577
+ : pendingAnimation === true);
578
+ const partialResultConsumesSpinner =
579
+ this.#result !== undefined &&
580
+ (renderer === undefined
581
+ ? !this.#tool?.renderCall && !this.#tool?.renderResult
582
+ : typeof partialAnimation === "function"
583
+ ? partialAnimation(this.#args)
584
+ : partialAnimation === true);
585
+ const isLivePartialTool =
586
+ this.#isPartial &&
587
+ this.#toolName !== "todo" &&
588
+ !isBackgroundAsyncRunning &&
589
+ (pendingCallConsumesSpinner || partialResultConsumesSpinner);
590
+ const needsSpinner = isStreamingArgs || isLivePartialTool || this.#displaceableByToolName === "job";
564
591
  if (needsSpinner && !this.#spinnerInterval) {
565
592
  const frameCount = theme.spinnerFrames.length;
566
593
  const frame = sharedSpinnerFrame(frameCount);
@@ -1172,8 +1199,14 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
1172
1199
  */
1173
1200
  #formatToolExecution(contentWidth: number): string {
1174
1201
  const lines: string[] = [];
1175
- const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "done";
1176
- lines.push(renderStatusLine({ icon, title: this.#toolLabel }, theme));
1202
+ const icon = this.#isPartial
1203
+ ? this.#spinnerFrame !== undefined
1204
+ ? "running"
1205
+ : "pending"
1206
+ : this.#result?.isError
1207
+ ? "error"
1208
+ : "done";
1209
+ lines.push(renderStatusLine({ icon, spinnerFrame: this.#spinnerFrame, title: this.#toolLabel }, theme));
1177
1210
 
1178
1211
  const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record<string, unknown>) : null;
1179
1212
  if (!this.#expanded && argsObject && Object.keys(argsObject).length > 0) {
@@ -15,7 +15,7 @@ import type { TreeFilterMode } from "../../config/settings-schema";
15
15
  import { theme } from "../../modes/theme/theme";
16
16
  import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
17
17
  import type { SessionTreeNode } from "../../session/session-entries";
18
- import { toPathList } from "../../tools/grep";
18
+ import { toPathList } from "../../tools/path-utils";
19
19
  import { shortenPath } from "../../tools/render-utils";
20
20
  import { canonicalizeMessage } from "../../utils/thinking-display";
21
21
  import { DynamicBorder } from "./dynamic-border";
@@ -753,8 +753,15 @@ class TreeList implements Component {
753
753
  return `[grep: /${pattern}/ in ${shortenPath(scope)}]`;
754
754
  }
755
755
  case "glob": {
756
- const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.pattern || ".");
757
- return `[glob: ${shortenPath(paths)}]`;
756
+ const globInput =
757
+ typeof args.path === "string"
758
+ ? args.path
759
+ : typeof args.paths === "string" || Array.isArray(args.paths)
760
+ ? args.paths
761
+ : undefined;
762
+ const paths = toPathList(globInput);
763
+ const scope = paths.length > 0 ? paths.join(", ") : ".";
764
+ return `[glob: ${shortenPath(scope)}]`;
758
765
  }
759
766
  case "ls": {
760
767
  const path = shortenPath(String(args.path || "."));
@@ -27,6 +27,7 @@ import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../sessi
27
27
  import { previewLine, TRUNCATE_LENGTHS } from "../../tools/render-utils";
28
28
  import type { ResolveToolDetails } from "../../tools/resolve";
29
29
  import { nextActionableTask } from "../../tools/todo";
30
+ import { SpeechEnhancer } from "../../tts/speech-enhancer";
30
31
  import { vocalizer } from "../../tts/vocalizer";
31
32
  import { canonicalizeMessage } from "../../utils/thinking-display";
32
33
  import { interruptHint } from "../shared";
@@ -111,6 +112,21 @@ export class EventController {
111
112
  #terminalProgressActive = false;
112
113
 
113
114
  constructor(private ctx: InteractiveModeContext) {
115
+ // Enhanced speech (`speech.enhanced`) rewrites blocks through the
116
+ // tiny/smol role with this session's registry and credentials; the
117
+ // vocalizer falls back to mechanical cleanup when unset. Tolerates
118
+ // partial contexts (tests, minimal embeddings) by wiring null.
119
+ const session = ctx.session;
120
+ vocalizer.setEnhancer(
121
+ session?.modelRegistry && session.agent && session.settings
122
+ ? new SpeechEnhancer({
123
+ settings: session.settings,
124
+ registry: session.modelRegistry,
125
+ sessionId: session.sessionId,
126
+ metadataResolver: provider => session.agent.metadataForProvider(provider),
127
+ })
128
+ : null,
129
+ );
114
130
  this.#streamingReveal = new StreamingRevealController({
115
131
  getSmoothStreaming: () => this.ctx.settings.get("display.smoothStreaming"),
116
132
  getHideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock,
@@ -0,0 +1,4 @@
1
+ {{goalContext}}
2
+ {{#if todoContext}}
3
+ {{todoContext}}
4
+ {{/if}}