@yeaft/webchat-agent 1.0.299 → 1.0.301
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/context.js +1 -0
- package/index.js +9 -0
- package/local-runtime/server/database.js +1 -0
- package/local-runtime/server/db/connection.js +86 -0
- package/local-runtime/server/db/yeaft-project-db.js +225 -0
- package/local-runtime/server/handlers/agent-output.js +41 -5
- package/local-runtime/server/handlers/client-conversation.js +73 -0
- package/local-runtime/server/ws-utils.js +5 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +73 -78
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/cli-session-runner.js +3 -2
- package/yeaft/cli.js +20 -1
- package/yeaft/conversation/search.js +13 -7
- package/yeaft/engine.js +29 -5
- package/yeaft/managed-cli.js +618 -0
- package/yeaft/session.js +7 -0
- package/yeaft/sub-agent/runner.js +6 -0
- package/yeaft/tools/disk-usage.js +243 -0
- package/yeaft/tools/glob.js +84 -28
- package/yeaft/tools/grep.js +616 -152
- package/yeaft/tools/history-search.js +12 -3
- package/yeaft/tools/index.js +2 -0
- package/yeaft/tools/process-runner.js +211 -0
- package/yeaft/tools/search-paths.js +108 -0
- package/yeaft/tools/types.js +2 -0
- package/yeaft/web-bridge.js +93 -6
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { lstat, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { defineTool } from './types.js';
|
|
5
|
+
import { managedCliToolReady, resolveManagedCliCommand } from '../managed-cli.js';
|
|
6
|
+
import { runProcess } from './process-runner.js';
|
|
7
|
+
import { isAbortError, throwIfAborted, waitForAbortable } from './search-paths.js';
|
|
8
|
+
|
|
9
|
+
const FALLBACK_CONCURRENCY = 16;
|
|
10
|
+
const MAX_LIMIT = 200;
|
|
11
|
+
const MAX_DEPTH = 10;
|
|
12
|
+
const MAX_OUTPUT_BYTES = 512 * 1024;
|
|
13
|
+
|
|
14
|
+
function formatSize(bytes) {
|
|
15
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
16
|
+
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
17
|
+
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)}MB`;
|
|
18
|
+
return `${(bytes / 1024 ** 3).toFixed(1)}GB`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseDustBytes(value) {
|
|
22
|
+
const match = String(value || '').match(/^(\d+)B$/);
|
|
23
|
+
return match ? Number(match[1]) : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function flattenDustTree(root, baseDir, depth, limit) {
|
|
27
|
+
const rows = [];
|
|
28
|
+
function visit(node, level) {
|
|
29
|
+
if (!node || level > depth) return;
|
|
30
|
+
rows.push({
|
|
31
|
+
path: level === 0 ? '.' : relative(baseDir, node.name) || basename(node.name),
|
|
32
|
+
size: parseDustBytes(node.size),
|
|
33
|
+
level,
|
|
34
|
+
});
|
|
35
|
+
for (const child of node.children || []) visit(child, level + 1);
|
|
36
|
+
}
|
|
37
|
+
visit(root, 0);
|
|
38
|
+
const total = rows.shift();
|
|
39
|
+
rows.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
|
|
40
|
+
return [total, ...rows.slice(0, Math.max(0, limit - 1))].filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runDust(command, baseDir, { depth, limit, signal }) {
|
|
44
|
+
const result = await runProcess(command, [
|
|
45
|
+
'--output-json',
|
|
46
|
+
'--depth', String(depth),
|
|
47
|
+
'--apparent-size',
|
|
48
|
+
'--only-dir',
|
|
49
|
+
'--no-progress',
|
|
50
|
+
'--output-format', 'b',
|
|
51
|
+
baseDir,
|
|
52
|
+
], {
|
|
53
|
+
cwd: baseDir,
|
|
54
|
+
signal,
|
|
55
|
+
timeoutMs: 120_000,
|
|
56
|
+
maxBytes: MAX_OUTPUT_BYTES,
|
|
57
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
58
|
+
});
|
|
59
|
+
if (result.timedOut) throw new Error('dust timed out');
|
|
60
|
+
if (result.truncated) throw new Error('dust output exceeded the tool limit');
|
|
61
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || `dust exited with code ${result.code}`);
|
|
62
|
+
return flattenDustTree(JSON.parse(result.stdout), baseDir, depth, limit);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function validateDiskUsageRoot(path, signal, fsOps = { lstat, stat }) {
|
|
66
|
+
throwIfAborted(signal);
|
|
67
|
+
const rootStat = await fsOps.lstat(path);
|
|
68
|
+
throwIfAborted(signal);
|
|
69
|
+
if (rootStat.isDirectory()) return rootStat;
|
|
70
|
+
if (rootStat.isSymbolicLink()) {
|
|
71
|
+
const targetStat = await fsOps.stat(path);
|
|
72
|
+
throwIfAborted(signal);
|
|
73
|
+
if (targetStat.isDirectory()) return rootStat;
|
|
74
|
+
}
|
|
75
|
+
throw new Error('path must be a directory or a directory symlink');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function nodeDiskUsage(
|
|
79
|
+
baseDir,
|
|
80
|
+
depth,
|
|
81
|
+
limit,
|
|
82
|
+
signal,
|
|
83
|
+
fsOps = { lstat, readdir, stat },
|
|
84
|
+
) {
|
|
85
|
+
const rootStat = await validateDiskUsageRoot(baseDir, signal, fsOps);
|
|
86
|
+
const root = { path: baseDir, level: 0, size: rootStat.size, parent: null };
|
|
87
|
+
const directories = [root];
|
|
88
|
+
const linkedDirectories = [];
|
|
89
|
+
|
|
90
|
+
async function scanDirectory(directory) {
|
|
91
|
+
throwIfAborted(signal);
|
|
92
|
+
let entries;
|
|
93
|
+
try { entries = await fsOps.readdir(directory.path, { withFileTypes: true }); } catch (error) {
|
|
94
|
+
if (isAbortError(error)) throw error;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
throwIfAborted(signal);
|
|
98
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
throwIfAborted(signal);
|
|
101
|
+
const childPath = join(directory.path, entry.name);
|
|
102
|
+
let childStat;
|
|
103
|
+
try { childStat = await fsOps.lstat(childPath); } catch (error) {
|
|
104
|
+
if (isAbortError(error)) throw error;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
throwIfAborted(signal);
|
|
108
|
+
if (childStat.isSymbolicLink()) {
|
|
109
|
+
directory.size += childStat.size;
|
|
110
|
+
let targetIsDirectory = false;
|
|
111
|
+
try { targetIsDirectory = (await fsOps.stat(childPath)).isDirectory(); } catch (error) {
|
|
112
|
+
if (isAbortError(error)) throw error;
|
|
113
|
+
}
|
|
114
|
+
throwIfAborted(signal);
|
|
115
|
+
if (targetIsDirectory && directory.level + 1 <= depth) {
|
|
116
|
+
linkedDirectories.push({
|
|
117
|
+
path: relative(baseDir, childPath),
|
|
118
|
+
size: childStat.size,
|
|
119
|
+
level: directory.level + 1,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} else if (childStat.isDirectory()) {
|
|
123
|
+
directories.push({
|
|
124
|
+
path: childPath,
|
|
125
|
+
level: directory.level + 1,
|
|
126
|
+
size: childStat.size,
|
|
127
|
+
parent: directory,
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
directory.size += childStat.size;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let cursor = 0;
|
|
136
|
+
while (cursor < directories.length) {
|
|
137
|
+
throwIfAborted(signal);
|
|
138
|
+
const batch = directories.slice(cursor, cursor + FALLBACK_CONCURRENCY);
|
|
139
|
+
cursor += batch.length;
|
|
140
|
+
const settled = await Promise.allSettled(batch.map(scanDirectory));
|
|
141
|
+
const rejected = settled.find(result => result.status === 'rejected');
|
|
142
|
+
if (rejected) throw rejected.reason;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (let index = directories.length - 1; index > 0; index -= 1) {
|
|
146
|
+
directories[index].parent.size += directories[index].size;
|
|
147
|
+
}
|
|
148
|
+
const rows = [
|
|
149
|
+
...directories
|
|
150
|
+
.filter(directory => directory.level <= depth)
|
|
151
|
+
.map(directory => ({
|
|
152
|
+
path: directory.level === 0 ? '.' : relative(baseDir, directory.path),
|
|
153
|
+
size: directory.size,
|
|
154
|
+
level: directory.level,
|
|
155
|
+
})),
|
|
156
|
+
...linkedDirectories,
|
|
157
|
+
];
|
|
158
|
+
const total = rows.find(row => row.level === 0);
|
|
159
|
+
const children = rows
|
|
160
|
+
.filter(row => row.level > 0)
|
|
161
|
+
.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path))
|
|
162
|
+
.slice(0, Math.max(0, limit - 1));
|
|
163
|
+
return [total, ...children].filter(Boolean);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatRows(baseDir, rows) {
|
|
167
|
+
const lines = rows.map(row => `${formatSize(row.size).padStart(9)} ${row.path}`);
|
|
168
|
+
return `${baseDir}\n\n${lines.join('\n')}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export default defineTool({
|
|
172
|
+
name: 'DiskUsage',
|
|
173
|
+
description: {
|
|
174
|
+
en: `Show the largest directories under a path by apparent size.
|
|
175
|
+
|
|
176
|
+
Uses dust when available for parallel disk-usage scanning, with a Node.js fallback.
|
|
177
|
+
The root total is shown first, followed by the largest descendants. A root directory
|
|
178
|
+
symlink is scanned like dust; descendant symlinks are not followed.`,
|
|
179
|
+
zh: `按表观大小显示路径下占用最大的目录。
|
|
180
|
+
|
|
181
|
+
优先使用 dust 并行扫描磁盘用量,回退到 Node.js 实现。根路径总量排在首行,之后按大小列出后代目录;
|
|
182
|
+
根目录符号链接与 dust 一样扫描目标内容,后代符号链接不跟随。`,
|
|
183
|
+
},
|
|
184
|
+
parameters: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
path: {
|
|
188
|
+
type: 'string',
|
|
189
|
+
description: { en: 'Directory to inspect (default: cwd)', zh: '要检查的目录(默认当前工作目录)' },
|
|
190
|
+
},
|
|
191
|
+
depth: {
|
|
192
|
+
type: 'integer',
|
|
193
|
+
minimum: 0,
|
|
194
|
+
maximum: MAX_DEPTH,
|
|
195
|
+
description: { en: 'Maximum directory depth to show (default: 2)', zh: '要显示的最大目录深度(默认 2)' },
|
|
196
|
+
},
|
|
197
|
+
limit: {
|
|
198
|
+
type: 'integer',
|
|
199
|
+
minimum: 1,
|
|
200
|
+
maximum: MAX_LIMIT,
|
|
201
|
+
description: { en: 'Maximum rows to return (default: 20)', zh: '最多返回行数(默认 20)' },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
isConcurrencySafe: () => true,
|
|
206
|
+
isReadOnly: () => true,
|
|
207
|
+
async execute(input, ctx) {
|
|
208
|
+
const { path, depth = 2, limit = 20 } = input;
|
|
209
|
+
if (!Number.isInteger(depth) || depth < 0 || depth > MAX_DEPTH) {
|
|
210
|
+
return JSON.stringify({ error: `depth must be an integer between 0 and ${MAX_DEPTH}` });
|
|
211
|
+
}
|
|
212
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
213
|
+
return JSON.stringify({ error: `limit must be an integer between 1 and ${MAX_LIMIT}` });
|
|
214
|
+
}
|
|
215
|
+
const cwd = ctx?.cwd || process.cwd();
|
|
216
|
+
const baseDir = path ? resolve(cwd, path) : cwd;
|
|
217
|
+
if (!existsSync(baseDir)) return JSON.stringify({ error: `Directory not found: ${baseDir}` });
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
throwIfAborted(ctx?.signal);
|
|
221
|
+
await validateDiskUsageRoot(baseDir, ctx?.signal);
|
|
222
|
+
let rows;
|
|
223
|
+
let dustCommand = resolveManagedCliCommand('dust', { yeaftDir: ctx?.yeaftDir });
|
|
224
|
+
if (!dustCommand) {
|
|
225
|
+
await waitForAbortable(managedCliToolReady(ctx?.managedCliReady, 'dust'), ctx?.signal);
|
|
226
|
+
dustCommand = resolveManagedCliCommand('dust', { yeaftDir: ctx?.yeaftDir });
|
|
227
|
+
}
|
|
228
|
+
if (dustCommand) {
|
|
229
|
+
try {
|
|
230
|
+
rows = await runDust(dustCommand, baseDir, { depth, limit, signal: ctx?.signal });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (isAbortError(error)) throw error;
|
|
233
|
+
rows = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!rows) rows = await nodeDiskUsage(baseDir, depth, limit, ctx?.signal);
|
|
237
|
+
return formatRows(baseDir, rows);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (isAbortError(error)) throw error;
|
|
240
|
+
return JSON.stringify({ error: `Disk usage scan failed: ${error.message}` });
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
});
|
package/yeaft/tools/glob.js
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* glob.js — Find files by pattern matching.
|
|
3
3
|
*
|
|
4
|
-
* Uses
|
|
5
|
-
* Results are sorted by modification time (newest first).
|
|
4
|
+
* Uses fd for traversal when available and the same local glob matcher in both
|
|
5
|
+
* fast and fallback paths. Results are sorted by modification time (newest first).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { defineTool } from './types.js';
|
|
9
9
|
import { readdir, stat } from 'fs/promises';
|
|
10
10
|
import { existsSync } from 'fs';
|
|
11
11
|
import { resolve, join, relative } from 'path';
|
|
12
|
+
import { managedCliToolReady, resolveManagedCliCommand } from '../managed-cli.js';
|
|
13
|
+
import { runProcess } from './process-runner.js';
|
|
14
|
+
import {
|
|
15
|
+
createSearchPathMatcher,
|
|
16
|
+
isAbortError,
|
|
17
|
+
isSkippedSearchDirectory,
|
|
18
|
+
SEARCH_SKIP_DIRS,
|
|
19
|
+
throwIfAborted,
|
|
20
|
+
waitForAbortable,
|
|
21
|
+
} from './search-paths.js';
|
|
12
22
|
|
|
13
23
|
const STAT_CONCURRENCY = 32;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
]);
|
|
24
|
+
|
|
25
|
+
function comparePaths(left, right) {
|
|
26
|
+
return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
|
27
|
+
}
|
|
19
28
|
|
|
20
29
|
/**
|
|
21
30
|
* Simple glob pattern matcher (supports * and **).
|
|
@@ -24,48 +33,72 @@ const SKIP_DIRS = new Set([
|
|
|
24
33
|
* @returns {boolean}
|
|
25
34
|
*/
|
|
26
35
|
function matchGlob(pattern, str) {
|
|
27
|
-
|
|
28
|
-
// IMPORTANT: escape dots FIRST before replacing glob chars to avoid
|
|
29
|
-
// corrupting regex tokens like [^/]* and .*
|
|
30
|
-
let regex = pattern
|
|
31
|
-
.replace(/\\/g, '/')
|
|
32
|
-
.replace(/\./g, '\\.') // Escape dots first (before glob replacements)
|
|
33
|
-
.replace(/\*\*/g, '<<<GLOBSTAR>>>')
|
|
34
|
-
.replace(/\*/g, '[^/]*')
|
|
35
|
-
.replace(/<<<GLOBSTAR>>>/g, '.*')
|
|
36
|
-
.replace(/\?/g, '[^/]');
|
|
37
|
-
regex = '^' + regex + '$';
|
|
38
|
-
return new RegExp(regex).test(str.replace(/\\/g, '/'));
|
|
36
|
+
return createSearchPathMatcher({ glob: pattern })(str);
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
/**
|
|
42
40
|
* Recursively walk a directory, yielding relative paths.
|
|
43
41
|
*/
|
|
44
|
-
async function* walkDir(dir, baseDir, maxDepth = 10, depth = 0) {
|
|
42
|
+
async function* walkDir(dir, baseDir, signal, maxDepth = 10, depth = 0) {
|
|
43
|
+
throwIfAborted(signal);
|
|
45
44
|
if (depth > maxDepth) return;
|
|
46
45
|
|
|
47
46
|
let entries;
|
|
48
47
|
try {
|
|
49
48
|
entries = await readdir(dir, { withFileTypes: true });
|
|
50
|
-
} catch {
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (isAbortError(error)) throw error;
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
|
+
throwIfAborted(signal);
|
|
53
54
|
|
|
54
55
|
for (const entry of entries) {
|
|
56
|
+
throwIfAborted(signal);
|
|
55
57
|
const fullPath = join(dir, entry.name);
|
|
56
58
|
const relPath = relative(baseDir, fullPath);
|
|
57
59
|
|
|
58
60
|
if (entry.isDirectory()) {
|
|
59
61
|
const normalized = relPath.replace(/\\/g, '/');
|
|
60
|
-
if (
|
|
62
|
+
if (isSkippedSearchDirectory(normalized, entry.name)) continue;
|
|
61
63
|
yield { path: relPath, isDir: true };
|
|
62
|
-
yield* walkDir(fullPath, baseDir, maxDepth, depth + 1);
|
|
64
|
+
yield* walkDir(fullPath, baseDir, signal, maxDepth, depth + 1);
|
|
63
65
|
} else {
|
|
64
66
|
yield { path: relPath, isDir: false };
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
async function listFilesWithFd(fdCommand, baseDir, signal) {
|
|
72
|
+
const args = [
|
|
73
|
+
'.', baseDir,
|
|
74
|
+
'--type', 'file',
|
|
75
|
+
'--type', 'symlink',
|
|
76
|
+
'--hidden',
|
|
77
|
+
'--no-ignore',
|
|
78
|
+
'--color', 'never',
|
|
79
|
+
'--max-depth', '11',
|
|
80
|
+
'--print0',
|
|
81
|
+
];
|
|
82
|
+
for (const skipped of SEARCH_SKIP_DIRS) args.push('--exclude', skipped);
|
|
83
|
+
args.push('--exclude', '.yeaft/worktrees', '--exclude', '**/.yeaft/worktrees/**');
|
|
84
|
+
const result = await runProcess(fdCommand, args, {
|
|
85
|
+
cwd: baseDir,
|
|
86
|
+
signal,
|
|
87
|
+
timeoutMs: 120_000,
|
|
88
|
+
maxBytes: 16 * 1024 * 1024,
|
|
89
|
+
preserveCarriageReturns: true,
|
|
90
|
+
});
|
|
91
|
+
if (result.timedOut) throw new Error('fd timed out');
|
|
92
|
+
if (result.truncated) throw new Error('fd output exceeded the tool limit');
|
|
93
|
+
if (result.code !== 0) {
|
|
94
|
+
throw new Error(result.stderr.trim() || `fd exited with code ${result.code}`);
|
|
95
|
+
}
|
|
96
|
+
return result.stdout
|
|
97
|
+
.split('\0')
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.map(path => relative(baseDir, resolve(baseDir, path)));
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
export default defineTool({
|
|
70
103
|
name: 'Glob',
|
|
71
104
|
description: {
|
|
@@ -134,29 +167,52 @@ Guidelines:
|
|
|
134
167
|
}
|
|
135
168
|
|
|
136
169
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
170
|
+
throwIfAborted(ctx?.signal);
|
|
171
|
+
let paths;
|
|
172
|
+
let fdCommand = resolveManagedCliCommand('fd', { yeaftDir: ctx?.yeaftDir });
|
|
173
|
+
if (!fdCommand) {
|
|
174
|
+
await waitForAbortable(managedCliToolReady(ctx?.managedCliReady, 'fd'), ctx?.signal);
|
|
175
|
+
fdCommand = resolveManagedCliCommand('fd', { yeaftDir: ctx?.yeaftDir });
|
|
176
|
+
}
|
|
177
|
+
if (fdCommand) {
|
|
178
|
+
try {
|
|
179
|
+
paths = (await listFilesWithFd(fdCommand, baseDir, ctx?.signal))
|
|
180
|
+
.filter(path => matchGlob(pattern, path));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (isAbortError(error)) throw error;
|
|
183
|
+
paths = null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!paths) {
|
|
187
|
+
paths = [];
|
|
188
|
+
for await (const entry of walkDir(baseDir, baseDir, ctx?.signal)) {
|
|
189
|
+
if (!entry.isDir && matchGlob(pattern, entry.path)) paths.push(entry.path);
|
|
190
|
+
}
|
|
140
191
|
}
|
|
141
192
|
|
|
142
193
|
// Exact newest-first semantics require every matching mtime. Batch the
|
|
143
194
|
// metadata reads instead of serializing one syscall per path.
|
|
144
195
|
const matches = [];
|
|
145
196
|
for (let i = 0; i < paths.length; i += STAT_CONCURRENCY) {
|
|
197
|
+
throwIfAborted(ctx?.signal);
|
|
146
198
|
matches.push(...await Promise.all(paths.slice(i, i + STAT_CONCURRENCY).map(async (path) => {
|
|
199
|
+
throwIfAborted(ctx?.signal);
|
|
147
200
|
try {
|
|
148
201
|
const fileStat = await stat(join(baseDir, path));
|
|
202
|
+
throwIfAborted(ctx?.signal);
|
|
149
203
|
return { path, mtime: fileStat.mtimeMs };
|
|
150
|
-
} catch {
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (isAbortError(error)) throw error;
|
|
151
206
|
return { path, mtime: 0 };
|
|
152
207
|
}
|
|
153
208
|
})));
|
|
154
209
|
}
|
|
155
|
-
matches.sort((a, b) => b.mtime - a.mtime);
|
|
210
|
+
matches.sort((a, b) => b.mtime - a.mtime || comparePaths(a.path, b.path));
|
|
156
211
|
const trimmed = matches.slice(0, limit);
|
|
157
212
|
|
|
158
213
|
return trimmed.map(m => m.path).join('\n') || '(no matches)';
|
|
159
214
|
} catch (err) {
|
|
215
|
+
if (isAbortError(err)) throw err;
|
|
160
216
|
return JSON.stringify({ error: `Glob search failed: ${err.message}` });
|
|
161
217
|
}
|
|
162
218
|
},
|