minimal-agent 0.11.0 → 0.12.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.
Files changed (128) hide show
  1. package/package.json +4 -1
  2. package/plugins/workflow-runner/plugin.js +27 -15
  3. package/plugins/workflow-runner/plugin.ts +34 -20
  4. package/plugins/workflow-runner/src/agentCommon.js +13 -12
  5. package/plugins/workflow-runner/src/agentCommon.ts +22 -13
  6. package/plugins/workflow-runner/src/index.js +5 -3
  7. package/plugins/workflow-runner/src/index.ts +31 -13
  8. package/plugins/workflow-runner/src/multimodal.js +2 -1
  9. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  10. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  11. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  12. package/plugins/workflow-runner/src/stepExecutors/tool.js +68 -57
  13. package/plugins/workflow-runner/src/stepExecutors/tool.ts +88 -66
  14. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  15. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  16. package/plugins/workflow-runner/src/types.ts +26 -7
  17. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  18. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  19. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  20. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  21. package/src/cli/print.js +19 -4
  22. package/src/cli/print.ts +17 -4
  23. package/src/cli/streamJson.js +19 -4
  24. package/src/cli/streamJson.ts +17 -4
  25. package/src/config/configDefaults.js +25 -0
  26. package/src/config/configDefaults.ts +33 -0
  27. package/src/config/configFile.js +2 -21
  28. package/src/config/configFile.ts +11 -24
  29. package/src/config/configManagement.js +1 -1
  30. package/src/config/configManagement.ts +2 -2
  31. package/src/context/compact.js +4 -0
  32. package/src/context/compact.ts +4 -0
  33. package/src/context/contextBundle.js +38 -13
  34. package/src/context/contextBundle.ts +47 -13
  35. package/src/harness/stallGuard.js +19 -4
  36. package/src/harness/stallGuard.ts +30 -7
  37. package/src/loop.js +95 -11
  38. package/src/loop.ts +125 -16
  39. package/src/main.js +8 -1
  40. package/src/main.tsx +7 -1
  41. package/src/memory/constants.js +10 -0
  42. package/src/memory/constants.ts +13 -0
  43. package/src/memory/episodicMemory.js +274 -2
  44. package/src/memory/episodicMemory.ts +306 -1
  45. package/src/memory/fileStore.js +24 -4
  46. package/src/memory/fileStore.ts +22 -2
  47. package/src/memory/memoryService.js +79 -12
  48. package/src/memory/memoryService.ts +108 -9
  49. package/src/memory/repository.js +415 -85
  50. package/src/memory/repository.ts +437 -86
  51. package/src/memory/turnJournal.js +95 -0
  52. package/src/memory/turnJournal.ts +120 -0
  53. package/src/memory/types.ts +16 -0
  54. package/src/plugins/pluginApi.ts +30 -6
  55. package/src/plugins/pluginRunner.js +6 -2
  56. package/src/plugins/pluginRunner.ts +12 -7
  57. package/src/prompts/policyRegistry.js +27 -0
  58. package/src/prompts/policyRegistry.ts +27 -0
  59. package/src/prompts/promptAssembly.js +39 -7
  60. package/src/prompts/promptAssembly.ts +145 -59
  61. package/src/session/runAgentTurn.js +43 -21
  62. package/src/session/runAgentTurn.ts +68 -26
  63. package/src/session/runTurn.js +11 -46
  64. package/src/session/runTurn.ts +12 -46
  65. package/src/session/runtimeStore.js +149 -0
  66. package/src/session/runtimeStore.ts +197 -0
  67. package/src/session/sessionState.js +5 -0
  68. package/src/session/sessionState.ts +14 -0
  69. package/src/tools/agent/agent.js +143 -14
  70. package/src/tools/agent/agent.ts +149 -14
  71. package/src/tools/agent/agentTaskService.js +60 -0
  72. package/src/tools/agent/agentTaskService.ts +113 -0
  73. package/src/tools/agent/agentTypes.js +5 -0
  74. package/src/tools/agent/agentTypes.ts +5 -0
  75. package/src/tools/bash/bash.js +31 -11
  76. package/src/tools/bash/bash.ts +29 -10
  77. package/src/tools/capabilities.js +5 -0
  78. package/src/tools/capabilities.ts +11 -1
  79. package/src/tools/edit/edit.js +7 -4
  80. package/src/tools/edit/edit.ts +7 -4
  81. package/src/tools/edit/multi-edit.js +4 -2
  82. package/src/tools/edit/multi-edit.ts +4 -2
  83. package/src/tools/glob/glob.js +156 -40
  84. package/src/tools/glob/glob.ts +185 -46
  85. package/src/tools/index.js +35 -17
  86. package/src/tools/index.ts +81 -20
  87. package/src/tools/lsp/lsp.js +165 -22
  88. package/src/tools/lsp/lsp.ts +206 -36
  89. package/src/tools/powershell/powershell.js +116 -26
  90. package/src/tools/powershell/powershell.ts +155 -30
  91. package/src/tools/presentresult/presentresult.js +10 -0
  92. package/src/tools/presentresult/presentresult.ts +10 -0
  93. package/src/tools/read/read.js +244 -29
  94. package/src/tools/read/read.ts +391 -59
  95. package/src/tools/shared/diffSummary.js +44 -0
  96. package/src/tools/shared/diffSummary.ts +63 -0
  97. package/src/tools/shared/fileActivity.js +2 -1
  98. package/src/tools/shared/fileActivity.ts +3 -0
  99. package/src/tools/shared/permanentDeleteGuard.js +26 -0
  100. package/src/tools/shared/permanentDeleteGuard.ts +34 -0
  101. package/src/tools/shared/toolOutputStore.js +97 -0
  102. package/src/tools/shared/toolOutputStore.ts +110 -0
  103. package/src/tools/skill/skill.js +86 -0
  104. package/src/tools/skill/skill.ts +94 -0
  105. package/src/tools/snip/snip.js +67 -0
  106. package/src/tools/snip/snip.ts +80 -0
  107. package/src/tools/tasks/backgroundTaskService.js +79 -28
  108. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  109. package/src/tools/tasks/taskManagement.js +163 -0
  110. package/src/tools/tasks/taskManagement.ts +180 -0
  111. package/src/tools/tasks/taskOutput.js +64 -14
  112. package/src/tools/tasks/taskOutput.ts +102 -23
  113. package/src/tools/tasks/taskStop.js +48 -8
  114. package/src/tools/tasks/taskStop.ts +69 -9
  115. package/src/tools/tasks/taskStore.js +46 -0
  116. package/src/tools/tasks/taskStore.ts +77 -0
  117. package/src/tools/terminal/terminalCapture.js +167 -0
  118. package/src/tools/terminal/terminalCapture.ts +179 -0
  119. package/src/tools/trash/trash.js +233 -0
  120. package/src/tools/trash/trash.ts +283 -0
  121. package/src/tools/worktree/sessionWorktree.js +128 -0
  122. package/src/tools/worktree/sessionWorktree.ts +158 -0
  123. package/src/tools/write/write.js +6 -2
  124. package/src/tools/write/write.ts +6 -2
  125. package/src/types.ts +12 -0
  126. package/src/ui/hooks/useChat.js +31 -11
  127. package/src/ui/hooks/useChat.ts +31 -13
  128. package/src/web/protocol.ts +78 -3
