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.
Files changed (181) hide show
  1. package/package.json +5 -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/expressions.js +29 -8
  7. package/plugins/workflow-runner/src/expressions.ts +28 -10
  8. package/plugins/workflow-runner/src/index.js +50 -20
  9. package/plugins/workflow-runner/src/index.ts +75 -31
  10. package/plugins/workflow-runner/src/multimodal.js +2 -1
  11. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  12. package/plugins/workflow-runner/src/recovery.js +9 -2
  13. package/plugins/workflow-runner/src/recovery.ts +9 -2
  14. package/plugins/workflow-runner/src/runner.js +31 -5
  15. package/plugins/workflow-runner/src/runner.ts +33 -5
  16. package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
  17. package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
  18. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  19. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  20. package/plugins/workflow-runner/src/stepExecutors/tool.js +91 -61
  21. package/plugins/workflow-runner/src/stepExecutors/tool.ts +111 -71
  22. package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
  23. package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
  24. package/plugins/workflow-runner/src/taskIdentity.js +197 -21
  25. package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
  26. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  27. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  28. package/plugins/workflow-runner/src/types.js +113 -7
  29. package/plugins/workflow-runner/src/types.ts +137 -14
  30. package/plugins/workflow-runner/src/workflowState.js +455 -54
  31. package/plugins/workflow-runner/src/workflowState.ts +508 -63
  32. package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
  33. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  34. package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
  35. package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
  36. package/plugins/workflow-runner/test/expressions.test.ts +42 -0
  37. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  38. package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
  39. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  40. package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
  41. package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
  42. package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
  43. package/plugins/workflow-runner/test/research.load.test.ts +328 -0
  44. package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
  45. package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
  46. package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
  47. package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
  48. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  49. package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
  50. package/plugins/workflow-runner/test/word.load.test.ts +9 -9
  51. package/skills/skill-creator/scripts/run_eval.py +3 -2
  52. package/src/cli/print.js +36 -4
  53. package/src/cli/print.ts +42 -4
  54. package/src/cli/streamJson.js +19 -4
  55. package/src/cli/streamJson.ts +17 -4
  56. package/src/config/configDefaults.js +25 -0
  57. package/src/config/configDefaults.ts +33 -0
  58. package/src/config/configFile.js +2 -21
  59. package/src/config/configFile.ts +11 -24
  60. package/src/config/configManagement.js +1 -1
  61. package/src/config/configManagement.ts +2 -2
  62. package/src/config/configService.js +24 -0
  63. package/src/config/configService.ts +23 -0
  64. package/src/context/compact.js +4 -0
  65. package/src/context/compact.ts +4 -0
  66. package/src/context/contextBundle.js +38 -13
  67. package/src/context/contextBundle.ts +47 -13
  68. package/src/harness/stallGuard.js +19 -4
  69. package/src/harness/stallGuard.ts +30 -7
  70. package/src/infra/verification.js +25 -18
  71. package/src/infra/verification.ts +50 -18
  72. package/src/loop.js +95 -11
  73. package/src/loop.ts +125 -16
  74. package/src/main.js +8 -1
  75. package/src/main.tsx +7 -1
  76. package/src/memory/constants.js +10 -0
  77. package/src/memory/constants.ts +13 -0
  78. package/src/memory/episodicMemory.js +274 -2
  79. package/src/memory/episodicMemory.ts +306 -1
  80. package/src/memory/fileStore.js +24 -4
  81. package/src/memory/fileStore.ts +22 -2
  82. package/src/memory/memoryService.js +79 -12
  83. package/src/memory/memoryService.ts +108 -9
  84. package/src/memory/repository.js +415 -85
  85. package/src/memory/repository.ts +437 -86
  86. package/src/memory/turnJournal.js +95 -0
  87. package/src/memory/turnJournal.ts +120 -0
  88. package/src/memory/types.ts +16 -0
  89. package/src/plugins/pluginApi.ts +30 -6
  90. package/src/plugins/pluginRunner.js +23 -8
  91. package/src/plugins/pluginRunner.ts +32 -13
  92. package/src/prompts/policyRegistry.js +55 -1
  93. package/src/prompts/policyRegistry.ts +55 -1
  94. package/src/prompts/promptAssembly.js +39 -7
  95. package/src/prompts/promptAssembly.ts +145 -59
  96. package/src/session/runAgentTurn.js +43 -21
  97. package/src/session/runAgentTurn.ts +68 -26
  98. package/src/session/runTurn.js +11 -46
  99. package/src/session/runTurn.ts +12 -46
  100. package/src/session/runtimeStore.js +149 -0
  101. package/src/session/runtimeStore.ts +197 -0
  102. package/src/session/sessionState.js +5 -0
  103. package/src/session/sessionState.ts +14 -0
  104. package/src/tools/advisor/advisor.js +76 -9
  105. package/src/tools/advisor/advisor.ts +79 -9
  106. package/src/tools/agent/agent.js +143 -14
  107. package/src/tools/agent/agent.ts +149 -14
  108. package/src/tools/agent/agentTaskService.js +60 -0
  109. package/src/tools/agent/agentTaskService.ts +113 -0
  110. package/src/tools/agent/agentTypes.js +5 -0
  111. package/src/tools/agent/agentTypes.ts +5 -0
  112. package/src/tools/bash/bash.js +80 -15
  113. package/src/tools/bash/bash.ts +83 -15
  114. package/src/tools/capabilities.js +5 -0
  115. package/src/tools/capabilities.ts +11 -1
  116. package/src/tools/edit/edit.js +7 -4
  117. package/src/tools/edit/edit.ts +7 -4
  118. package/src/tools/edit/multi-edit.js +4 -2
  119. package/src/tools/edit/multi-edit.ts +4 -2
  120. package/src/tools/glob/glob.js +156 -40
  121. package/src/tools/glob/glob.ts +185 -46
  122. package/src/tools/index.js +35 -17
  123. package/src/tools/index.ts +81 -20
  124. package/src/tools/lsp/lsp.js +165 -22
  125. package/src/tools/lsp/lsp.ts +206 -36
  126. package/src/tools/powershell/powershell.js +134 -27
  127. package/src/tools/powershell/powershell.ts +171 -30
  128. package/src/tools/presentresult/presentresult.js +10 -0
  129. package/src/tools/presentresult/presentresult.ts +10 -0
  130. package/src/tools/read/read.js +249 -29
  131. package/src/tools/read/read.ts +396 -59
  132. package/src/tools/shared/diffSummary.js +44 -0
  133. package/src/tools/shared/diffSummary.ts +63 -0
  134. package/src/tools/shared/fileActivity.js +2 -1
  135. package/src/tools/shared/fileActivity.ts +3 -0
  136. package/src/tools/shared/permanentDeleteGuard.js +57 -0
  137. package/src/tools/shared/permanentDeleteGuard.ts +71 -0
  138. package/src/tools/shared/toolOutputStore.js +97 -0
  139. package/src/tools/shared/toolOutputStore.ts +110 -0
  140. package/src/tools/skill/skill.js +86 -0
  141. package/src/tools/skill/skill.ts +94 -0
  142. package/src/tools/snip/snip.js +67 -0
  143. package/src/tools/snip/snip.ts +80 -0
  144. package/src/tools/tasks/backgroundTaskService.js +79 -28
  145. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  146. package/src/tools/tasks/taskManagement.js +163 -0
  147. package/src/tools/tasks/taskManagement.ts +180 -0
  148. package/src/tools/tasks/taskOutput.js +64 -14
  149. package/src/tools/tasks/taskOutput.ts +102 -23
  150. package/src/tools/tasks/taskStop.js +48 -8
  151. package/src/tools/tasks/taskStop.ts +69 -9
  152. package/src/tools/tasks/taskStore.js +46 -0
  153. package/src/tools/tasks/taskStore.ts +77 -0
  154. package/src/tools/terminal/terminalCapture.js +167 -0
  155. package/src/tools/terminal/terminalCapture.ts +179 -0
  156. package/src/tools/trash/trash.js +233 -0
  157. package/src/tools/trash/trash.ts +283 -0
  158. package/src/tools/webbrowser/browserControl.js +5 -1
  159. package/src/tools/webbrowser/browserControl.ts +7 -1
  160. package/src/tools/webbrowser/webbrowser.js +8 -4
  161. package/src/tools/webbrowser/webbrowser.ts +8 -4
  162. package/src/tools/worktree/sessionWorktree.js +128 -0
  163. package/src/tools/worktree/sessionWorktree.ts +158 -0
  164. package/src/tools/write/write.js +6 -2
  165. package/src/tools/write/write.ts +6 -2
  166. package/src/types.ts +22 -4
  167. package/src/ui/hooks/useChat.js +31 -11
  168. package/src/ui/hooks/useChat.ts +31 -13
  169. package/src/web/protocol.ts +78 -3
  170. package/workflows/adaptive-research.yaml +5 -5
  171. package/workflows/book-review-short.yaml +5 -5
  172. package/workflows/e2e-write-greet.yaml +2 -2
  173. package/workflows/illustrate.yaml +12 -12
  174. package/workflows/plan.yaml +18 -18
  175. package/workflows/ppt.yaml +201 -201
  176. package/workflows/ralph-loop.yaml +3 -1
  177. package/workflows/research.md +449 -0
  178. package/workflows/research.yaml +873 -0
  179. package/workflows/webppt.yaml +47 -47
  180. package/workflows/word.yaml +80 -80
  181. package/workflows/youtube-shorts.yaml +25 -25
