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
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { loadContext, saveContext } from '../context/persistContext.js';
22
22
  import { acquireContextLock } from '../context/contextLock.js';
23
- import { episodicMemoryService } from '../memory/episodicMemory.js';
23
+ import { startTurnJournal } from '../memory/turnJournal.js';
24
24
  import { runWithPlugins } from '../plugins/pluginRunner.js';
25
25
  /**
26
26
  * 已有历史时,用调用方新算好的 systemPrompt(含当前日期/实时扫描的 skills·plugins 清单/
@@ -55,23 +55,14 @@ export async function* runTurn(opts) {
55
55
  const loaded = opts.initialHistory ?? (persist ? await loadContext(opts.sessionFile) : null);
56
56
  const history = buildInitialHistory(loaded, opts.systemPrompt);
57
57
  const historyStart = history.length;
58
- const memoryContext = opts.memoryContext;
59
- const journalContext = memoryContext?.access !== 'denied'
60
- && memoryContext?.workspaceId
61
- && memoryContext?.sessionId
62
- ? {
63
- workspaceId: memoryContext.workspaceId,
64
- sessionId: memoryContext.sessionId,
65
- }
66
- : null;
67
- if (journalContext) {
68
- await episodicMemoryService.append({
69
- ...journalContext,
70
- role: 'user',
71
- content: opts.userInput,
72
- messageId: opts.userMessageId,
73
- }).catch(() => undefined);
74
- }
58
+ // 会话原文记账:实现在 src/memory/turnJournal.ts(TUI 的 useChat 用同一份),
59
+ // 刻意不下沉到 runQuery —— 子 Agent 与 workflow 步也调 runQuery,记在那里会把
60
+ // 子任务提示词当成"用户说的话"污染历史检索。
61
+ const journal = await startTurnJournal({
62
+ memoryContext: opts.memoryContext,
63
+ userInput: opts.userInput,
64
+ ...(opts.userMessageId ? { userMessageId: opts.userMessageId } : {}),
65
+ });
75
66
  try {
76
67
  yield* runWithPlugins(opts.userInput, {
77
68
  provider: opts.provider,
@@ -83,6 +74,7 @@ export async function* runTurn(opts) {
83
74
  sessionState: opts.sessionState,
84
75
  chat: opts.chat,
85
76
  executeTool: opts.executeTool,
77
+ commitFormalDelivery: opts.commitFormalDelivery,
86
78
  toolGate: opts.toolGate,
87
79
  transientContext: opts.transientContext,
88
80
  transientBundle: opts.transientBundle,
@@ -102,17 +94,7 @@ export async function* runTurn(opts) {
102
94
  }
103
95
  finally {
104
96
  try {
105
- if (journalContext) {
106
- const assistant = lastAssistantMessage(history, historyStart);
107
- const content = assistant ? messageText(assistant) : '';
108
- if (content) {
109
- await episodicMemoryService.append({
110
- ...journalContext,
111
- role: 'assistant',
112
- content,
113
- }).catch(() => undefined);
114
- }
115
- }
97
+ await journal?.finish(history, historyStart);
116
98
  if (persist)
117
99
  await saveContext(history, opts.sessionFile);
118
100
  }
@@ -121,20 +103,3 @@ export async function* runTurn(opts) {
121
103
  }
122
104
  }
123
105
  }
124
- function messageText(message) {
125
- if (typeof message.content === 'string')
126
- return message.content;
127
- if (!Array.isArray(message.content))
128
- return '';
129
- return message.content
130
- .filter((part) => part.type === 'text')
131
- .map((part) => part.text)
132
- .join('\n');
133
- }
134
- function lastAssistantMessage(messages, start) {
135
- for (let index = messages.length - 1; index >= start; index -= 1) {
136
- if (messages[index]?.role === 'assistant')
137
- return messages[index];
138
- }
139
- return undefined;
140
- }
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { loadContext, saveContext } from '../context/persistContext.js';
23
23
  import { acquireContextLock } from '../context/contextLock.js';
24
- import { episodicMemoryService } from '../memory/episodicMemory.js';
24
+ import { startTurnJournal } from '../memory/turnJournal.js';
25
25
  import type { PluginEvent } from '../plugins/pluginApi.js';
26
26
  import { runWithPlugins } from '../plugins/pluginRunner.js';
27
27
  import type {
@@ -49,6 +49,7 @@ export interface RunTurnOptions {
49
49
  * 让测试用显式注入替代 mock.module(loop.js)——后者会全局泄漏污染其它测试文件。 */