@@ -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
+ };
@@ -0,0 +1,128 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { toToolParameters } from '../../utils/zodToJson.js';
4
+ import { join } from 'node:path';
5
+ import { readRuntimeJson, sessionRuntimeDir, writeRuntimeJson, } from '../../session/runtimeStore.js';
6
+ import { applyAgentWorktree, commitWorktreeChanges, createAgentWorktree, hasWorktreeChanges, removeAgentWorktree, } from '../agent/worktree.js';
7
+ const enterSchema = z.object({
8
+ name: z
9
+ .string()
10
+ .regex(/^[a-zA-Z0-9._-]{1,48}$/)
11
+ .optional(),
12
+ });
13
+ const exitSchema = z.object({ action: z.enum(['apply', 'keep', 'discard']) });
14
+ async function persist(ctx, state) {
15
+ await writeRuntimeJson(join(sessionRuntimeDir(ctx), 'worktree.json'), {
16
+ active: state ?? null,
17
+ });
18
+ }
19
+ export async function hydrateSessionWorktree(ctx) {
20
+ if (!ctx.sessionState || ctx.sessionState.activeWorktree)
21
+ return;
22
+ const saved = await readRuntimeJson(join(sessionRuntimeDir(ctx), 'worktree.json'), {});
23
+ if (saved.active)
24
+ ctx.sessionState.activeWorktree = saved.active;
25
+ }
26
+ async function enter(input, ctx) {
27
+ const state = ctx.sessionState;
28
+ if (!state)
29
+ return { ok: false, error: 'EnterWorktree 需要会话状态' };
30
+ if (state.activeWorktree)
31
+ return {
32
+ ok: true,
33
+ content: `当前已在 worktree ${state.activeWorktree.path}`,
34
+ };
35
+ const slug = `session-${input.name ?? randomUUID().slice(0, 8)}`;
36
+ const wt = await createAgentWorktree(slug, ctx.cwd);
37
+ if (!wt.ok)
38
+ return wt;
39
+ state.activeWorktree = {
40
+ id: slug,
41
+ path: wt.path,
42
+ branch: wt.branch,
43
+ baseSha: wt.baseSha,
44
+ gitRoot: wt.gitRoot,
45
+ };
46
+ await persist(ctx, state.activeWorktree);
47
+ return {
48
+ ok: true,
49
+ content: `已进入主 Agent 隔离 worktree:${wt.path}\n分支:${wt.branch}\n后续工具调用立即使用该目录。`,
50
+ };
51
+ }
52
+ async function exit(input, ctx) {
53
+ const state = ctx.sessionState, wt = state?.activeWorktree;
54
+ if (!state || !wt)
55
+ return { ok: false, error: '当前会话没有活动 worktree' };
56
+ if (input.action === 'keep') {
57
+ state.activeWorktree = undefined;
58
+ await persist(ctx, undefined);
59
+ return {
60
+ ok: true,
61
+ content: `已退出有效 cwd,但保留 worktree:${wt.path}\n分支:${wt.branch}`,
62
+ };
63
+ }
64
+ const changed = await hasWorktreeChanges(wt.path, wt.baseSha);
65
+ if (input.action === 'discard' && changed) {
66
+ const committed = await commitWorktreeChanges(wt.path, `archive: preserve discarded session ${wt.id}`);
67
+ if (committed.ok) {
68
+ state.activeWorktree = undefined;
69
+ await persist(ctx, undefined);
70
+ }
71
+ return committed.ok
72
+ ? {
73
+ ok: true,
74
+ content: `检测到成果,未永久删除。已归档到分支 ${wt.branch},worktree 保留在 ${wt.path}。`,
75
+ }
76
+ : {
77
+ ok: false,
78
+ error: `无法安全归档,worktree 保持活动:${committed.error}`,
79
+ };
80
+ }
81
+ if (input.action === 'discard') {
82
+ await removeAgentWorktree(wt.path, wt.branch, wt.gitRoot);
83
+ state.activeWorktree = undefined;
84
+ await persist(ctx, undefined);
85
+ return {
86
+ ok: true,
87
+ content: 'worktree 无改动,已通过 Git worktree 管理安全清理。',
88
+ };
89
+ }
90
+ const committed = await commitWorktreeChanges(wt.path, `minimal-agent: apply session ${wt.id}`);
91
+ if (!committed.ok)
92
+ return {
93
+ ok: false,
94
+ error: `提交隔离改动失败,worktree 保持活动:${committed.error}`,
95
+ };
96
+ const applied = await applyAgentWorktree(wt.gitRoot, wt.id);
97
+ if (!applied.ok)
98
+ return {
99
+ ok: false,
100
+ error: `应用检查失败,worktree 保持活动:${applied.error}`,
101
+ };
102
+ state.activeWorktree = undefined;
103
+ await persist(ctx, undefined);
104
+ return {
105
+ ok: true,
106
+ content: `已通过 git apply --check 并将变更应用到原工作区;未自动提交。`,
107
+ };
108
+ }
109
+ export const enterWorktreeTool = {
110
+ name: 'EnterWorktree',
111
+ description: 'Creates and enters a session-scoped Git worktree for the main Agent. Later tool calls use it immediately while project memory keeps the original workspace identity.',
112
+ inputSchema: enterSchema,
113
+ parameters: toToolParameters(enterSchema),
114
+ isReadOnly: false,
115
+ isConcurrencySafe: false,
116
+ call: async () => ({ ok: false, error: 'requires session context' }),
117
+ callWithContext: (i, _s, c) => enter(i, c),
118
+ };
119
+ export const exitWorktreeTool = {
120
+ name: 'ExitWorktree',
121
+ description: 'Leaves the current session worktree. apply checks and applies its patch to the original workspace; keep preserves it; discard never destroys uncommitted results and archives changed work.',
122
+ inputSchema: exitSchema,
123
+ parameters: toToolParameters(exitSchema),
124
+ isReadOnly: false,
125
+ isConcurrencySafe: false,
126
+ call: async () => ({ ok: false, error: 'requires session context' }),
127
+ callWithContext: (i, _s, c) => exit(i, c),
128
+ };