@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.
- package/CHANGELOG.md +53 -0
- package/dist/cli.js +3610 -3566
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/irc/bus.d.ts +5 -4
- package/dist/types/session/agent-session.d.ts +13 -13
- package/dist/types/task/discovery.d.ts +5 -2
- package/dist/types/task/renderer.d.ts +1 -0
- package/dist/types/tools/ast-grep.d.ts +4 -2
- package/dist/types/tools/browser/render.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +1 -0
- package/dist/types/tools/eval-render.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -2
- package/dist/types/tools/grep.d.ts +4 -3
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tools/renderers.d.ts +11 -0
- package/dist/types/tools/ssh.d.ts +2 -1
- package/dist/types/tts/index.d.ts +2 -0
- package/dist/types/tts/speakable.d.ts +47 -0
- package/dist/types/tts/speech-enhancer.d.ts +46 -0
- package/dist/types/tts/streaming-player.d.ts +1 -2
- package/dist/types/tts/tts-client.d.ts +11 -10
- package/dist/types/tts/tts-protocol.d.ts +7 -0
- package/dist/types/tts/vocalizer.d.ts +15 -8
- package/dist/types/utils/git.d.ts +2 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/cli/gallery-fixtures/fs.ts +2 -2
- package/src/cli/gallery-fixtures/search.ts +2 -2
- package/src/cli.ts +27 -5
- package/src/config/model-resolver.ts +31 -10
- package/src/config/settings-schema.ts +11 -0
- package/src/cursor.ts +1 -1
- package/src/discovery/helpers.ts +8 -0
- package/src/export/html/tool-views.generated.js +34 -34
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/extensions/loader.ts +10 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
- package/src/extensibility/plugins/loader.ts +30 -1
- package/src/irc/bus.ts +5 -4
- package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
- package/src/modes/components/assistant-message.ts +1 -1
- package/src/modes/components/move-overlay.ts +35 -23
- package/src/modes/components/tool-execution.ts +45 -12
- package/src/modes/components/tree-selector.ts +10 -3
- package/src/modes/controllers/event-controller.ts +16 -0
- package/src/prompts/goals/goal-mode-context.md +4 -0
- package/src/prompts/goals/goal-todo-context.md +10 -2
- package/src/prompts/system/speech-rewrite.md +15 -0
- package/src/prompts/system/tiny-title-system.md +1 -1
- package/src/prompts/system/title-system-marker.md +2 -1
- package/src/prompts/system/title-system.md +2 -1
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/glob.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +163 -49
- package/src/session/session-history-format.ts +6 -2
- package/src/task/discovery.ts +25 -2
- package/src/task/executor.ts +24 -4
- package/src/task/render.ts +26 -12
- package/src/task/renderer.ts +1 -0
- package/src/task/worktree.ts +144 -16
- package/src/tiny/text.ts +109 -17
- package/src/tools/ast-grep.ts +20 -17
- package/src/tools/bash.ts +16 -8
- package/src/tools/browser/render.ts +2 -0
- package/src/tools/debug.ts +1 -0
- package/src/tools/eval-render.ts +5 -2
- package/src/tools/gh-renderer.ts +3 -0
- package/src/tools/glob.ts +24 -20
- package/src/tools/grep.ts +18 -36
- package/src/tools/path-utils.ts +55 -10
- package/src/tools/read.ts +9 -2
- package/src/tools/renderers.ts +11 -0
- package/src/tools/ssh.ts +17 -7
- package/src/tts/index.ts +2 -0
- package/src/tts/speakable.ts +382 -0
- package/src/tts/speech-enhancer.ts +204 -0
- package/src/tts/streaming-player.ts +71 -16
- package/src/tts/tts-client.ts +11 -10
- package/src/tts/tts-protocol.ts +14 -5
- package/src/tts/tts-worker.ts +52 -49
- package/src/tts/vocalizer.ts +277 -46
- 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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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<
|
|
911
|
-
const modules = new
|
|
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.
|
|
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<
|
|
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
|
|
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
|
-
|
|
991
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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:
|
|
32
|
+
const dirCache = new Map<string, { time: number; entries: fs.Dirent[] }>();
|
|
33
33
|
|
|
34
|
-
function readDirCached(dir: 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
|
|
80
|
-
for (const
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
122
|
-
for (const
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
550
|
+
* Start or stop spinner animation for live states that visibly tick.
|
|
550
551
|
*/
|
|
551
552
|
#updateSpinnerAnimation(): void {
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
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
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
1176
|
-
|
|
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/
|
|
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
|
|
757
|
-
|
|
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,
|