50
50
  chat?: import('../plugins/pluginRunner.js').PluginRunnerOptions['chat'];
51
51
  executeTool?: import('../plugins/pluginRunner.js').PluginRunnerOptions['executeTool'];
52
+ commitFormalDelivery?: import('../plugins/pluginRunner.js').PluginRunnerOptions['commitFormalDelivery'];
52
53
  /** 工具执行闸门(生产用):透传给 runWithPlugins→runQuery。见 src/toolGate.ts。 */
53
54
  toolGate?: import('../plugins/pluginRunner.js').PluginRunnerOptions['toolGate'];
54
55
  /** 只对本次模型回合可见,不写入 session。 */
@@ -111,24 +112,14 @@ export async function* runTurn(
111
112
  const loaded = opts.initialHistory ?? (persist ? await loadContext(opts.sessionFile) : null);
112
113
  const history: Message[] = buildInitialHistory(loaded, opts.systemPrompt);
113
114
  const historyStart = history.length;
114
- const memoryContext = opts.memoryContext;
115
- const journalContext = memoryContext?.access !== 'denied'
116
- && memoryContext?.workspaceId
117
- && memoryContext?.sessionId
118
- ? {
119
- workspaceId: memoryContext.workspaceId,
120
- sessionId: memoryContext.sessionId,
121
- }
122
- : null;
123
-
124
- if (journalContext) {
125
- await episodicMemoryService.append({
126
- ...journalContext,
127
- role: 'user',
128
- content: opts.userInput,
129
- messageId: opts.userMessageId,
130
- }).catch(() => undefined);
131
- }
115
+ // 会话原文记账:实现在 src/memory/turnJournal.ts(TUI 的 useChat 用同一份),
116
+ // 刻意不下沉到 runQuery —— 子 Agent 与 workflow 步也调 runQuery,记在那里会把
117
+ // 子任务提示词当成"用户说的话"污染历史检索。
118
+ const journal = await startTurnJournal({
119
+ memoryContext: opts.memoryContext,
120
+ userInput: opts.userInput,
121
+ ...(opts.userMessageId ? { userMessageId: opts.userMessageId } : {}),
122
+ });
132
123
 
133
124
  try {
134
125
  yield* runWithPlugins(opts.userInput, {
@@ -141,6 +132,7 @@ export async function* runTurn(
141
132
  sessionState: opts.sessionState,
142
133
  chat: opts.chat,
143
134
  executeTool: opts.executeTool,
135
+ commitFormalDelivery: opts.commitFormalDelivery,
144
136
  toolGate: opts.toolGate,
145
137
  transientContext: opts.transientContext,
146
138
  transientBundle: opts.transientBundle,
@@ -159,36 +151,10 @@ export async function* runTurn(
159
151
  });
160
152
  } finally {
161
153
  try {
162
- if (journalContext) {
163
- const assistant = lastAssistantMessage(history, historyStart);
164
- const content = assistant ? messageText(assistant) : '';
165
- if (content) {
166
- await episodicMemoryService.append({
167
- ...journalContext,
168
- role: 'assistant',
169
- content,
170
- }).catch(() => undefined);
171
- }
172
- }
154
+ await journal?.finish(history, historyStart);
173
155
  if (persist) await saveContext(history, opts.sessionFile);
174
156
  } finally {
175
157
  await release();
176
158
  }
177
159
  }
178
160
  }
179
-
180
- function messageText(message: Message): string {
181
- if (typeof message.content === 'string') return message.content;
182
- if (!Array.isArray(message.content)) return '';
183
- return message.content
184
- .filter((part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text')
185
- .map((part) => part.text)
186
- .join('\n');
187
- }
188
-
189
- function lastAssistantMessage(messages: Message[], start: number): Message | undefined {
190
- for (let index = messages.length - 1; index >= start; index -= 1) {
191
- if (messages[index]?.role === 'assistant') return messages[index];
192
- }
193
- return undefined;
194
- }
@@ -0,0 +1,149 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, readdir, rename, rm, stat, } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { agentStateDir } from '../context/agentHome.js';
5
+ function segment(value, fallback) {
6
+ return ((value ?? fallback).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) ||
7
+ fallback);
8
+ }
9
+ export function sessionRuntimeDir(identity) {
10
+ return join(agentStateDir(), 'runtime', segment(identity.workspaceId, 'local'), segment(identity.sessionId, 'local'));
11
+ }
12
+ const queues = new Map();
13
+ export async function withRuntimeFileLock(path, work) {
14
+ const key = resolve(path);
15
+ const previous = queues.get(key) ?? Promise.resolve();
16
+ let release;
17
+ const current = new Promise((done) => {
18
+ release = done;
19
+ });
20
+ const tail = previous.then(() => current);
21
+ queues.set(key, tail);
22
+ await previous.catch(() => undefined);
23
+ let lockHandle;
24
+ const lockPath = `${path}.lock`;
25
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
26
+ try {
27
+ const deadline = Date.now() + 10_000;
28
+ while (!lockHandle) {
29
+ try {
30
+ lockHandle = await open(lockPath, 'wx', 0o600);
31
+ await lockHandle.writeFile(`${process.pid}\n${Date.now()}\n`);
32
+ }
33
+ catch (error) {
34
+ if (error.code !== 'EEXIST')
35
+ throw error;
36
+ const age = Date.now() -
37
+ (await stat(lockPath).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
38
+ if (age > 30_000)
39
+ await rm(lockPath, { force: true });
40
+ else if (Date.now() >= deadline)
41
+ throw new Error(`runtime lock timeout: ${path}`);
42
+ else
43
+ await new Promise((r) => setTimeout(r, 25));
44
+ }
45
+ }
46
+ return await work();
47
+ }
48
+ finally {
49
+ await lockHandle?.close().catch(() => undefined);
50
+ if (lockHandle)
51
+ await rm(lockPath, { force: true }).catch(() => undefined);
52
+ release();
53
+ if (queues.get(key) === tail)
54
+ queues.delete(key);
55
+ }
56
+ }
57
+ export async function readRuntimeJson(path, fallback) {
58
+ try {
59
+ return JSON.parse(await readFile(path, 'utf8'));
60
+ }
61
+ catch {
62
+ return fallback;
63
+ }
64
+ }
65
+ export async function writeRuntimeJson(path, value) {
66
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
67
+ const temp = `${path}.${randomUUID()}.tmp`;
68
+ try {
69
+ const handle = await open(temp, 'wx', 0o600);
70
+ try {
71
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
72
+ await handle.sync();
73
+ }
74
+ finally {
75
+ await handle.close();
76
+ }
77
+ await rename(temp, path);
78
+ }
79
+ finally {
80
+ await rm(temp, { force: true }).catch(() => undefined);
81
+ }
82
+ }
83
+ export async function mutateRuntimeJson(path, fallback, mutate) {
84
+ return withRuntimeFileLock(path, async () => {
85
+ const next = await mutate(await readRuntimeJson(path, fallback));
86
+ await writeRuntimeJson(path, next);
87
+ return next;
88
+ });
89
+ }
90
+ export async function removeManagedPath(path, expectedRoot) {
91
+ const target = resolve(path);
92
+ const root = resolve(expectedRoot);
93
+ const rel = target.slice(root.length);
94
+ if (target === root || !target.startsWith(`${root}/`) || rel.includes('\0')) {
95
+ throw new Error(`拒绝清理托管根目录之外的路径:${target}`);
96
+ }
97
+ await rm(target, { recursive: true, force: true });
98
+ }
99
+ export async function runtimeFileVersion(path) {
100
+ const info = await stat(path);
101
+ return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}`;
102
+ }
103
+ const activeOutputs = new Set();
104
+ const OUTPUT_RETENTION_MS = 7 * 24 * 60 * 60_000;
105
+ const OUTPUT_GLOBAL_LIMIT = 5 * 1024 * 1024 * 1024;
106
+ let pruneOutputsPromise;
107
+ export function registerRuntimeOutput(path) {
108
+ activeOutputs.add(resolve(path));
109
+ }
110
+ export function unregisterRuntimeOutput(path) {
111
+ activeOutputs.delete(resolve(path));
112
+ }
113
+ export function pruneRuntimeOutputs() {
114
+ pruneOutputsPromise ??= (async () => {
115
+ const root = join(agentStateDir(), 'runtime');
116
+ const files = [];
117
+ for (const workspace of await readdir(root).catch(() => [])) {
118
+ const workspaceDir = join(root, workspace);
119
+ for (const session of await readdir(workspaceDir).catch(() => [])) {
120
+ const outputDir = join(workspaceDir, session, 'outputs');
121
+ const sessionFiles = [];
122
+ for (const name of await readdir(outputDir).catch(() => [])) {
123
+ const path = join(outputDir, name);
124
+ const info = await stat(path).catch(() => undefined);
125
+ if (info?.isFile() && !activeOutputs.has(resolve(path))) {
126
+ sessionFiles.push({ path, size: info.size, mtimeMs: info.mtimeMs });
127
+ }
128
+ }
129
+ sessionFiles.sort((left, right) => right.mtimeMs - left.mtimeMs);
130
+ await Promise.all(sessionFiles
131
+ .slice(50)
132
+ .map((file) => rm(file.path, { force: true }).catch(() => undefined)));
133
+ files.push(...sessionFiles.slice(0, 50));
134
+ }
135
+ }
136
+ files.sort((left, right) => right.mtimeMs - left.mtimeMs);
137
+ let retainedBytes = 0;
138
+ const cutoff = Date.now() - OUTPUT_RETENTION_MS;
139
+ for (const file of files) {
140
+ retainedBytes += file.size;
141
+ if (file.mtimeMs < cutoff || retainedBytes > OUTPUT_GLOBAL_LIMIT) {
142
+ await rm(file.path, { force: true }).catch(() => undefined);
143
+ }
144
+ }
145
+ })().finally(() => {
146
+ pruneOutputsPromise = undefined;
147
+ });
148
+ return pruneOutputsPromise;
149
+ }
@@ -0,0 +1,197 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ mkdir,
4
+ open,
5
+ readFile,
6
+ readdir,
7
+ rename,
8
+ rm,
9
+ stat,
10
+ } from 'node:fs/promises';
11
+ import { dirname, join, resolve } from 'node:path';
12
+
13
+ import { agentStateDir } from '../context/agentHome.js';
14
+
15
+ export interface RuntimeIdentity {
16
+ workspaceId?: string;
17
+ sessionId?: string;
18
+ }
19
+
20
+ function segment(value: string | undefined, fallback: string): string {
21
+ return (
22
+ (value ?? fallback).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) ||
23
+ fallback
24
+ );
25
+ }
26
+
27
+ export function sessionRuntimeDir(identity: RuntimeIdentity): string {
28
+ return join(
29
+ agentStateDir(),
30
+ 'runtime',
31
+ segment(identity.workspaceId, 'local'),
32
+ segment(identity.sessionId, 'local'),
33
+ );
34
+ }
35
+
36
+ const queues = new Map<string, Promise<unknown>>();
37
+ export async function withRuntimeFileLock<T>(
38
+ path: string,
39
+ work: () => Promise<T>,
40
+ ): Promise<T> {
41
+ const key = resolve(path);
42
+ const previous = queues.get(key) ?? Promise.resolve();
43
+ let release!: () => void;
44
+ const current = new Promise<void>((done) => {
45
+ release = done;
46
+ });
47
+ const tail = previous.then(() => current);
48
+ queues.set(key, tail);
49
+ await previous.catch(() => undefined);
50
+ let lockHandle: Awaited<ReturnType<typeof open>> | undefined;
51
+ const lockPath = `${path}.lock`;
52
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
53
+ try {
54
+ const deadline = Date.now() + 10_000;
55
+ while (!lockHandle) {
56
+ try {
57
+ lockHandle = await open(lockPath, 'wx', 0o600);
58
+ await lockHandle.writeFile(`${process.pid}\n${Date.now()}\n`);
59
+ } catch (error) {
60
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
61
+ const age =
62
+ Date.now() -
63
+ (await stat(lockPath).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
64
+ if (age > 30_000) await rm(lockPath, { force: true });
65
+ else if (Date.now() >= deadline)
66
+ throw new Error(`runtime lock timeout: ${path}`);
67
+ else await new Promise((r) => setTimeout(r, 25));
68
+ }
69
+ }
70
+ return await work();
71
+ } finally {
72
+ await lockHandle?.close().catch(() => undefined);
73
+ if (lockHandle) await rm(lockPath, { force: true }).catch(() => undefined);
74
+ release();
75
+ if (queues.get(key) === tail) queues.delete(key);
76
+ }
77
+ }
78
+
79
+ export async function readRuntimeJson<T>(
80
+ path: string,
81
+ fallback: T,
82
+ ): Promise<T> {
83
+ try {
84
+ return JSON.parse(await readFile(path, 'utf8')) as T;
85
+ } catch {
86
+ return fallback;
87
+ }
88
+ }
89
+
90
+ export async function writeRuntimeJson(
91
+ path: string,
92
+ value: unknown,
93
+ ): Promise<void> {
94
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
95
+ const temp = `${path}.${randomUUID()}.tmp`;
96
+ try {
97
+ const handle = await open(temp, 'wx', 0o600);
98
+ try {
99
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
100
+ await handle.sync();
101
+ } finally {
102
+ await handle.close();
103
+ }
104
+ await rename(temp, path);
105
+ } finally {
106
+ await rm(temp, { force: true }).catch(() => undefined);
107
+ }
108
+ }
109
+
110
+ export async function mutateRuntimeJson<T>(
111
+ path: string,
112
+ fallback: T,
113
+ mutate: (value: T) => T | Promise<T>,
114
+ ): Promise<T> {
115
+ return withRuntimeFileLock(path, async () => {
116
+ const next = await mutate(await readRuntimeJson(path, fallback));
117
+ await writeRuntimeJson(path, next);
118
+ return next;
119
+ });
120
+ }
121
+
122
+ export async function removeManagedPath(
123
+ path: string,
124
+ expectedRoot: string,
125
+ ): Promise<void> {
126
+ const target = resolve(path);
127
+ const root = resolve(expectedRoot);
128
+ const rel = target.slice(root.length);
129
+ if (target === root || !target.startsWith(`${root}/`) || rel.includes('\0')) {
130
+ throw new Error(`拒绝清理托管根目录之外的路径:${target}`);
131
+ }
132
+ await rm(target, { recursive: true, force: true });
133
+ }
134
+
135
+ export async function runtimeFileVersion(path: string): Promise<string> {
136
+ const info = await stat(path);
137
+ return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}`;
138
+ }
139
+
140
+ const activeOutputs = new Set<string>();
141
+ const OUTPUT_RETENTION_MS = 7 * 24 * 60 * 60_000;
142
+ const OUTPUT_GLOBAL_LIMIT = 5 * 1024 * 1024 * 1024;
143
+ let pruneOutputsPromise: Promise<void> | undefined;
144
+
145
+ export function registerRuntimeOutput(path: string): void {
146
+ activeOutputs.add(resolve(path));
147
+ }
148
+
149
+ export function unregisterRuntimeOutput(path: string): void {
150
+ activeOutputs.delete(resolve(path));
151
+ }
152
+
153
+ export function pruneRuntimeOutputs(): Promise<void> {
154
+ pruneOutputsPromise ??= (async () => {
155
+ const root = join(agentStateDir(), 'runtime');
156
+ const files: Array<{ path: string; size: number; mtimeMs: number }> = [];
157
+ for (const workspace of await readdir(root).catch(() => [])) {
158
+ const workspaceDir = join(root, workspace);
159
+ for (const session of await readdir(workspaceDir).catch(() => [])) {
160
+ const outputDir = join(workspaceDir, session, 'outputs');
161
+ const sessionFiles: Array<{
162
+ path: string;
163
+ size: number;
164
+ mtimeMs: number;
165
+ }> = [];
166
+ for (const name of await readdir(outputDir).catch(() => [])) {
167
+ const path = join(outputDir, name);
168
+ const info = await stat(path).catch(() => undefined);
169
+ if (info?.isFile() && !activeOutputs.has(resolve(path))) {
170
+ sessionFiles.push({ path, size: info.size, mtimeMs: info.mtimeMs });
171
+ }
172
+ }
173
+ sessionFiles.sort((left, right) => right.mtimeMs - left.mtimeMs);
174
+ await Promise.all(
175
+ sessionFiles
176
+ .slice(50)
177
+ .map((file) =>
178
+ rm(file.path, { force: true }).catch(() => undefined),
179
+ ),
180
+ );
181
+ files.push(...sessionFiles.slice(0, 50));
182
+ }
183
+ }
184
+ files.sort((left, right) => right.mtimeMs - left.mtimeMs);
185
+ let retainedBytes = 0;
186
+ const cutoff = Date.now() - OUTPUT_RETENTION_MS;
187
+ for (const file of files) {
188
+ retainedBytes += file.size;
189
+ if (file.mtimeMs < cutoff || retainedBytes > OUTPUT_GLOBAL_LIMIT) {
190
+ await rm(file.path, { force: true }).catch(() => undefined);
191
+ }
192
+ }
193
+ })().finally(() => {
194
+ pruneOutputsPromise = undefined;
195
+ });
196
+ return pruneOutputsPromise;
197
+ }
@@ -12,12 +12,14 @@ import { createMicroCompactState, } from '../context/microCompactLite.js';
12
12
  import { createReactiveCompactState, } from '../context/reactiveCompact.js';
13
13
  import { createFileReadState } from '../tools/shared/fileState.js';
14
14
  import { createBackgroundTaskState, disposeBackgroundTaskState, } from '../tools/tasks/backgroundTaskService.js';
15
+ import { createAgentTaskState, stopAllAgentTasks } from '../tools/agent/agentTaskService.js';
15
16
  export function createSessionState() {
16
17
  return {
17
18
  reactive: createReactiveCompactState(),
18
19
  microCompact: createMicroCompactState(),
19
20
  fileReads: createFileReadState(),
20
21
  backgroundTasks: createBackgroundTaskState(),
22
+ agentTasks: createAgentTaskState(),
21
23
  };
22
24
  }
23
25
  const persistentStates = new Map();
@@ -56,6 +58,7 @@ export async function disposeSessionState(key) {
56
58
  return;
57
59
  persistentStates.delete(key);
58
60
  await disposeBackgroundTaskState(entry.state.backgroundTasks);
61
+ await stopAllAgentTasks(entry.state.agentTasks);
59
62
  entry.state.fileReads.entries.clear();
60
63
  }
61
64
  export async function disposeAllSessionStates() {
@@ -63,11 +66,13 @@ export async function disposeAllSessionStates() {
63
66
  persistentStates.clear();
64
67
  await Promise.all(entries.map(async ({ state }) => {
65
68
  await disposeBackgroundTaskState(state.backgroundTasks);
69
+ await stopAllAgentTasks(state.agentTasks);
66
70
  state.fileReads.entries.clear();
67
71
  }));
68
72
  }
69
73
  export async function disposeDetachedSessionState(state) {
70
74
  await disposeBackgroundTaskState(state.backgroundTasks);
75
+ await stopAllAgentTasks(state.agentTasks);
71
76
  state.fileReads.entries.clear();
72
77
  }
73
78
  function prunePersistentStates(now, keepKey) {
@@ -23,12 +23,22 @@ import {
23
23
  disposeBackgroundTaskState,
24
24
  type BackgroundTaskState,
25
25
  } from '../tools/tasks/backgroundTaskService.js';
26
+ import { createAgentTaskState, stopAllAgentTasks, type AgentTaskState } from '../tools/agent/agentTaskService.js';
26
27
 
27
28
  export interface SessionState {
28
29
  reactive: ReactiveCompactState;
29
30
  microCompact: MicroCompactState;
30
31
  fileReads: FileReadState;
31
32
  backgroundTasks: BackgroundTaskState;
33
+ agentTasks: AgentTaskState;
34
+ /** Main-agent worktree changes the effective cwd without changing workspace identity. */
35
+ activeWorktree?: {
36
+ id: string;
37
+ path: string;
38
+ branch: string;
39
+ baseSha: string;
40
+ gitRoot: string;
41
+ };
32
42
  }
33
43
 
34
44
  export function createSessionState(): SessionState {
@@ -37,6 +47,7 @@ export function createSessionState(): SessionState {
37
47
  microCompact: createMicroCompactState(),
38
48
  fileReads: createFileReadState(),
39
49
  backgroundTasks: createBackgroundTaskState(),
50
+ agentTasks: createAgentTaskState(),
40
51
  };
41
52
  }
42
53
 
@@ -84,6 +95,7 @@ export async function disposeSessionState(key: string): Promise<void> {
84
95
  if (!entry) return;
85
96
  persistentStates.delete(key);
86
97
  await disposeBackgroundTaskState(entry.state.backgroundTasks);
98
+ await stopAllAgentTasks(entry.state.agentTasks);
87
99
  entry.state.fileReads.entries.clear();
88
100
  }
89
101
 
@@ -92,12 +104,14 @@ export async function disposeAllSessionStates(): Promise<void> {
92
104
  persistentStates.clear();
93
105
  await Promise.all(entries.map(async ({ state }) => {
94
106
  await disposeBackgroundTaskState(state.backgroundTasks);
107
+ await stopAllAgentTasks(state.agentTasks);
95
108
  state.fileReads.entries.clear();
96
109
  }));
97
110
  }
98
111
 
99
112
  export async function disposeDetachedSessionState(state: SessionState): Promise<void> {
100
113
  await disposeBackgroundTaskState(state.backgroundTasks);
114
+ await stopAllAgentTasks(state.agentTasks);
101
115
  state.fileReads.entries.clear();
102
116
  }
103
117