@@ -3,14 +3,12 @@
3
3
  * src/tools/agent/agent.ts —— Agent 工具(fork 子 agent / 多 agent 并行)
4
4
  * ------------------------------------------------------------
5
5
  * 借鉴 Claude Code 官方 AgentTool/ForkAgent 的核心思想,按 minimal-agent
6
- * 内核架构做同步等效实现:
6
+ * 内核架构做可恢复的前台/后台等效实现:
7
7
  * - 官方 fork = 「隔离 context 嵌套跑同一主循环 + 摘要回传(完整历史不进
8
- * 父上下文)」。我们不需要它的后台任务队列/AsyncLocalStorage 身份追踪
9
- * (那是为 Ink UI 后台任务服务的)——本工具同步 await 一个嵌套 runQuery,
10
- * 声明 isConcurrencySafe:true 后,模型在同一轮发出 N 个 Agent 调用时,
11
- * loop.ts 现成的并行 worker 队列自动把它们并发执行。这就是并行的全部来源。
12
- * - 同轮混入任一非并发安全工具(如 Edit)会整轮退化串行——这是想要的安全
13
- * 语义(写操作与并行 fork 不该交错)。
8
+ * 父上下文)」。本工具在隔离 history 中嵌套运行同一主循环,并将私有历史、
9
+ * 进度和结果写入会话运行存储;前台、后台和重启后续接共用同一执行内核。
10
+ * - 同轮多个 Agent 调用仍由 loop.ts 的并行 worker 执行;write scopes 与可选
11
+ * worktree 负责隔离真正可并行的写任务。
14
12
  *
15
13
  * 已知限制(如实声明):
16
14
  * - 子 agent 的 LoopEvent 不外泄:-p / stream-json 宿主只看到本工具的一对
@@ -28,6 +26,8 @@
28
26
  */
29
27
 
30
28
  import { randomUUID } from 'node:crypto';
29
+ import { EventEmitter } from 'node:events';
30
+ import { access } from 'node:fs/promises';
31
31
  import { withAskSource } from '../../askBridge.js';
32
32
  import { isAbsolute, relative, resolve, sep } from 'node:path';
33
33
  import { z } from 'zod';
