minimal-agent 0.11.0 → 0.13.0
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/package.json +5 -1
- package/plugins/workflow-runner/plugin.js +27 -15
- package/plugins/workflow-runner/plugin.ts +34 -20
- package/plugins/workflow-runner/src/agentCommon.js +13 -12
- package/plugins/workflow-runner/src/agentCommon.ts +22 -13
- package/plugins/workflow-runner/src/expressions.js +29 -8
- package/plugins/workflow-runner/src/expressions.ts +28 -10
- package/plugins/workflow-runner/src/index.js +50 -20
- package/plugins/workflow-runner/src/index.ts +75 -31
- package/plugins/workflow-runner/src/multimodal.js +2 -1
- package/plugins/workflow-runner/src/multimodal.ts +2 -1
- package/plugins/workflow-runner/src/recovery.js +9 -2
- package/plugins/workflow-runner/src/recovery.ts +9 -2
- package/plugins/workflow-runner/src/runner.js +31 -5
- package/plugins/workflow-runner/src/runner.ts +33 -5
- package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
- package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
- package/plugins/workflow-runner/src/stepExecutors/tool.js +91 -61
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +111 -71
- package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
- package/plugins/workflow-runner/src/taskIdentity.js +197 -21
- package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
- package/plugins/workflow-runner/src/toolRuntime.js +48 -0
- package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
- package/plugins/workflow-runner/src/types.js +113 -7
- package/plugins/workflow-runner/src/types.ts +137 -14
- package/plugins/workflow-runner/src/workflowState.js +455 -54
- package/plugins/workflow-runner/src/workflowState.ts +508 -63
- package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
- package/plugins/workflow-runner/test/context-files.test.ts +47 -16
- package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
- package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
- package/plugins/workflow-runner/test/expressions.test.ts +42 -0
- package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
- package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
- package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
- package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
- package/plugins/workflow-runner/test/research.load.test.ts +328 -0
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
- package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
- package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
- package/plugins/workflow-runner/test/word.load.test.ts +9 -9
- package/skills/skill-creator/scripts/run_eval.py +3 -2
- package/src/cli/print.js +36 -4
- package/src/cli/print.ts +42 -4
- package/src/cli/streamJson.js +19 -4
- package/src/cli/streamJson.ts +17 -4
- package/src/config/configDefaults.js +25 -0
- package/src/config/configDefaults.ts +33 -0
- package/src/config/configFile.js +2 -21
- package/src/config/configFile.ts +11 -24
- package/src/config/configManagement.js +1 -1
- package/src/config/configManagement.ts +2 -2
- package/src/config/configService.js +24 -0
- package/src/config/configService.ts +23 -0
- package/src/context/compact.js +4 -0
- package/src/context/compact.ts +4 -0
- package/src/context/contextBundle.js +38 -13
- package/src/context/contextBundle.ts +47 -13
- package/src/harness/stallGuard.js +19 -4
- package/src/harness/stallGuard.ts +30 -7
- package/src/infra/verification.js +25 -18
- package/src/infra/verification.ts +50 -18
- package/src/loop.js +95 -11
- package/src/loop.ts +125 -16
- package/src/main.js +8 -1
- package/src/main.tsx +7 -1
- package/src/memory/constants.js +10 -0
- package/src/memory/constants.ts +13 -0
- package/src/memory/episodicMemory.js +274 -2
- package/src/memory/episodicMemory.ts +306 -1
- package/src/memory/fileStore.js +24 -4
- package/src/memory/fileStore.ts +22 -2
- package/src/memory/memoryService.js +79 -12
- package/src/memory/memoryService.ts +108 -9
- package/src/memory/repository.js +415 -85
- package/src/memory/repository.ts +437 -86
- package/src/memory/turnJournal.js +95 -0
- package/src/memory/turnJournal.ts +120 -0
- package/src/memory/types.ts +16 -0
- package/src/plugins/pluginApi.ts +30 -6
- package/src/plugins/pluginRunner.js +23 -8
- package/src/plugins/pluginRunner.ts +32 -13
- package/src/prompts/policyRegistry.js +55 -1
- package/src/prompts/policyRegistry.ts +55 -1
- package/src/prompts/promptAssembly.js +39 -7
- package/src/prompts/promptAssembly.ts +145 -59
- package/src/session/runAgentTurn.js +43 -21
- package/src/session/runAgentTurn.ts +68 -26
- package/src/session/runTurn.js +11 -46
- package/src/session/runTurn.ts +12 -46
- package/src/session/runtimeStore.js +149 -0
- package/src/session/runtimeStore.ts +197 -0
- package/src/session/sessionState.js +5 -0
- package/src/session/sessionState.ts +14 -0
- package/src/tools/advisor/advisor.js +76 -9
- package/src/tools/advisor/advisor.ts +79 -9
- package/src/tools/agent/agent.js +143 -14
- package/src/tools/agent/agent.ts +149 -14
- package/src/tools/agent/agentTaskService.js +60 -0
- package/src/tools/agent/agentTaskService.ts +113 -0
- package/src/tools/agent/agentTypes.js +5 -0
- package/src/tools/agent/agentTypes.ts +5 -0
- package/src/tools/bash/bash.js +80 -15
- package/src/tools/bash/bash.ts +83 -15
- package/src/tools/capabilities.js +5 -0
- package/src/tools/capabilities.ts +11 -1
- package/src/tools/edit/edit.js +7 -4
- package/src/tools/edit/edit.ts +7 -4
- package/src/tools/edit/multi-edit.js +4 -2
- package/src/tools/edit/multi-edit.ts +4 -2
- package/src/tools/glob/glob.js +156 -40
- package/src/tools/glob/glob.ts +185 -46
- package/src/tools/index.js +35 -17
- package/src/tools/index.ts +81 -20
- package/src/tools/lsp/lsp.js +165 -22
- package/src/tools/lsp/lsp.ts +206 -36
- package/src/tools/powershell/powershell.js +134 -27
- package/src/tools/powershell/powershell.ts +171 -30
- package/src/tools/presentresult/presentresult.js +10 -0
- package/src/tools/presentresult/presentresult.ts +10 -0
- package/src/tools/read/read.js +249 -29
- package/src/tools/read/read.ts +396 -59
- package/src/tools/shared/diffSummary.js +44 -0
- package/src/tools/shared/diffSummary.ts +63 -0
- package/src/tools/shared/fileActivity.js +2 -1
- package/src/tools/shared/fileActivity.ts +3 -0
- package/src/tools/shared/permanentDeleteGuard.js +57 -0
- package/src/tools/shared/permanentDeleteGuard.ts +71 -0
- package/src/tools/shared/toolOutputStore.js +97 -0
- package/src/tools/shared/toolOutputStore.ts +110 -0
- package/src/tools/skill/skill.js +86 -0
- package/src/tools/skill/skill.ts +94 -0
- package/src/tools/snip/snip.js +67 -0
- package/src/tools/snip/snip.ts +80 -0
- package/src/tools/tasks/backgroundTaskService.js +79 -28
- package/src/tools/tasks/backgroundTaskService.ts +165 -49
- package/src/tools/tasks/taskManagement.js +163 -0
- package/src/tools/tasks/taskManagement.ts +180 -0
- package/src/tools/tasks/taskOutput.js +64 -14
- package/src/tools/tasks/taskOutput.ts +102 -23
- package/src/tools/tasks/taskStop.js +48 -8
- package/src/tools/tasks/taskStop.ts +69 -9
- package/src/tools/tasks/taskStore.js +46 -0
- package/src/tools/tasks/taskStore.ts +77 -0
- package/src/tools/terminal/terminalCapture.js +167 -0
- package/src/tools/terminal/terminalCapture.ts +179 -0
- package/src/tools/trash/trash.js +233 -0
- package/src/tools/trash/trash.ts +283 -0
- package/src/tools/webbrowser/browserControl.js +5 -1
- package/src/tools/webbrowser/browserControl.ts +7 -1
- package/src/tools/webbrowser/webbrowser.js +8 -4
- package/src/tools/webbrowser/webbrowser.ts +8 -4
- package/src/tools/worktree/sessionWorktree.js +128 -0
- package/src/tools/worktree/sessionWorktree.ts +158 -0
- package/src/tools/write/write.js +6 -2
- package/src/tools/write/write.ts +6 -2
- package/src/types.ts +22 -4
- package/src/ui/hooks/useChat.js +31 -11
- package/src/ui/hooks/useChat.ts +31 -13
- package/src/web/protocol.ts +78 -3
- package/workflows/adaptive-research.yaml +5 -5
- package/workflows/book-review-short.yaml +5 -5
- package/workflows/e2e-write-greet.yaml +2 -2
- package/workflows/illustrate.yaml +12 -12
- package/workflows/plan.yaml +18 -18
- package/workflows/ppt.yaml +201 -201
- package/workflows/ralph-loop.yaml +3 -1
- package/workflows/research.md +449 -0
- package/workflows/research.yaml +873 -0
- package/workflows/webppt.yaml +47 -47
- package/workflows/word.yaml +80 -80
- package/workflows/youtube-shorts.yaml +25 -25
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { access, cp, lstat, mkdir, readdir, readlink, rename, rm, realpath, } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { basename, isAbsolute, join, relative, resolve, sep, } from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
const schema = z.discriminatedUnion('action', [
|
|
12
|
+
z.object({
|
|
13
|
+
action: z.literal('move'),
|
|
14
|
+
paths: z.array(z.string().min(1)).min(1).max(200),
|
|
15
|
+
}),
|
|
16
|
+
z.object({
|
|
17
|
+
action: z.literal('git-clean'),
|
|
18
|
+
include_ignored: z.boolean().default(false).optional(),
|
|
19
|
+
}),
|
|
20
|
+
]);
|
|
21
|
+
function inside(root, path) {
|
|
22
|
+
const rel = relative(root, path);
|
|
23
|
+
return (rel === '' ||
|
|
24
|
+
(!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel)));
|
|
25
|
+
}
|
|
26
|
+
async function gitRoot(cwd) {
|
|
27
|
+
try {
|
|
28
|
+
return await realpath((await exec('git', ['-C', cwd, 'rev-parse', '--show-toplevel'])).stdout.trim());
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function validate(path, cwd) {
|
|
35
|
+
const lexical = resolve(cwd, path);
|
|
36
|
+
const target = await realpath(lexical);
|
|
37
|
+
const root = await gitRoot(cwd);
|
|
38
|
+
if (root) {
|
|
39
|
+
const git = join(root, '.git');
|
|
40
|
+
if (target === root ||
|
|
41
|
+
inside(target, root) ||
|
|
42
|
+
lexical === git ||
|
|
43
|
+
inside(git, lexical) ||
|
|
44
|
+
inside(git, target))
|
|
45
|
+
throw new Error(`拒绝移动 Git 仓库根目录、其祖先或 .git:${target}`);
|
|
46
|
+
}
|
|
47
|
+
await lstat(lexical);
|
|
48
|
+
return lexical;
|
|
49
|
+
}
|
|
50
|
+
async function fallbackTrash(source) {
|
|
51
|
+
const files = process.platform === 'linux'
|
|
52
|
+
? join(homedir(), '.local/share/Trash/files')
|
|
53
|
+
: join(homedir(), '.Trash');
|
|
54
|
+
const info = join(homedir(), '.local/share/Trash/info');
|
|
55
|
+
await mkdir(files, { recursive: true, mode: 0o700 });
|
|
56
|
+
if (process.platform === 'linux')
|
|
57
|
+
await mkdir(info, { recursive: true, mode: 0o700 });
|
|
58
|
+
const name = `${basename(source)}.${new Date().toISOString().replace(/[:.]/g, '-')}.${randomUUID().slice(0, 8)}`;
|
|
59
|
+
const target = join(files, name);
|
|
60
|
+
try {
|
|
61
|
+
await rename(source, target);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
await cp(source, target, {
|
|
65
|
+
recursive: true,
|
|
66
|
+
errorOnExist: true,
|
|
67
|
+
preserveTimestamps: true,
|
|
68
|
+
});
|
|
69
|
+
if ((await treeFingerprint(source)) !== (await treeFingerprint(target)))
|
|
70
|
+
throw new Error('跨卷复制内容校验失败,来源已保留');
|
|
71
|
+
await rm(source, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
if (process.platform === 'linux') {
|
|
74
|
+
const encoded = encodeURI(source).replace(/#/g, '%23');
|
|
75
|
+
await import('node:fs/promises').then((fs) => fs.writeFile(join(info, `${name}.trashinfo`), `[Trash Info]\nPath=${encoded}\nDeletionDate=${new Date().toISOString().slice(0, 19)}\n`, { mode: 0o600 }));
|
|
76
|
+
}
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
async function treeFingerprint(path) {
|
|
80
|
+
const hash = createHash('sha256');
|
|
81
|
+
const visit = async (current, relativePath) => {
|
|
82
|
+
const info = await lstat(current);
|
|
83
|
+
if (info.isSymbolicLink()) {
|
|
84
|
+
hash.update(`link\0${relativePath}\0${await readlink(current)}\0`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (info.isDirectory()) {
|
|
88
|
+
hash.update(`dir\0${relativePath}\0`);
|
|
89
|
+
const entries = await readdir(current);
|
|
90
|
+
entries.sort();
|
|
91
|
+
for (const entry of entries)
|
|
92
|
+
await visit(join(current, entry), join(relativePath, entry));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
hash.update(`file\0${relativePath}\0${info.size}\0`);
|
|
96
|
+
for await (const chunk of createReadStream(current))
|
|
97
|
+
hash.update(chunk);
|
|
98
|
+
};
|
|
99
|
+
await visit(path, '.');
|
|
100
|
+
return hash.digest('hex');
|
|
101
|
+
}
|
|
102
|
+
async function missing(path) {
|
|
103
|
+
return access(path)
|
|
104
|
+
.then(() => false)
|
|
105
|
+
.catch((error) => {
|
|
106
|
+
if (error.code === 'ENOENT')
|
|
107
|
+
return true;
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function moveMany(sources) {
|
|
112
|
+
if (process.platform === 'darwin') {
|
|
113
|
+
try {
|
|
114
|
+
await exec('osascript', [
|
|
115
|
+
'-e',
|
|
116
|
+
'on run argv',
|
|
117
|
+
'-e',
|
|
118
|
+
'tell application "Finder"',
|
|
119
|
+
'-e',
|
|
120
|
+
'repeat with sourcePath in argv',
|
|
121
|
+
'-e',
|
|
122
|
+
'delete POSIX file (sourcePath as text)',
|
|
123
|
+
'-e',
|
|
124
|
+
'end repeat',
|
|
125
|
+
'-e',
|
|
126
|
+
'end tell',
|
|
127
|
+
'-e',
|
|
128
|
+
'end run',
|
|
129
|
+
...sources,
|
|
130
|
+
]);
|
|
131
|
+
if ((await Promise.all(sources.map(missing))).every(Boolean))
|
|
132
|
+
return 'macOS Trash';
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
}
|
|
136
|
+
if (process.platform === 'win32') {
|
|
137
|
+
const script = `Add-Type -AssemblyName Microsoft.VisualBasic; foreach ($path in $args) { if ([IO.Directory]::Exists($path)) { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($path,'OnlyErrorDialogs','SendToRecycleBin') } else { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($path,'OnlyErrorDialogs','SendToRecycleBin') } }`;
|
|
138
|
+
try {
|
|
139
|
+
await exec('powershell.exe', [
|
|
140
|
+
'-NoProfile',
|
|
141
|
+
'-NonInteractive',
|
|
142
|
+
'-Command',
|
|
143
|
+
script,
|
|
144
|
+
...sources,
|
|
145
|
+
]);
|
|
146
|
+
if ((await Promise.all(sources.map(missing))).every(Boolean))
|
|
147
|
+
return 'Windows Recycle Bin';
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
}
|
|
151
|
+
if (process.platform === 'linux') {
|
|
152
|
+
try {
|
|
153
|
+
await exec('gio', ['trash', '--', ...sources]);
|
|
154
|
+
if ((await Promise.all(sources.map(missing))).every(Boolean))
|
|
155
|
+
return 'FreeDesktop Trash';
|
|
156
|
+
}
|
|
157
|
+
catch { }
|
|
158
|
+
}
|
|
159
|
+
for (const source of sources) {
|
|
160
|
+
if (await access(source)
|
|
161
|
+
.then(() => true)
|
|
162
|
+
.catch(() => false))
|
|
163
|
+
await fallbackTrash(source);
|
|
164
|
+
}
|
|
165
|
+
return process.platform === 'linux' ? 'FreeDesktop Trash' : 'system Trash';
|
|
166
|
+
}
|
|
167
|
+
async function gitCleanCandidates(cwd, ignored) {
|
|
168
|
+
const root = await gitRoot(cwd);
|
|
169
|
+
if (!root)
|
|
170
|
+
throw new Error('当前目录不在 Git 仓库中');
|
|
171
|
+
const read = async (extra) => {
|
|
172
|
+
const out = (await exec('git', [
|
|
173
|
+
'-C',
|
|
174
|
+
root,
|
|
175
|
+
'ls-files',
|
|
176
|
+
'-z',
|
|
177
|
+
'--others',
|
|
178
|
+
'--directory',
|
|
179
|
+
'--exclude-standard',
|
|
180
|
+
...extra,
|
|
181
|
+
], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 })).stdout;
|
|
182
|
+
return out.toString('utf8').split('\0').filter(Boolean);
|
|
183
|
+
};
|
|
184
|
+
const names = [
|
|
185
|
+
...(await read([])),
|
|
186
|
+
...(ignored ? await read(['--ignored']) : []),
|
|
187
|
+
];
|
|
188
|
+
const values = [...new Set(names)].map((p) => resolve(root, p.replace(/\/$/, '')));
|
|
189
|
+
return values.filter((p, i) => !values.some((other, j) => j !== i && inside(other, p)));
|
|
190
|
+
}
|
|
191
|
+
async function call(input, context) {
|
|
192
|
+
const cwd = context.cwd ?? process.cwd();
|
|
193
|
+
try {
|
|
194
|
+
const raw = input.action === 'move'
|
|
195
|
+
? input.paths
|
|
196
|
+
: await gitCleanCandidates(cwd, input.include_ignored ?? false);
|
|
197
|
+
const paths = [];
|
|
198
|
+
for (const item of raw)
|
|
199
|
+
paths.push(await validate(item, cwd));
|
|
200
|
+
const moved = [];
|
|
201
|
+
const destination = paths.length ? await moveMany(paths) : '';
|
|
202
|
+
for (const path of paths) {
|
|
203
|
+
try {
|
|
204
|
+
await access(path);
|
|
205
|
+
throw new Error(`回收站操作后来源仍存在:${path}`);
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
if (e.code !== 'ENOENT')
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
moved.push(`${path} → ${destination}`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
content: moved.length
|
|
216
|
+
? `已移动到系统回收站,可从回收站恢复:\n${moved.join('\n')}`
|
|
217
|
+
: '没有匹配的未跟踪文件。',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
return { ok: false, error: `Trash 失败:${e.message}` };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
export const trashTool = {
|
|
225
|
+
name: 'Trash',
|
|
226
|
+
description: 'Recoverably removes files or directories by moving them to the operating system Trash/Recycle Bin. Use action=git-clean to trash untracked Git files without running git clean. Repository roots and .git are always refused.',
|
|
227
|
+
inputSchema: schema,
|
|
228
|
+
parameters: toToolParameters(schema),
|
|
229
|
+
isReadOnly: false,
|
|
230
|
+
isConcurrencySafe: false,
|
|
231
|
+
call: async () => ({ ok: false, error: 'Trash requires runtime context' }),
|
|
232
|
+
callWithContext: (input, _s, ctx) => call(input, ctx),
|
|
233
|
+
};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
access,
|
|
6
|
+
cp,
|
|
7
|
+
lstat,
|
|
8
|
+
mkdir,
|
|
9
|
+
readdir,
|
|
10
|
+
readlink,
|
|
11
|
+
rename,
|
|
12
|
+
rm,
|
|
13
|
+
realpath,
|
|
14
|
+
} from 'node:fs/promises';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import {
|
|
17
|
+
basename,
|
|
18
|
+
dirname,
|
|
19
|
+
isAbsolute,
|
|
20
|
+
join,
|
|
21
|
+
relative,
|
|
22
|
+
resolve,
|
|
23
|
+
sep,
|
|
24
|
+
} from 'node:path';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import type { Tool, ToolCallContext, ToolResult } from '../../types.js';
|
|
28
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
29
|
+
|
|
30
|
+
const exec = promisify(execFile);
|
|
31
|
+
const schema = z.discriminatedUnion('action', [
|
|
32
|
+
z.object({
|
|
33
|
+
action: z.literal('move'),
|
|
34
|
+
paths: z.array(z.string().min(1)).min(1).max(200),
|
|
35
|
+
}),
|
|
36
|
+
z.object({
|
|
37
|
+
action: z.literal('git-clean'),
|
|
38
|
+
include_ignored: z.boolean().default(false).optional(),
|
|
39
|
+
}),
|
|
40
|
+
]);
|
|
41
|
+
type Input = z.infer<typeof schema>;
|
|
42
|
+
function inside(root: string, path: string): boolean {
|
|
43
|
+
const rel = relative(root, path);
|
|
44
|
+
return (
|
|
45
|
+
rel === '' ||
|
|
46
|
+
(!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
async function gitRoot(cwd: string): Promise<string | undefined> {
|
|
50
|
+
try {
|
|
51
|
+
return await realpath(
|
|
52
|
+
(
|
|
53
|
+
await exec('git', ['-C', cwd, 'rev-parse', '--show-toplevel'])
|
|
54
|
+
).stdout.trim(),
|
|
55
|
+
);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function validate(path: string, cwd: string): Promise<string> {
|
|
61
|
+
const lexical = resolve(cwd, path);
|
|
62
|
+
const target = await realpath(lexical);
|
|
63
|
+
const root = await gitRoot(cwd);
|
|
64
|
+
if (root) {
|
|
65
|
+
const git = join(root, '.git');
|
|
66
|
+
if (
|
|
67
|
+
target === root ||
|
|
68
|
+
inside(target, root) ||
|
|
69
|
+
lexical === git ||
|
|
70
|
+
inside(git, lexical) ||
|
|
71
|
+
inside(git, target)
|
|
72
|
+
)
|
|
73
|
+
throw new Error(`拒绝移动 Git 仓库根目录、其祖先或 .git:${target}`);
|
|
74
|
+
}
|
|
75
|
+
await lstat(lexical);
|
|
76
|
+
return lexical;
|
|
77
|
+
}
|
|
78
|
+
async function fallbackTrash(source: string): Promise<string> {
|
|
79
|
+
const files =
|
|
80
|
+
process.platform === 'linux'
|
|
81
|
+
? join(homedir(), '.local/share/Trash/files')
|
|
82
|
+
: join(homedir(), '.Trash');
|
|
83
|
+
const info = join(homedir(), '.local/share/Trash/info');
|
|
84
|
+
await mkdir(files, { recursive: true, mode: 0o700 });
|
|
85
|
+
if (process.platform === 'linux')
|
|
86
|
+
await mkdir(info, { recursive: true, mode: 0o700 });
|
|
87
|
+
const name = `${basename(source)}.${new Date().toISOString().replace(/[:.]/g, '-')}.${randomUUID().slice(0, 8)}`;
|
|
88
|
+
const target = join(files, name);
|
|
89
|
+
try {
|
|
90
|
+
await rename(source, target);
|
|
91
|
+
} catch {
|
|
92
|
+
await cp(source, target, {
|
|
93
|
+
recursive: true,
|
|
94
|
+
errorOnExist: true,
|
|
95
|
+
preserveTimestamps: true,
|
|
96
|
+
});
|
|
97
|
+
if ((await treeFingerprint(source)) !== (await treeFingerprint(target)))
|
|
98
|
+
throw new Error('跨卷复制内容校验失败,来源已保留');
|
|
99
|
+
await rm(source, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
if (process.platform === 'linux') {
|
|
102
|
+
const encoded = encodeURI(source).replace(/#/g, '%23');
|
|
103
|
+
await import('node:fs/promises').then((fs) =>
|
|
104
|
+
fs.writeFile(
|
|
105
|
+
join(info, `${name}.trashinfo`),
|
|
106
|
+
`[Trash Info]\nPath=${encoded}\nDeletionDate=${new Date().toISOString().slice(0, 19)}\n`,
|
|
107
|
+
{ mode: 0o600 },
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return target;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function treeFingerprint(path: string): Promise<string> {
|
|
115
|
+
const hash = createHash('sha256');
|
|
116
|
+
const visit = async (
|
|
117
|
+
current: string,
|
|
118
|
+
relativePath: string,
|
|
119
|
+
): Promise<void> => {
|
|
120
|
+
const info = await lstat(current);
|
|
121
|
+
if (info.isSymbolicLink()) {
|
|
122
|
+
hash.update(`link\0${relativePath}\0${await readlink(current)}\0`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (info.isDirectory()) {
|
|
126
|
+
hash.update(`dir\0${relativePath}\0`);
|
|
127
|
+
const entries = await readdir(current);
|
|
128
|
+
entries.sort();
|
|
129
|
+
for (const entry of entries)
|
|
130
|
+
await visit(join(current, entry), join(relativePath, entry));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
hash.update(`file\0${relativePath}\0${info.size}\0`);
|
|
134
|
+
for await (const chunk of createReadStream(current)) hash.update(chunk);
|
|
135
|
+
};
|
|
136
|
+
await visit(path, '.');
|
|
137
|
+
return hash.digest('hex');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function missing(path: string): Promise<boolean> {
|
|
141
|
+
return access(path)
|
|
142
|
+
.then(() => false)
|
|
143
|
+
.catch((error: NodeJS.ErrnoException) => {
|
|
144
|
+
if (error.code === 'ENOENT') return true;
|
|
145
|
+
throw error;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function moveMany(sources: string[]): Promise<string> {
|
|
150
|
+
if (process.platform === 'darwin') {
|
|
151
|
+
try {
|
|
152
|
+
await exec('osascript', [
|
|
153
|
+
'-e',
|
|
154
|
+
'on run argv',
|
|
155
|
+
'-e',
|
|
156
|
+
'tell application "Finder"',
|
|
157
|
+
'-e',
|
|
158
|
+
'repeat with sourcePath in argv',
|
|
159
|
+
'-e',
|
|
160
|
+
'delete POSIX file (sourcePath as text)',
|
|
161
|
+
'-e',
|
|
162
|
+
'end repeat',
|
|
163
|
+
'-e',
|
|
164
|
+
'end tell',
|
|
165
|
+
'-e',
|
|
166
|
+
'end run',
|
|
167
|
+
...sources,
|
|
168
|
+
]);
|
|
169
|
+
if ((await Promise.all(sources.map(missing))).every(Boolean))
|
|
170
|
+
return 'macOS Trash';
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
if (process.platform === 'win32') {
|
|
174
|
+
const script = `Add-Type -AssemblyName Microsoft.VisualBasic; foreach ($path in $args) { if ([IO.Directory]::Exists($path)) { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($path,'OnlyErrorDialogs','SendToRecycleBin') } else { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($path,'OnlyErrorDialogs','SendToRecycleBin') } }`;
|
|
175
|
+
try {
|
|
176
|
+
await exec('powershell.exe', [
|
|
177
|
+
'-NoProfile',
|
|
178
|
+
'-NonInteractive',
|
|
179
|
+
'-Command',
|
|
180
|
+
script,
|
|
181
|
+
...sources,
|
|
182
|
+
]);
|
|
183
|
+
if ((await Promise.all(sources.map(missing))).every(Boolean))
|
|
184
|
+
return 'Windows Recycle Bin';
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
if (process.platform === 'linux') {
|
|
188
|
+
try {
|
|
189
|
+
await exec('gio', ['trash', '--', ...sources]);
|
|
190
|
+
if ((await Promise.all(sources.map(missing))).every(Boolean))
|
|
191
|
+
return 'FreeDesktop Trash';
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
for (const source of sources) {
|
|
195
|
+
if (
|
|
196
|
+
await access(source)
|
|
197
|
+
.then(() => true)
|
|
198
|
+
.catch(() => false)
|
|
199
|
+
)
|
|
200
|
+
await fallbackTrash(source);
|
|
201
|
+
}
|
|
202
|
+
return process.platform === 'linux' ? 'FreeDesktop Trash' : 'system Trash';
|
|
203
|
+
}
|
|
204
|
+
async function gitCleanCandidates(
|
|
205
|
+
cwd: string,
|
|
206
|
+
ignored: boolean,
|
|
207
|
+
): Promise<string[]> {
|
|
208
|
+
const root = await gitRoot(cwd);
|
|
209
|
+
if (!root) throw new Error('当前目录不在 Git 仓库中');
|
|
210
|
+
const read = async (extra: string[]) => {
|
|
211
|
+
const out = (
|
|
212
|
+
await exec(
|
|
213
|
+
'git',
|
|
214
|
+
[
|
|
215
|
+
'-C',
|
|
216
|
+
root,
|
|
217
|
+
'ls-files',
|
|
218
|
+
'-z',
|
|
219
|
+
'--others',
|
|
220
|
+
'--directory',
|
|
221
|
+
'--exclude-standard',
|
|
222
|
+
...extra,
|
|
223
|
+
],
|
|
224
|
+
{ encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 } as any,
|
|
225
|
+
)
|
|
226
|
+
).stdout as unknown as Buffer;
|
|
227
|
+
return out.toString('utf8').split('\0').filter(Boolean);
|
|
228
|
+
};
|
|
229
|
+
const names = [
|
|
230
|
+
...(await read([])),
|
|
231
|
+
...(ignored ? await read(['--ignored']) : []),
|
|
232
|
+
];
|
|
233
|
+
const values = [...new Set(names)].map((p) =>
|
|
234
|
+
resolve(root, p.replace(/\/$/, '')),
|
|
235
|
+
);
|
|
236
|
+
return values.filter(
|
|
237
|
+
(p, i) => !values.some((other, j) => j !== i && inside(other, p)),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
async function call(
|
|
241
|
+
input: Input,
|
|
242
|
+
context: ToolCallContext,
|
|
243
|
+
): Promise<ToolResult> {
|
|
244
|
+
const cwd = context.cwd ?? process.cwd();
|
|
245
|
+
try {
|
|
246
|
+
const raw =
|
|
247
|
+
input.action === 'move'
|
|
248
|
+
? input.paths
|
|
249
|
+
: await gitCleanCandidates(cwd, input.include_ignored ?? false);
|
|
250
|
+
const paths = [];
|
|
251
|
+
for (const item of raw) paths.push(await validate(item, cwd));
|
|
252
|
+
const moved = [];
|
|
253
|
+
const destination = paths.length ? await moveMany(paths) : '';
|
|
254
|
+
for (const path of paths) {
|
|
255
|
+
try {
|
|
256
|
+
await access(path);
|
|
257
|
+
throw new Error(`回收站操作后来源仍存在:${path}`);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
|
260
|
+
}
|
|
261
|
+
moved.push(`${path} → ${destination}`);
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
content: moved.length
|
|
266
|
+
? `已移动到系统回收站,可从回收站恢复:\n${moved.join('\n')}`
|
|
267
|
+
: '没有匹配的未跟踪文件。',
|
|
268
|
+
};
|
|
269
|
+
} catch (e) {
|
|
270
|
+
return { ok: false, error: `Trash 失败:${(e as Error).message}` };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
export const trashTool: Tool<Input> = {
|
|
274
|
+
name: 'Trash',
|
|
275
|
+
description:
|
|
276
|
+
'Recoverably removes files or directories by moving them to the operating system Trash/Recycle Bin. Use action=git-clean to trash untracked Git files without running git clean. Repository roots and .git are always refused.',
|
|
277
|
+
inputSchema: schema,
|
|
278
|
+
parameters: toToolParameters(schema),
|
|
279
|
+
isReadOnly: false,
|
|
280
|
+
isConcurrencySafe: false,
|
|
281
|
+
call: async () => ({ ok: false, error: 'Trash requires runtime context' }),
|
|
282
|
+
callWithContext: (input, _s, ctx) => call(input, ctx),
|
|
283
|
+
};
|
|
@@ -75,7 +75,11 @@ export class BrowserControlCoordinator {
|
|
|
75
75
|
throw new Error('浏览器正在等待另一个任务的人工处理');
|
|
76
76
|
}
|
|
77
77
|
if (this.state.status === 'running' && this.state.runId !== input.runId) {
|
|
78
|
-
|
|
78
|
+
// 带上持锁者身份:这把锁是进程级单例,一旦泄漏,后续每次调用都撞同一句话,
|
|
79
|
+
// 而"另一个会话"是谁、卡在哪个动作上,现场早就没了 —— 排障只能靠猜。
|
|
80
|
+
throw new Error(`浏览器正在执行另一个会话的原子操作(持有者 runId=${this.state.runId ?? '?'}`
|
|
81
|
+
+ `${this.state.action ? `, action=${this.state.action}` : ''}`
|
|
82
|
+
+ `${this.activeIdentity?.conversationKey ? `, conversation=${this.activeIdentity.conversationKey}` : ''})`);
|
|
79
83
|
}
|
|
80
84
|
this.activeIdentity = {
|
|
81
85
|
ownerId: this.defaultOwnerId,
|
|
@@ -106,7 +106,13 @@ export class BrowserControlCoordinator {
|
|
|
106
106
|
throw new Error('浏览器正在等待另一个任务的人工处理');
|
|
107
107
|
}
|
|
108
108
|
if (this.state.status === 'running' && this.state.runId !== input.runId) {
|
|
109
|
-
|
|
109
|
+
// 带上持锁者身份:这把锁是进程级单例,一旦泄漏,后续每次调用都撞同一句话,
|
|
110
|
+
// 而"另一个会话"是谁、卡在哪个动作上,现场早就没了 —— 排障只能靠猜。
|
|
111
|
+
throw new Error(
|
|
112
|
+
`浏览器正在执行另一个会话的原子操作(持有者 runId=${this.state.runId ?? '?'}`
|
|
113
|
+
+ `${this.state.action ? `, action=${this.state.action}` : ''}`
|
|
114
|
+
+ `${this.activeIdentity?.conversationKey ? `, conversation=${this.activeIdentity.conversationKey}` : ''})`,
|
|
115
|
+
);
|
|
110
116
|
}
|
|
111
117
|
this.activeIdentity = {
|
|
112
118
|
ownerId: this.defaultOwnerId,
|
|
@@ -1083,12 +1083,16 @@ async function call(input, signal, deps = defaultDeps, ctx = {}) {
|
|
|
1083
1083
|
/** 单条命令 = 只有一步的序列。人工等待之后的收尾快照走这个(此时不该再持锁)。 */
|
|
1084
1084
|
const runAction = (args) => runSequence((run) => run(args));
|
|
1085
1085
|
stream.beginAgentAction({ runId, conversationKey, action: input.action });
|
|
1086
|
-
if (visibleAgentAction) {
|
|
1087
|
-
stream.setAgentActing(true, input.action, input.selector ?? input.url);
|
|
1088
|
-
ctx.reportProgress?.(describeAction(input)); // C3:每个可见动作发一句人话旁白到对话/状态行
|
|
1089
|
-
}
|
|
1090
1086
|
let pointerShown = false; // C4:本次是否点亮了 AI 指针(finally 淡出用)
|
|
1087
|
+
// ⚠️ 拿到锁之后**必须立刻进 try**:finally 里的 finishAgentAction 是唯一的释放路径。
|
|
1088
|
+
// 下面这段旁白曾经放在 try 之外 —— `ctx.reportProgress` 是宿主注入的回调,它一抛错
|
|
1089
|
+
// 就会跳过整个 try/finally,锁再也不会被释放;此后该进程内**任何**会话调
|
|
1090
|
+
// beginAgent 都会撞上「浏览器正在执行另一个会话的原子操作」,且现场已经没了。
|
|
1091
1091
|
try {
|
|
1092
|
+
if (visibleAgentAction) {
|
|
1093
|
+
stream.setAgentActing(true, input.action, input.selector ?? input.url);
|
|
1094
|
+
ctx.reportProgress?.(describeAction(input)); // C3:每个可见动作发一句人话旁白到对话/状态行
|
|
1095
|
+
}
|
|
1092
1096
|
await deps.ensureDaemon(BROWSER_SESSION, { signal });
|
|
1093
1097
|
// actor 兜底点亮媒体面(幂等):防「直接 navigate 未先 start」「start 那次 connect 竞态失败」,
|
|
1094
1098
|
// 让面板从 agent 侧被点亮,不依赖面板自启动。
|
|
@@ -1156,13 +1156,17 @@ async function call(
|
|
|
1156
1156
|
runSequence((run) => run(args));
|
|
1157
1157
|
|
|
1158
1158
|
stream.beginAgentAction({ runId, conversationKey, action: input.action });
|
|
1159
|
-
if (visibleAgentAction) {
|
|
1160
|
-
stream.setAgentActing(true, input.action, input.selector ?? input.url);
|
|
1161
|
-
ctx.reportProgress?.(describeAction(input)); // C3:每个可见动作发一句人话旁白到对话/状态行
|
|
1162
|
-
}
|
|
1163
1159
|
let pointerShown = false; // C4:本次是否点亮了 AI 指针(finally 淡出用)
|
|
1164
1160
|
|
|
1161
|
+
// ⚠️ 拿到锁之后**必须立刻进 try**:finally 里的 finishAgentAction 是唯一的释放路径。
|
|
1162
|
+
// 下面这段旁白曾经放在 try 之外 —— `ctx.reportProgress` 是宿主注入的回调,它一抛错
|
|
1163
|
+
// 就会跳过整个 try/finally,锁再也不会被释放;此后该进程内**任何**会话调
|
|
1164
|
+
// beginAgent 都会撞上「浏览器正在执行另一个会话的原子操作」,且现场已经没了。
|
|
1165
1165
|
try {
|
|
1166
|
+
if (visibleAgentAction) {
|
|
1167
|
+
stream.setAgentActing(true, input.action, input.selector ?? input.url);
|
|
1168
|
+
ctx.reportProgress?.(describeAction(input)); // C3:每个可见动作发一句人话旁白到对话/状态行
|
|
1169
|
+
}
|
|
1166
1170
|
await deps.ensureDaemon(BROWSER_SESSION, { signal });
|
|
1167
1171
|
// actor 兜底点亮媒体面(幂等):防「直接 navigate 未先 start」「start 那次 connect 竞态失败」,
|
|
1168
1172
|
// 让面板从 agent 侧被点亮,不依赖面板自启动。
|