@@ -59,6 +59,7 @@ import {
59
59
  worktreeChangedFiles,
60
60
  worktreeDiffStat,
61
61
  } from './worktree.js';
62
+ import { appendAgentEvent, loadAgentRecord, saveAgentRecord, type AgentTaskRecord, type LiveAgentTask } from './agentTaskService.js';
62
63
 
63
64
  // ---------------- 0. 环内模块懒加载(见文件头「循环依赖纪律」) ----------------
64
65
 
@@ -116,6 +117,8 @@ const inputSchema = z.object({
116
117
  .describe(
117
118
  'general 预计修改的工作目录相对路径。范围互不重叠时可并行;缺省时按整个工作目录串行。explore 不需要。',
118
119
  ),
120
+ run_in_background: z.boolean().optional().describe('立即返回 agent_id,子任务在后台运行;用 TaskOutput/TaskStop 管理'),
121
+ resume_agent_id: z.string().regex(/^agent_[0-9a-f-]{36}$/i).optional().describe('继续当前会话中此前完成、中断或失败的 Agent 历史'),
119
122
  });
120
123
  type AgentInput = z.infer<typeof inputSchema>;
121
124
 
@@ -124,9 +127,10 @@ const parameters = toToolParameters(inputSchema);
124
127
  // ---------------- 2. Description(给 LLM 看的工具说明) ----------------
125
128
  // 借鉴官方 AgentTool prompt 文案(任务书自包含 / 同轮并行 / 何时不用)。
126
129
 
127
- const description = `- Launches a stateless sub-agent with a fresh, isolated context to execute one self-contained subtask; returns only its final report text (its full working history never enters this conversation).
130
+ const description = `- Launches an isolated sub-agent for one self-contained task. Every run returns an agent_id; run_in_background=true continues without blocking, and resume_agent_id continues its persisted private history across user turns or an app restart.
128
131
  - The sub-agent CANNOT see this conversation. The \`prompt\` must be fully self-contained: background, exact file paths, what to look for / do, and the expected output format.
129
132
  - **To parallelize, emit multiple Agent calls in ONE assistant message** — they run concurrently. This is the main value of this tool.
133
+ - Background Agent progress/results are read with TaskOutput and stopped with TaskStop. Do not poll: TaskOutput(block=true) waits efficiently.
130
134
  - Use for: exploring/researching several independent areas of a codebase at once, parallel information gathering from multiple sources, independent analyses that will be synthesized afterwards, and (with agent_type:'general') genuinely parallelizable write/execute work.
131
135
  - Do NOT use for: single targeted lookups that one or two direct tool calls would answer (a fork starts cold and re-derives context — slower and costlier), or strictly sequential work. Only general execution agents may ask the user, and only when genuinely blocked.
132
136
  - agent_type: 'explore' (default) = strictly read-only research; 'general' = read-write execution. For direct writes, declare relative write_scopes; disjoint scopes may run concurrently, overlapping or omitted scopes are serialized.
@@ -222,6 +226,10 @@ export function extractCoveredFiles(
222
226
  export interface AgentTaskDeps {
223
227
  chat?: RunQueryOptions['chat'];
224
228
  executeTool?: RunQueryOptions['executeTool'];
229
+ initialHistory?: RunQueryOptions['history'];
230
+ captureHistory?: (history:RunQueryOptions['history'])=>void|Promise<void>;
231
+ existingWorktree?: AgentWorktree;
232
+ onWorktreeCreated?: (worktree: AgentWorktree) => void | Promise<void>;
225
233
  }
226
234
 
227
235
  function positiveInt(value: string | undefined): number | undefined {
@@ -296,10 +304,10 @@ export async function runAgentTask(
296
304
  const allowedTools = def.resolveAllowedTools(ALL_TOOLS.map((t) => t.name));
297
305
 
298
306
  // ---- worktree 隔离(仅 general 显式要求时) ----
299
- let wt: AgentWorktree | null = null;
307
+ let wt: AgentWorktree | null = deps.existingWorktree ?? null;
300
308
  const isolation = input.isolation ?? ctx.subagentConfig?.defaultWriteIsolation ?? 'none';
301
- if (def.type === 'general' && isolation === 'worktree') {
302
- const created = await createAgentWorktree(`fork-${randomUUID().slice(0, 8)}`);
309
+ if (def.type === 'general' && isolation === 'worktree' && !wt) {
310
+ const created = await createAgentWorktree(`fork-${randomUUID().slice(0, 8)}`, ctx.cwd);
303
311
  if (!created.ok) {
304
312
  return {
305
313
  ok: false,
@@ -307,8 +315,9 @@ export async function runAgentTask(
307
315
  };
308
316
  }
309
317
  wt = created;
318
+ await deps.onWorktreeCreated?.(created);
310
319
  }
311
- const cwd = wt?.path ?? getWorkingDir();
320
+ const cwd = wt?.path ?? ctx.cwd ?? getWorkingDir();
312
321
  let writeScopes: string[];
313
322
  try {
314
323
  writeScopes = def.type === 'general'
@@ -348,6 +357,7 @@ export async function runAgentTask(
348
357
  surface: 'subagent',
349
358
  cwd,
350
359
  workspaceId: ctx.workspaceId,
360
+ sessionId: ctx.sessionId,
351
361
  interactiveAsk: childRuntime.interactiveAsk,
352
362
  memoryAccess: childMemoryAccess,
353
363
  currentUserInput: input.prompt,
@@ -364,7 +374,7 @@ export async function runAgentTask(
364
374
  };
365
375
 
366
376
  // ---- 子 history:一条 system(压缩按 role 定位,必须存在)+ runQuery 自己 push 任务书 ----
367
- const history: RunQueryOptions['history'] = [
377
+ const history: RunQueryOptions['history'] = deps.initialHistory?.length ? [...deps.initialHistory] : [
368
378
  { role: 'system', content: await buildSubSystemPrompt(cwd, def, subTools) },
369
379
  ];
370
380
  const historyLenBefore = history.length;
@@ -582,6 +592,7 @@ export async function runAgentTask(
582
592
  } finally {
583
593
  stopTimeout();
584
594
  releaseLease?.();
595
+ await deps.captureHistory?.(history);
585
596
  }
586
597
 
587
598
  // ---- 结局映射(直接查 signal 状态,不依赖事件旗标) ----
@@ -684,6 +695,130 @@ async function call(input: AgentInput, signal?: AbortSignal): Promise<ToolResult
684
695
  return runAgentTask(input, signal, {});
685
696
  }
686
697
 
698
+ export async function runManagedAgentTask(
699
+ input: AgentInput,
700
+ signal: AbortSignal | undefined,
701
+ ctx: ToolCallContext,
702
+ deps: AgentTaskDeps = {},
703
+ ): Promise<ToolResult> {
704
+ if (!ctx.sessionState) return runAgentTask(input, signal, ctx);
705
+ const id = input.resume_agent_id ?? `agent_${randomUUID()}`;
706
+ let initialHistory: RunQueryOptions['history'] | undefined;
707
+ let resumedWorktree: AgentWorktree | undefined;
708
+ let coreInput = { ...input, run_in_background: undefined, resume_agent_id: undefined };
709
+ if (input.resume_agent_id) {
710
+ const live = ctx.sessionState.agentTasks.tasks.get(id);
711
+ if (live?.status === 'running') return { ok: false, error: `Agent ${id} 正在运行,不能并发恢复` };
712
+ const saved = await loadAgentRecord(ctx, id);
713
+ if (!saved) return { ok: false, error: `找不到可恢复 Agent:${id}` };
714
+ initialHistory = saved.history;
715
+ resumedWorktree = saved.worktree && await access(saved.worktree.path)
716
+ .then(() => saved.worktree)
717
+ .catch(() => undefined);
718
+ coreInput = {
719
+ ...(saved.input as AgentInput),
720
+ prompt: input.prompt,
721
+ description: input.description || saved.description,
722
+ run_in_background: undefined,
723
+ resume_agent_id: undefined,
724
+ };
725
+ }
726
+ const abort = new AbortController();
727
+ const combined = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
728
+ let settle!: () => void;
729
+ const completion = new Promise<void>((resolvePromise) => { settle = resolvePromise; });
730
+ const task: LiveAgentTask = {
731
+ id,
732
+ status: 'running',
733
+ description: coreInput.description,
734
+ input: coreInput as unknown as Record<string, unknown>,
735
+ createdAt: Date.now(),
736
+ updatedAt: Date.now(),
737
+ abort,
738
+ completion,
739
+ events: new EventEmitter(),
740
+ };
741
+ ctx.sessionState.agentTasks.tasks.set(id, task);
742
+ await saveAgentRecord(ctx, task);
743
+ await appendAgentEvent(ctx, id, { phase: 'started', resumed: Boolean(input.resume_agent_id) });
744
+
745
+ const run = async (): Promise<ToolResult> => {
746
+ let captured = initialHistory;
747
+ let lastPersistedProgressAt = 0;
748
+ const managedContext: ToolCallContext = {
749
+ ...ctx,
750
+ reportProgress: (line, detail) => {
751
+ ctx.reportProgress?.(line, detail);
752
+ task.progress = line;
753
+ task.events.emit('change');
754
+ if (Date.now() - lastPersistedProgressAt >= 1_000) {
755
+ lastPersistedProgressAt = Date.now();
756
+ void saveAgentRecord(ctx, task).catch(() => undefined);
757
+ void appendAgentEvent(ctx, id, { phase: 'progress', message: line })
758
+ .catch(() => undefined);
759
+ }
760
+ },
761
+ };
762
+ try {
763
+ const result = await runAgentTask(coreInput, combined, managedContext, {
764
+ ...deps,
765
+ initialHistory,
766
+ existingWorktree: resumedWorktree,
767
+ onWorktreeCreated: async (worktree) => {
768
+ task.worktree = worktree;
769
+ await saveAgentRecord(ctx, task);
770
+ },
771
+ captureHistory: (history) => { captured = [...history]; },
772
+ });
773
+ task.result = result;
774
+ task.history = captured;
775
+ task.status = abort.signal.aborted ? 'stopped' : result.ok ? 'completed' : 'failed';
776
+ if (task.worktree && !await access(task.worktree.path).then(() => true).catch(() => false)) {
777
+ task.worktree = undefined;
778
+ }
779
+ task.updatedAt = Date.now();
780
+ await saveAgentRecord(ctx, task);
781
+ await appendAgentEvent(ctx, id, { phase: task.status, ok: result.ok });
782
+ return result;
783
+ } catch (error) {
784
+ task.status = abort.signal.aborted ? 'stopped' : 'failed';
785
+ task.result = { ok: false, error: (error as Error).message };
786
+ task.history = captured;
787
+ task.updatedAt = Date.now();
788
+ await saveAgentRecord(ctx, task).catch(() => undefined);
789
+ await appendAgentEvent(ctx, id, { phase: task.status, error: (error as Error).message })
790
+ .catch(() => undefined);
791
+ return task.result;
792
+ } finally {
793
+ task.events.emit('change');
794
+ settle();
795
+ }
796
+ };
797
+
798
+ if (input.run_in_background) {
799
+ void run();
800
+ return {
801
+ ok: true,
802
+ content: `后台 Agent 已启动。agent_id=${id}\n使用 TaskOutput 获取进度/结果,TaskStop 停止。`,
803
+ metadata: {
804
+ kind: 'agent', agentId: id, status: 'running',
805
+ agentType: input.agent_type ?? 'explore', description: input.description,
806
+ isolation: input.isolation ?? 'none', turns: 0, toolCalls: 0,
807
+ failedToolCalls: 0, durationMs: 0, changedFiles: [],
808
+ },
809
+ };
810
+ }
811
+ const result = await run();
812
+ if (result.ok && result.metadata?.kind === 'agent') {
813
+ result.metadata.agentId = id;
814
+ result.metadata.status = 'completed';
815
+ result.content = `agent_id=${id}\n${result.content}`;
816
+ } else if (!result.ok) {
817
+ result.error = `agent_id=${id}\n${result.error}`;
818
+ }
819
+ return result;
820
+ }
821
+
687
822
  // ---------------- 5. 导出 ----------------
688
823
 
689
824
  export const agentTool: Tool<AgentInput> = {
@@ -698,5 +833,5 @@ export const agentTool: Tool<AgentInput> = {
698
833
  maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
699
834
  interruptBehavior: () => 'block',
700
835
  call,
701
- callWithContext: runAgentTask,
836
+ callWithContext: runManagedAgentTask,
702
837
  };
@@ -0,0 +1,60 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readRuntimeJson, sessionRuntimeDir, withRuntimeFileLock, writeRuntimeJson, } from '../../session/runtimeStore.js';
4
+ export function createAgentTaskState() {
5
+ return { tasks: new Map() };
6
+ }
7
+ function dir(ctx, id) {
8
+ return join(sessionRuntimeDir(ctx), 'agents', id);
9
+ }
10
+ export async function appendAgentEvent(ctx, id, event) {
11
+ const path = join(dir(ctx, id), 'events.jsonl');
12
+ await mkdir(dir(ctx, id), { recursive: true, mode: 0o700 });
13
+ await withRuntimeFileLock(path, () => appendFile(path, `${JSON.stringify({ at: Date.now(), ...event })}\n`, {
14
+ encoding: 'utf8',
15
+ mode: 0o600,
16
+ }));
17
+ }
18
+ export async function saveAgentRecord(ctx, record) {
19
+ const persisted = {
20
+ id: record.id,
21
+ status: record.status,
22
+ description: record.description,
23
+ input: record.input,
24
+ createdAt: record.createdAt,
25
+ updatedAt: record.updatedAt,
26
+ ...(record.progress ? { progress: record.progress } : {}),
27
+ ...(record.worktree ? { worktree: record.worktree } : {}),
28
+ ...(record.result ? { result: record.result } : {}),
29
+ };
30
+ const recordDir = dir(ctx, record.id);
31
+ await withRuntimeFileLock(join(recordDir, '.record'), async () => {
32
+ await writeRuntimeJson(join(recordDir, 'state.json'), persisted);
33
+ if (record.history) {
34
+ await writeRuntimeJson(join(recordDir, 'history.json'), record.history);
35
+ }
36
+ });
37
+ }
38
+ export async function loadAgentRecord(ctx, id) {
39
+ const state = await readRuntimeJson(join(dir(ctx, id), 'state.json'), undefined);
40
+ if (!state)
41
+ return undefined;
42
+ const history = await readRuntimeJson(join(dir(ctx, id), 'history.json'), undefined);
43
+ return {
44
+ ...state,
45
+ status: state.status === 'running' ? 'interrupted' : state.status,
46
+ history,
47
+ };
48
+ }
49
+ export function getLiveAgentTask(state, id) {
50
+ return state?.tasks.get(id);
51
+ }
52
+ export async function stopAllAgentTasks(state) {
53
+ const running = [...state.tasks.values()].filter((t) => t.status === 'running');
54
+ running.forEach((t) => t.abort.abort());
55
+ await Promise.race([
56
+ Promise.allSettled(running.map((t) => t.completion)).then(() => undefined),
57
+ new Promise((r) => setTimeout(r, 5000)),
58
+ ]);
59
+ state.tasks.clear();
60
+ }
@@ -0,0 +1,113 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { appendFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { Message, ToolCallContext, ToolResult } from '../../types.js';
5
+ import type { AgentWorktree } from './worktree.js';
6
+ import {
7
+ readRuntimeJson,
8
+ sessionRuntimeDir,
9
+ withRuntimeFileLock,
10
+ writeRuntimeJson,
11
+ } from '../../session/runtimeStore.js';
12
+
13
+ export type AgentTaskStatus =
14
+ 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
15
+ export interface AgentTaskRecord {
16
+ id: string;
17
+ status: AgentTaskStatus;
18
+ description: string;
19
+ input: Record<string, unknown>;
20
+ createdAt: number;
21
+ updatedAt: number;
22
+ progress?: string;
23
+ worktree?: AgentWorktree;
24
+ result?: ToolResult;
25
+ history?: Message[];
26
+ }
27
+ export interface LiveAgentTask extends AgentTaskRecord {
28
+ abort: AbortController;
29
+ completion: Promise<void>;
30
+ events: EventEmitter;
31
+ }
32
+ export interface AgentTaskState {
33
+ tasks: Map<string, LiveAgentTask>;
34
+ }
35
+ export function createAgentTaskState(): AgentTaskState {
36
+ return { tasks: new Map() };
37
+ }
38
+ function dir(ctx: ToolCallContext, id: string) {
39
+ return join(sessionRuntimeDir(ctx), 'agents', id);
40
+ }
41
+ export async function appendAgentEvent(
42
+ ctx: ToolCallContext,
43
+ id: string,
44
+ event: Record<string, unknown>,
45
+ ): Promise<void> {
46
+ const path = join(dir(ctx, id), 'events.jsonl');
47
+ await mkdir(dir(ctx, id), { recursive: true, mode: 0o700 });
48
+ await withRuntimeFileLock(path, () =>
49
+ appendFile(path, `${JSON.stringify({ at: Date.now(), ...event })}\n`, {
50
+ encoding: 'utf8',
51
+ mode: 0o600,
52
+ }),
53
+ );
54
+ }
55
+ export async function saveAgentRecord(
56
+ ctx: ToolCallContext,
57
+ record: AgentTaskRecord,
58
+ ): Promise<void> {
59
+ const persisted: AgentTaskRecord = {
60
+ id: record.id,
61
+ status: record.status,
62
+ description: record.description,
63
+ input: record.input,
64
+ createdAt: record.createdAt,
65
+ updatedAt: record.updatedAt,
66
+ ...(record.progress ? { progress: record.progress } : {}),
67
+ ...(record.worktree ? { worktree: record.worktree } : {}),
68
+ ...(record.result ? { result: record.result } : {}),
69
+ };
70
+ const recordDir = dir(ctx, record.id);
71
+ await withRuntimeFileLock(join(recordDir, '.record'), async () => {
72
+ await writeRuntimeJson(join(recordDir, 'state.json'), persisted);
73
+ if (record.history) {
74
+ await writeRuntimeJson(join(recordDir, 'history.json'), record.history);
75
+ }
76
+ });
77
+ }
78
+ export async function loadAgentRecord(
79
+ ctx: ToolCallContext,
80
+ id: string,
81
+ ): Promise<AgentTaskRecord | undefined> {
82
+ const state = await readRuntimeJson<AgentTaskRecord | undefined>(
83
+ join(dir(ctx, id), 'state.json'),
84
+ undefined,
85
+ );
86
+ if (!state) return undefined;
87
+ const history = await readRuntimeJson<Message[] | undefined>(
88
+ join(dir(ctx, id), 'history.json'),
89
+ undefined,
90
+ );
91
+ return {
92
+ ...state,
93
+ status: state.status === 'running' ? 'interrupted' : state.status,
94
+ history,
95
+ };
96
+ }
97
+ export function getLiveAgentTask(
98
+ state: AgentTaskState | undefined,
99
+ id: string,
100
+ ): LiveAgentTask | undefined {
101
+ return state?.tasks.get(id);
102
+ }
103
+ export async function stopAllAgentTasks(state: AgentTaskState): Promise<void> {
104
+ const running = [...state.tasks.values()].filter(
105
+ (t) => t.status === 'running',
106
+ );
107
+ running.forEach((t) => t.abort.abort());
108
+ await Promise.race([
109
+ Promise.allSettled(running.map((t) => t.completion)).then(() => undefined),
110
+ new Promise<void>((r) => setTimeout(r, 5000)),
111
+ ]);
112
+ state.tasks.clear();
113
+ }
@@ -35,6 +35,9 @@ export const EXPLORE_ALLOWED_TOOLS = [
35
35
  'Read',
36
36
  'Glob',
37
37
  'Grep',
38
+ 'Snip',
39
+ 'LSP',
40
+ 'Skill',
38
41
  'WebSearch',
39
42
  'WebFetch',
40
43
  'WebBrowser',
@@ -42,6 +45,8 @@ export const EXPLORE_ALLOWED_TOOLS = [
42
45
  'ListMemories',
43
46
  'ReviewMemories',
44
47
  'SearchConversations',
48
+ 'TaskGet',
49
+ 'TaskList',
45
50
  ];
46
51
  /** 借鉴官方 EXPLORE_AGENT「READ-ONLY MODE」文案中文化。 */
47
52
  const EXPLORE_APPENDIX = `# 本次子任务类型:只读调研(explore)
@@ -53,6 +53,9 @@ export const EXPLORE_ALLOWED_TOOLS: readonly string[] = [
53
53
  'Read',
54
54
  'Glob',
55
55
  'Grep',
56
+ 'Snip',
57
+ 'LSP',
58
+ 'Skill',
56
59
  'WebSearch',
57
60
  'WebFetch',
58
61
  'WebBrowser',
@@ -60,6 +63,8 @@ export const EXPLORE_ALLOWED_TOOLS: readonly string[] = [
60
63
  'ListMemories',
61
64
  'ReviewMemories',
62
65
  'SearchConversations',
66
+ 'TaskGet',
67
+ 'TaskList',
63
68
  ];
64
69
 
65
70
  /** 借鉴官方 EXPLORE_AGENT「READ-ONLY MODE」文案中文化。 */
@@ -56,6 +56,8 @@ import { requestUserChoice } from '../../askBridge.js';
56
56
  import { classifyCommand } from '../../irreversible/rules.js';
57
57
  import { startBackgroundTask } from '../tasks/backgroundTaskService.js';
58
58
  import { processExecutionMetadata } from '../tasks/processEvidence.js';
59
+ import { permanentDeleteReason, } from '../shared/permanentDeleteGuard.js';
60
+ import { ToolOutputStore } from '../shared/toolOutputStore.js';
59
61
  // ---------------- 0. 常量 ----------------
60
62
  const DEFAULT_TIMEOUT_MS = 120_000; // 2 min
61
63
  const MAX_TIMEOUT_MS = 600_000; // 10 min
@@ -103,6 +105,16 @@ function detectWindowsBash() {
103
105
  return null;
104
106
  }
105
107
  const WIN_BASH = detectWindowsBash();
108
+ /**
109
+ * 本工具这一刻**真正**把命令交给谁执行。
110
+ *
111
+ * 单一出处:description(告诉模型该写哪种语法)与 permanentDeleteGuard(按哪套动词
112
+ * 匹配删除命令)必须看同一个判断,否则就会出现「description 说这是 cmd、守卫却按
113
+ * bash 匹配」这种两头对不上的裂缝 —— del / rd 会因此直接穿过全部三层防护。
114
+ */
115
+ function effectiveShellKind() {
116
+ return process.platform === 'win32' && !WIN_BASH ? 'cmd' : 'bash';
117
+ }
106
118
  /**
107
119
  * G1:stdout / stderr 即时缓冲上限(各自)。命令狂输出(如 cat 大文件 / yes)时,
108
120
  * 在最终统一截断之前先把内存封顶,防 OOM。取最终展示截断阈值的 2 倍 —— 正常输出不受影响,
@@ -309,8 +321,11 @@ Some commands return non-zero exit codes for informational (non-error) reasons.
309
321
  - \`test\` / \`[\` exit 1 → condition is false (the answer to a question, not a failure)
310
322
  Only exit codes ≥ 2 from these commands indicate a real failure. For all other commands, non-zero exit codes are treated as failures normally.
311
323
 
312
- # Safety
313
- The following command patterns are blocked at the tool level and will fail before execution (no need to try them):
324
+ # Safety - three levels, from strict to lenient
325
+
326
+ ## 1. Blocked outright (fails before execution - do NOT retry with variants)
327
+
328
+ Catastrophic commands (no legitimate use):
314
329
  - \`rm -rf /\` and variants targeting root, $HOME, ~, or system directories (/etc, /usr, /bin, /Windows, /Users, /home, ...)
315
330
  - Filesystem destruction: \`mkfs\`, \`format X:\`, \`dd of=/dev/sdX\`, redirects to raw block devices
316
331
  - Power management: \`shutdown\`, \`reboot\`, \`halt\`, \`poweroff\`, \`init 0/6\`
@@ -318,9 +333,37 @@ The following command patterns are blocked at the tool level and will fail befor
318
333
  - Pipe-to-shell from network: \`curl ... | sh\`, \`wget ... | bash\`, etc.
319
334
  - \`chmod 777 /\`, Windows full-disk \`del /s\` / \`rmdir /s\`, \`diskpart\`
320
335
 
321
- If you have a legitimate use case that requires one of the above patterns, ask the user to run the command themselves in their terminal — do not try to bypass the check.
336
+ Permanent deletion (bypasses the OS Trash / Recycle Bin - use the Trash tool instead):
337
+ - \`rm\`, \`unlink\`, \`rmdir\` (any shell-level file/dir deletion, any target)
338
+ - \`find ... -delete\`
339
+ - \`git clean -f\` / \`git clean --force\` (\`-n\` / \`--dry-run\` is fine)
340
+
341
+ To delete files or directories use the **Trash** tool (\`action: "move"\` - recoverable); to clean Git untracked files use Trash with \`action: "git-clean"\`.
342
+ Do NOT work around this by switching to \`find -delete\`, Python \`os.remove\`, Node \`fs.rm\`, or other shell variants - they are blocked the same way.
343
+ If a permanent deletion is truly required, ask the user to run it themselves in their terminal.
344
+
345
+ ## 2. Requires user confirmation (irreversible)
346
+
347
+ These have legitimate uses but destroy the last copy - execution pauses and asks the user to confirm. In non-interactive contexts (sub-agents, cron, batch mode) they are automatically cancelled.
348
+ - \`git push --force\` / \`-f\`, and \`git push origin +branch\` (+refspec = force push)
349
+ - \`git push --delete <branch>\` / \`git push :<branch>\` (deleting remote branches)
350
+ - \`git filter-branch\` / \`git filter-repo\` (rewriting history)
351
+ - \`git reflog expire --expire=now\` / \`git gc --prune=now\` (destroying reflog)
352
+ - \`shred -u\` / \`shred --remove\` (overwrite and delete - unrecoverable)
353
+ - SQL data destruction: \`DROP DATABASE\`, \`DROP TABLE\`, \`TRUNCATE TABLE\`
354
+
355
+ ## 3. Warning only (command still runs)
356
+
357
+ These execute normally; a \`⚠️ 警告:\` line is prepended to the output as a reminder. They are NOT blocked.
358
+ - \`git reset --hard\` (reflog keeps it recoverable)
359
+ - \`git push --force-with-lease\` (rejects if the remote has unseen commits - the safe force-push form)
360
+ - \`git checkout -- .\` / \`git restore .\` (discards uncommitted changes)
361
+ - \`git branch -D\`, \`git stash drop\` / \`git stash clear\` (local, recoverable via reflog in most cases)
362
+ - \`git commit --amend\`, \`git commit --no-verify\`
363
+ - \`DELETE FROM ...\` (SQL row deletion; DROP/TRUNCATE are in level 2)
364
+ - \`kubectl delete\`, \`terraform destroy\`
322
365
 
323
- Separately, Bash scans for common destructive-but-recoverable patterns (\`git reset --hard\`, \`git push -f\` / \`--force\` / \`--force-with-lease\`, \`git checkout .\`, \`git restore .\`, \`git clean -f\`, \`git stash drop/clear\`, \`git branch -D\`, \`git commit --amend\` / \`--no-verify\`, \`rm -rf <path>\`, \`DROP TABLE\`, \`TRUNCATE\`, \`DELETE FROM\`, \`kubectl delete\`, \`terraform destroy\`, etc.) and prepends a \`⚠️ 警告:\` line to the output. These commands are NOT blocked — the warning is informational. Treat it as a signal to double-check intent and surface the warning to the user when relevant.${process.platform !== 'win32'
366
+ Regular Git operations (commit, add, status, log, diff, pull, fetch, checkout <branch>, merge, rebase, etc.) run without extra restrictions.${process.platform !== 'win32'
324
367
  ? ''
325
368
  : WIN_BASH
326
369
  ? `
@@ -347,6 +390,13 @@ async function call(input, signal, context) {
347
390
  error: `安全检查拒绝执行:${forbiddenReason}\n命令:${command}`,
348
391
  };
349
392
  }
393
+ // 传**实际执行命令的 shell**,不是工具名:win32 上找不到 Git Bash 时下面走的是
394
+ // cmd.exe(见 WIN_BASH 探测),description 也会告诉模型「这里是 cmd」,模型于是写
395
+ // del / rd —— 按 'bash' 匹配的话这两个词一个都拦不住(黑名单与 irreversible 规则表
396
+ // 同样只认 POSIX 形态),等于三层全空。
397
+ const permanentDelete = permanentDeleteReason(command, effectiveShellKind());
398
+ if (permanentDelete)
399
+ return { ok: false, error: `永久删除检查拒绝执行:${permanentDelete}\n命令:${command}` };
350
400
  // 4a-1. 不可逆动作闸门:毁掉「最后一份副本」的命令要**停下来问人**。
351
401
  //
352
402
  // 与上面黑名单的分工:黑名单是「灾难且无正当用途」→ 直接拒;这里是
@@ -389,18 +439,19 @@ async function call(input, signal, context) {
389
439
  try {
390
440
  const cwd = context?.cwd ?? getWorkingDir();
391
441
  const task = await startBackgroundTask(WIN_BASH
392
- ? { command, executable: WIN_BASH, args: ['-c', command], cwd, env: sanitizedChildEnv(), sessionId: context?.sessionId, shellKind: 'bash' }
442
+ ? { command, executable: WIN_BASH, args: ['-c', command], cwd, env: sanitizedChildEnv(), sessionId: context?.sessionId, workspaceId: context?.workspaceId, shellKind: 'bash' }
393
443
  : {
394
444
  command: process.platform === 'win32' ? `chcp 65001 >nul & ${command}` : command,
395
445
  cwd,
396
446
  env: sanitizedChildEnv(),
397
447
  shell: true,
398
448
  sessionId: context?.sessionId,
449
+ workspaceId: context?.workspaceId,
399
450
  shellKind: 'bash',
400
451
  }, context?.sessionState?.backgroundTasks);
401
452
  return {
402
453
  ok: true,
403
- content: `${destructiveWarning ? `⚠️ 警告: ${destructiveWarning}\n` : ''}后台任务已启动。task_id=${task.id}\nlog=${task.logPath}\n使用 TaskOutput 增量读取输出,TaskStop 停止进程。`,
454
+ content: `${destructiveWarning ? `⚠️ 警告: ${destructiveWarning}\n` : ''}后台任务已启动。task_id=${task.id.replace(/^proc_/, '')}\nprocess_id=${task.id}\nlog=${task.logPath}\n使用 TaskOutput 增量读取输出,TaskStop 停止进程。`,
404
455
  metadata: processExecutionMetadata({ shell: 'bash', mode: 'background', command, status: 'started', taskId: task.id }),
405
456
  };
406
457
  }
@@ -414,6 +465,10 @@ async function call(input, signal, context) {
414
465
  // Windows 无 bash: cmd.exe,命令前缀 chcp 65001 强制 UTF-8 输出
415
466
  let stdout = '';
416
467
  let stderr = '';
468
+ const outputStore = new ToolOutputStore({
469
+ workspaceId: context?.workspaceId,
470
+ sessionId: context?.sessionId,
471
+ });
417
472
  let exitCode = null;
418
473
  let timedOut = false;
419
474
  let killedBySignal = false;
@@ -456,14 +511,16 @@ async function call(input, signal, context) {
456
511
  child.stdout?.setEncoding('utf8');
457
512
  child.stderr?.setEncoding('utf8');
458
513
  child.stdout?.on('data', (chunk) => {
459
- // G1:即时封顶,防止狂输出命令在最终截断前撑爆内存(OOM)。不强行 kill,
460
- // 让命令自己跑完或被 timeout 收走;超出上限的部分直接丢弃,最终仍按上限截断展示。
461
- if (stdout.length < MAX_STREAM_BUFFER_CHARS)
462
- stdout += chunk;
514
+ if (!outputStore.append('stdout', chunk)) {
515
+ child.stdout?.pause();
516
+ outputStore.onDrain(() => child.stdout?.resume());
517
+ }
463
518
  });
464
519
  child.stderr?.on('data', (chunk) => {
465
- if (stderr.length < MAX_STREAM_BUFFER_CHARS)
466
- stderr += chunk;
520
+ if (!outputStore.append('stderr', chunk)) {
521
+ child.stderr?.pause();
522
+ outputStore.onDrain(() => child.stderr?.resume());
523
+ }
467
524
  });
468
525
  child.on('error', (e) => {
469
526
  ac.signal.removeEventListener('abort', onKill);
@@ -489,6 +546,9 @@ async function call(input, signal, context) {
489
546
  clearTimeout(timer);
490
547
  signal?.removeEventListener('abort', onAbort);
491
548
  }
549
+ const storedOutput = await outputStore.finish();
550
+ stdout = storedOutput.stdout;
551
+ stderr = storedOutput.stderr;
492
552
  // 4b-2. ATTACH_IMAGE sentinel:脚本可以打一行 `<<<ATTACH_IMAGE:path>>>` 让模型亲眼看图。
493
553
  // 协议行**从 content 剥掉**(那是给宿主看的,不是给模型读的噪声);被拒的条目
494
554
  // 如实写进输出(静默丢弃会让脚本作者以为图发出去了,无从 debug)。
@@ -518,13 +578,18 @@ async function call(input, signal, context) {
518
578
  parts.push(`\n[被信号中断]`);
519
579
  }
520
580
  parts.push(`\n[exit code: ${exitCode === null ? 'killed' : exitCode}]`);
581
+ const outputReference = storedOutput.path
582
+ ? `\n[完整输出: ${storedOutput.path}; bytes=${storedOutput.bytes}; sha256=${storedOutput.sha256}; complete=${storedOutput.complete}]`
583
+ : '';
521
584
  let combinedOutput = parts.join('\n');
522
585
  // 4d. 大小兜底
523
- if (combinedOutput.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
586
+ if (combinedOutput.length + outputReference.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
587
+ const suffix = `\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,预览已截断)${outputReference}`;
524
588
  combinedOutput =
525
- combinedOutput.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
526
- `\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)`;
589
+ combinedOutput.slice(0, Math.max(0, DEFAULT_MAX_RESULT_SIZE_CHARS - suffix.length)) + suffix;
527
590
  }
591
+ else
592
+ combinedOutput += outputReference;
528
593
  // 4e. 中断 / 超时仍然算硬失败
529
594
  // 失败也带上已解析的附图:脚本挂掉前截的那张诊断图正是最该看的。
530
595
  if (timedOut || killedBySignal) {