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
@@ -0,0 +1,67 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { z } from 'zod';
4
+ import { toToolParameters } from '../../utils/zodToJson.js';
5
+ import { resolveToolPath } from '../shared/fileUtils.js';
6
+ import { recordRead } from '../shared/fileState.js';
7
+ const schema = z
8
+ .object({
9
+ file_path: z.string().min(1),
10
+ start_line: z.number().int().min(1),
11
+ end_line: z.number().int().min(1),
12
+ context: z.number().int().min(0).max(500).default(50).optional(),
13
+ })
14
+ .refine((v) => v.end_line >= v.start_line, {
15
+ message: 'end_line 必须大于等于 start_line',
16
+ });
17
+ async function call(input, cwd, state) {
18
+ const resolved = resolveToolPath(input.file_path, cwd);
19
+ if (!resolved.ok)
20
+ return resolved;
21
+ const before = input.context ?? 50, from = Math.max(1, input.start_line - before), to = input.end_line + before;
22
+ const lines = [];
23
+ let total = 0;
24
+ let hasMore = false;
25
+ try {
26
+ const source = createReadStream(resolved.resolvedPath, {
27
+ encoding: 'utf8',
28
+ });
29
+ const rl = createInterface({
30
+ input: source,
31
+ crlfDelay: Infinity,
32
+ });
33
+ for await (const line of rl) {
34
+ total++;
35
+ if (total >= from && total <= to)
36
+ lines.push(`${String(total).padStart(6)}\t${line}`);
37
+ if (total > to) {
38
+ hasMore = true;
39
+ rl.close();
40
+ source.destroy();
41
+ break;
42
+ }
43
+ }
44
+ }
45
+ catch (e) {
46
+ return { ok: false, error: `Snip 读取失败:${e.message}` };
47
+ }
48
+ recordRead(resolved.resolvedPath, {
49
+ partial: true,
50
+ offset: from - 1,
51
+ limit: Math.max(0, Math.min(to, total) - from + 1),
52
+ }, state);
53
+ return {
54
+ ok: true,
55
+ content: `${lines.join('\n')}\n\n[requested ${input.start_line}-${input.end_line}; shown ${from}-${Math.min(to, total)}; ${hasMore ? `more than ${to} total lines; continue at ${to + 1}` : `total ${total} lines`}]`,
56
+ };
57
+ }
58
+ export const snipTool = {
59
+ name: 'Snip',
60
+ description: 'Reads a precise line range from a potentially huge text file with bounded surrounding context, without loading the whole file into memory.',
61
+ inputSchema: schema,
62
+ parameters: toToolParameters(schema),
63
+ isReadOnly: true,
64
+ isConcurrencySafe: true,
65
+ call: (input) => call(input),
66
+ callWithContext: (input, _s, ctx) => call(input, ctx.cwd, ctx.sessionState?.fileReads),
67
+ };
@@ -0,0 +1,80 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { z } from 'zod';
4
+ import type { Tool, ToolCallContext, ToolResult } from '../../types.js';
5
+ import { toToolParameters } from '../../utils/zodToJson.js';
6
+ import { resolveToolPath } from '../shared/fileUtils.js';
7
+ import { recordRead, type FileReadState } from '../shared/fileState.js';
8
+
9
+ const schema = z
10
+ .object({
11
+ file_path: z.string().min(1),
12
+ start_line: z.number().int().min(1),
13
+ end_line: z.number().int().min(1),
14
+ context: z.number().int().min(0).max(500).default(50).optional(),
15
+ })
16
+ .refine((v) => v.end_line >= v.start_line, {
17
+ message: 'end_line 必须大于等于 start_line',
18
+ });
19
+ type Input = z.infer<typeof schema>;
20
+ async function call(
21
+ input: Input,
22
+ cwd?: string,
23
+ state?: FileReadState,
24
+ ): Promise<ToolResult> {
25
+ const resolved = resolveToolPath(input.file_path, cwd);
26
+ if (!resolved.ok) return resolved;
27
+ const before = input.context ?? 50,
28
+ from = Math.max(1, input.start_line - before),
29
+ to = input.end_line + before;
30
+ const lines: string[] = [];
31
+ let total = 0;
32
+ let hasMore = false;
33
+ try {
34
+ const source = createReadStream(resolved.resolvedPath, {
35
+ encoding: 'utf8',
36
+ });
37
+ const rl = createInterface({
38
+ input: source,
39
+ crlfDelay: Infinity,
40
+ });
41
+ for await (const line of rl) {
42
+ total++;
43
+ if (total >= from && total <= to)
44
+ lines.push(`${String(total).padStart(6)}\t${line}`);
45
+ if (total > to) {
46
+ hasMore = true;
47
+ rl.close();
48
+ source.destroy();
49
+ break;
50
+ }
51
+ }
52
+ } catch (e) {
53
+ return { ok: false, error: `Snip 读取失败:${(e as Error).message}` };
54
+ }
55
+ recordRead(
56
+ resolved.resolvedPath,
57
+ {
58
+ partial: true,
59
+ offset: from - 1,
60
+ limit: Math.max(0, Math.min(to, total) - from + 1),
61
+ },
62
+ state,
63
+ );
64
+ return {
65
+ ok: true,
66
+ content: `${lines.join('\n')}\n\n[requested ${input.start_line}-${input.end_line}; shown ${from}-${Math.min(to, total)}; ${hasMore ? `more than ${to} total lines; continue at ${to + 1}` : `total ${total} lines`}]`,
67
+ };
68
+ }
69
+ export const snipTool: Tool<Input> = {
70
+ name: 'Snip',
71
+ description:
72
+ 'Reads a precise line range from a potentially huge text file with bounded surrounding context, without loading the whole file into memory.',
73
+ inputSchema: schema,
74
+ parameters: toToolParameters(schema),
75
+ isReadOnly: true,
76
+ isConcurrencySafe: true,
77
+ call: (input) => call(input),
78
+ callWithContext: (input, _s, ctx) =>
79
+ call(input, ctx.cwd, ctx.sessionState?.fileReads),
80
+ };
@@ -1,17 +1,20 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { EventEmitter } from 'node:events';
4
- import { closeSync, createWriteStream, existsSync, openSync, readSync, statSync } from 'node:fs';
4
+ import { closeSync, createWriteStream, existsSync, openSync, readSync, statSync, } from 'node:fs';
5
5
  import { mkdir, readdir, rm, stat } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import { basename, join } from 'node:path';
8
8
  import { killTree } from '../shared/killTree.js';
9
- export function createBackgroundTaskState() { return { tasks: new Map() }; }
9
+ import { pruneRuntimeOutputs, registerRuntimeOutput, sessionRuntimeDir, unregisterRuntimeOutput, } from '../../session/runtimeStore.js';
10
+ export function createBackgroundTaskState() {
11
+ return { tasks: new Map() };
12
+ }
10
13
  const fallbackState = createBackgroundTaskState();
11
14
  /** Contains only states that currently own at least one running child. */
12
15
  const liveStates = new Set();
13
16
  const TASK_ROOT = join(homedir(), '.minimal-agent', 'tasks');
14
- const MAX_LOG_BYTES = 16 * 1024 * 1024;
17
+ const MAX_LOG_BYTES = 512 * 1024 * 1024;
15
18
  const MAX_COMPLETED_PER_STATE = 50;
16
19
  const MAX_GLOBAL_LOGS = 500;
17
20
  const LOG_RETENTION_MS = 7 * 24 * 60 * 60_000;
@@ -19,22 +22,32 @@ function safeSegment(value) {
19
22
  return value.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) || 'local';
20
23
  }
21
24
  export async function startBackgroundTask(spec, state = fallbackState) {
22
- const sessionDir = join(TASK_ROOT, safeSegment(spec.sessionId ?? 'local'));
25
+ const sessionDir = join(sessionRuntimeDir(spec), 'outputs');
23
26
  await mkdir(sessionDir, { recursive: true });
24
- const id = randomUUID();
27
+ const id = `proc_${randomUUID()}`;
25
28
  const logPath = join(sessionDir, `${id}.log`);
29
+ registerRuntimeOutput(logPath);
26
30
  const log = createWriteStream(logPath, { flags: 'wx', encoding: 'utf8' });
27
31
  const child = spec.executable
28
32
  ? spawn(spec.executable, spec.args ?? [], {
29
- cwd: spec.cwd, env: spec.env, stdio: ['ignore', 'pipe', 'pipe'],
30
- windowsHide: true, detached: process.platform !== 'win32',
33
+ cwd: spec.cwd,
34
+ env: spec.env,
35
+ stdio: ['ignore', 'pipe', 'pipe'],
36
+ windowsHide: true,
37
+ detached: process.platform !== 'win32',
31
38
  })
32
39
  : spawn(spec.command, {
33
- cwd: spec.cwd, env: spec.env, shell: spec.shell ?? true, stdio: ['ignore', 'pipe', 'pipe'],
34
- windowsHide: true, detached: process.platform !== 'win32',
40
+ cwd: spec.cwd,
41
+ env: spec.env,
42
+ shell: spec.shell ?? true,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ windowsHide: true,
45
+ detached: process.platform !== 'win32',
35
46
  });
36
47
  let settle;
37
- const completion = new Promise((resolvePromise) => { settle = resolvePromise; });
48
+ const completion = new Promise((resolvePromise) => {
49
+ settle = resolvePromise;
50
+ });
38
51
  const task = {
39
52
  id,
40
53
  command: spec.command,
@@ -50,9 +63,16 @@ export async function startBackgroundTask(spec, state = fallbackState) {
50
63
  state.tasks.set(id, task);
51
64
  liveStates.add(state);
52
65
  let written = 0;
66
+ let truncated = false;
67
+ const outputHash = createHash('sha256');
53
68
  const writeChunk = (source, value) => {
54
- if (written >= MAX_LOG_BYTES)
55
- return;
69
+ if (written >= MAX_LOG_BYTES) {
70
+ if (!truncated) {
71
+ truncated = true;
72
+ log.write('\n[log truncated at 512 MiB; complete=false]\n');
73
+ }
74
+ return true;
75
+ }
56
76
  const raw = Buffer.from(value);
57
77
  const chunk = source === 'stderr'
58
78
  ? Buffer.concat([Buffer.from('[stderr] '), raw])
@@ -60,12 +80,26 @@ export async function startBackgroundTask(spec, state = fallbackState) {
60
80
  const remaining = MAX_LOG_BYTES - written;
61
81
  const selected = chunk.subarray(0, remaining);
62
82
  written += selected.length;
63
- log.write(selected, () => task.events.emit('change'));
64
- if (written >= MAX_LOG_BYTES)
65
- log.write('\n[log truncated at 16 MiB]\n');
83
+ outputHash.update(selected);
84
+ const accepted = log.write(selected, () => task.events.emit('change'));
85
+ if (selected.length < chunk.length) {
86
+ truncated = true;
87
+ log.write('\n[log truncated at 512 MiB; complete=false]\n');
88
+ }
89
+ return accepted;
66
90
  };
67
- child.stdout?.on('data', (chunk) => writeChunk('stdout', chunk));
68
- child.stderr?.on('data', (chunk) => writeChunk('stderr', chunk));
91
+ child.stdout?.on('data', (chunk) => {
92
+ if (!writeChunk('stdout', chunk)) {
93
+ child.stdout?.pause();
94
+ log.once('drain', () => child.stdout?.resume());
95
+ }
96
+ });
97
+ child.stderr?.on('data', (chunk) => {
98
+ if (!writeChunk('stderr', chunk)) {
99
+ child.stderr?.pause();
100
+ log.once('drain', () => child.stderr?.resume());
101
+ }
102
+ });
69
103
  let finalized = false;
70
104
  const finalize = (status, code, error) => {
71
105
  if (finalized)
@@ -75,7 +109,12 @@ export async function startBackgroundTask(spec, state = fallbackState) {
75
109
  task.finishedAt = Date.now();
76
110
  task.exitCode = code;
77
111
  task.child = undefined;
112
+ task.outputBytes = written;
113
+ task.outputSha256 = outputHash.digest('hex');
114
+ task.complete = !truncated;
78
115
  log.end(error ? `[stderr] Failed to start: ${error}\n` : undefined, () => {
116
+ unregisterRuntimeOutput(logPath);
117
+ void pruneRuntimeOutputs();
79
118
  task.events.emit('change');
80
119
  settle();
81
120
  pruneCompletedTasks(state);
@@ -87,16 +126,21 @@ export async function startBackgroundTask(spec, state = fallbackState) {
87
126
  finalize('failed', null, error.message);
88
127
  });
89
128
  child.once('close', (code) => {
90
- finalize(task.status === 'stopped' ? 'stopped' : code === 0 ? 'completed' : 'failed', code);
129
+ finalize(task.status === 'stopped'
130
+ ? 'stopped'
131
+ : code === 0
132
+ ? 'completed'
133
+ : 'failed', code);
91
134
  });
92
135
  void pruneTaskLogs();
93
136
  return task;
94
137
  }
95
138
  export function getBackgroundTask(id, state = fallbackState) {
96
- return state.tasks.get(id);
139
+ return (state.tasks.get(id) ??
140
+ state.tasks.get(id.startsWith('proc_') ? id.slice(5) : `proc_${id}`));
97
141
  }
98
142
  export function stopBackgroundTask(id, state = fallbackState) {
99
- const task = state.tasks.get(id);
143
+ const task = getBackgroundTask(id, state);
100
144
  if (!task)
101
145
  return undefined;
102
146
  if (task.status === 'running' && task.child) {
@@ -148,9 +192,12 @@ function completeUtf8PrefixLength(bytes) {
148
192
  if (index < 0)
149
193
  return bytes.length;
150
194
  const lead = bytes[index];
151
- const expectedLength = lead >= 0xf0 && lead <= 0xf7 ? 4
152
- : lead >= 0xe0 && lead <= 0xef ? 3
153
- : lead >= 0xc0 && lead <= 0xdf ? 2
195
+ const expectedLength = lead >= 0xf0 && lead <= 0xf7
196
+ ? 4
197
+ : lead >= 0xe0 && lead <= 0xef
198
+ ? 3
199
+ : lead >= 0xc0 && lead <= 0xdf
200
+ ? 2
154
201
  : 1;
155
202
  return expectedLength > continuationBytes + 1 ? index : bytes.length;
156
203
  }
@@ -216,9 +263,13 @@ async function pruneTaskLogsInner() {
216
263
  catch { }
217
264
  }
218
265
  function pruneTaskLogs() {
219
- prunePromise ??= pruneTaskLogsInner().finally(() => { prunePromise = undefined; });
266
+ prunePromise ??= pruneTaskLogsInner().finally(() => {
267
+ prunePromise = undefined;
268
+ });
220
269
  return prunePromise;
221
270
  }
222
- process.once('exit', () => { for (const state of liveStates)
223
- for (const task of state.tasks.values())
224
- stopBackgroundTask(task.id, state); });
271
+ process.once('exit', () => {
272
+ for (const state of liveStates)
273
+ for (const task of state.tasks.values())
274
+ stopBackgroundTask(task.id, state);
275
+ });
@@ -1,14 +1,28 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { spawn, type ChildProcess } from 'node:child_process';
3
3
  import { EventEmitter } from 'node:events';
4
- import { closeSync, createWriteStream, existsSync, openSync, readSync, statSync } from 'node:fs';
4
+ import {
5
+ closeSync,
6
+ createWriteStream,
7
+ existsSync,
8
+ openSync,
9
+ readSync,
10
+ statSync,
11
+ } from 'node:fs';
5
12
  import { mkdir, readdir, rm, stat } from 'node:fs/promises';
6
13
  import { homedir } from 'node:os';
7
14
  import { basename, join } from 'node:path';
8
15
 
9
16
  import { killTree } from '../shared/killTree.js';
17
+ import {
18
+ pruneRuntimeOutputs,
19
+ registerRuntimeOutput,
20
+ sessionRuntimeDir,
21
+ unregisterRuntimeOutput,
22
+ } from '../../session/runtimeStore.js';
10
23
 
11
- export type BackgroundTaskStatus = 'running' | 'completed' | 'failed' | 'stopped';
24
+ export type BackgroundTaskStatus =
25
+ 'running' | 'completed' | 'failed' | 'stopped';
12
26
  export interface BackgroundTask {
13
27
  id: string;
14
28
  command: string;
@@ -18,19 +32,26 @@ export interface BackgroundTask {
18
32
  finishedAt?: number;
19
33
  status: BackgroundTaskStatus;
20
34
  exitCode?: number | null;
35
+ outputBytes?: number;
36
+ outputSha256?: string;
37
+ complete?: boolean;
21
38
  child?: ChildProcess;
22
39
  completion: Promise<void>;
23
40
  events: EventEmitter;
24
41
  shell: 'bash' | 'powershell';
25
42
  }
26
- export interface BackgroundTaskState { tasks: Map<string, BackgroundTask> }
27
- export function createBackgroundTaskState(): BackgroundTaskState { return { tasks: new Map() }; }
43
+ export interface BackgroundTaskState {
44
+ tasks: Map<string, BackgroundTask>;
45
+ }
46
+ export function createBackgroundTaskState(): BackgroundTaskState {
47
+ return { tasks: new Map() };
48
+ }
28
49
 
29
50
  const fallbackState = createBackgroundTaskState();
30
51
  /** Contains only states that currently own at least one running child. */
31
52
  const liveStates = new Set<BackgroundTaskState>();
32
53
  const TASK_ROOT = join(homedir(), '.minimal-agent', 'tasks');
33
- const MAX_LOG_BYTES = 16 * 1024 * 1024;
54
+ const MAX_LOG_BYTES = 512 * 1024 * 1024;
34
55
  const MAX_COMPLETED_PER_STATE = 50;
35
56
  const MAX_GLOBAL_LOGS = 500;
36
57
  const LOG_RETENTION_MS = 7 * 24 * 60 * 60_000;
@@ -47,6 +68,7 @@ export interface BackgroundSpawnSpec {
47
68
  args?: string[];
48
69
  shell?: boolean;
49
70
  sessionId?: string;
71
+ workspaceId?: string;
50
72
  shellKind?: 'bash' | 'powershell';
51
73
  }
52
74
 
@@ -54,23 +76,33 @@ export async function startBackgroundTask(
54
76
  spec: BackgroundSpawnSpec,
55
77
  state: BackgroundTaskState = fallbackState,
56
78
  ): Promise<BackgroundTask> {
57
- const sessionDir = join(TASK_ROOT, safeSegment(spec.sessionId ?? 'local'));
79
+ const sessionDir = join(sessionRuntimeDir(spec), 'outputs');
58
80
  await mkdir(sessionDir, { recursive: true });
59
- const id = randomUUID();
81
+ const id = `proc_${randomUUID()}`;
60
82
  const logPath = join(sessionDir, `${id}.log`);
83
+ registerRuntimeOutput(logPath);
61
84
  const log = createWriteStream(logPath, { flags: 'wx', encoding: 'utf8' });
62
85
  const child = spec.executable
63
86
  ? spawn(spec.executable, spec.args ?? [], {
64
- cwd: spec.cwd, env: spec.env, stdio: ['ignore', 'pipe', 'pipe'],
65
- windowsHide: true, detached: process.platform !== 'win32',
87
+ cwd: spec.cwd,
88
+ env: spec.env,
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ windowsHide: true,
91
+ detached: process.platform !== 'win32',
66
92
  })
67
93
  : spawn(spec.command, {
68
- cwd: spec.cwd, env: spec.env, shell: spec.shell ?? true, stdio: ['ignore', 'pipe', 'pipe'],
69
- windowsHide: true, detached: process.platform !== 'win32',
94
+ cwd: spec.cwd,
95
+ env: spec.env,
96
+ shell: spec.shell ?? true,
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ windowsHide: true,
99
+ detached: process.platform !== 'win32',
70
100
  });
71
101
 
72
102
  let settle!: () => void;
73
- const completion = new Promise<void>((resolvePromise) => { settle = resolvePromise; });
103
+ const completion = new Promise<void>((resolvePromise) => {
104
+ settle = resolvePromise;
105
+ });
74
106
  const task: BackgroundTask = {
75
107
  id,
76
108
  command: spec.command,
@@ -87,58 +119,120 @@ export async function startBackgroundTask(
87
119
  liveStates.add(state);
88
120
 
89
121
  let written = 0;
90
- const writeChunk = (source: 'stdout' | 'stderr', value: Buffer | string) => {
91
- if (written >= MAX_LOG_BYTES) return;
122
+ let truncated = false;
123
+ const outputHash = createHash('sha256');
124
+ const writeChunk = (
125
+ source: 'stdout' | 'stderr',
126
+ value: Buffer | string,
127
+ ): boolean => {
128
+ if (written >= MAX_LOG_BYTES) {
129
+ if (!truncated) {
130
+ truncated = true;
131
+ log.write('\n[log truncated at 512 MiB; complete=false]\n');
132
+ }
133
+ return true;
134
+ }
92
135
  const raw = Buffer.from(value);
93
- const chunk = source === 'stderr'
94
- ? Buffer.concat([Buffer.from('[stderr] '), raw])
95
- : raw;
136
+ const chunk =
137
+ source === 'stderr'
138
+ ? Buffer.concat([Buffer.from('[stderr] '), raw])
139
+ : raw;
96
140
  const remaining = MAX_LOG_BYTES - written;
97
141
  const selected = chunk.subarray(0, remaining);
98
142
  written += selected.length;
99
- log.write(selected, () => task.events.emit('change'));
100
- if (written >= MAX_LOG_BYTES) log.write('\n[log truncated at 16 MiB]\n');
143
+ outputHash.update(selected);
144
+ const accepted = log.write(selected, () => task.events.emit('change'));
145
+ if (selected.length < chunk.length) {
146
+ truncated = true;
147
+ log.write('\n[log truncated at 512 MiB; complete=false]\n');
148
+ }
149
+ return accepted;
101
150
  };
102
- child.stdout?.on('data', (chunk) => writeChunk('stdout', chunk));
103
- child.stderr?.on('data', (chunk) => writeChunk('stderr', chunk));
151
+ child.stdout?.on('data', (chunk) => {
152
+ if (!writeChunk('stdout', chunk)) {
153
+ child.stdout?.pause();
154
+ log.once('drain', () => child.stdout?.resume());
155
+ }
156
+ });
157
+ child.stderr?.on('data', (chunk) => {
158
+ if (!writeChunk('stderr', chunk)) {
159
+ child.stderr?.pause();
160
+ log.once('drain', () => child.stderr?.resume());
161
+ }
162
+ });
104
163
  let finalized = false;
105
- const finalize = (status: BackgroundTaskStatus, code: number | null, error?: string) => {
164
+ const finalize = (
165
+ status: BackgroundTaskStatus,
166
+ code: number | null,
167
+ error?: string,
168
+ ) => {
106
169
  if (finalized) return;
107
170
  finalized = true;
108
- task.status = status; task.finishedAt = Date.now(); task.exitCode = code; task.child = undefined;
171
+ task.status = status;
172
+ task.finishedAt = Date.now();
173
+ task.exitCode = code;
174
+ task.child = undefined;
175
+ task.outputBytes = written;
176
+ task.outputSha256 = outputHash.digest('hex');
177
+ task.complete = !truncated;
109
178
  log.end(error ? `[stderr] Failed to start: ${error}\n` : undefined, () => {
179
+ unregisterRuntimeOutput(logPath);
180
+ void pruneRuntimeOutputs();
110
181
  task.events.emit('change');
111
182
  settle();
112
183
  pruneCompletedTasks(state);
113
- if (![...state.tasks.values()].some((item) => item.status === 'running')) liveStates.delete(state);
184
+ if (![...state.tasks.values()].some((item) => item.status === 'running'))
185
+ liveStates.delete(state);
114
186
  });
115
187
  };
116
188
  child.once('error', (error) => {
117
189
  finalize('failed', null, error.message);
118
190
  });
119
191
  child.once('close', (code) => {
120
- finalize(task.status === 'stopped' ? 'stopped' : code === 0 ? 'completed' : 'failed', code);
192
+ finalize(
193
+ task.status === 'stopped'
194
+ ? 'stopped'
195
+ : code === 0
196
+ ? 'completed'
197
+ : 'failed',
198
+ code,
199
+ );
121
200
  });
122
201
  void pruneTaskLogs();
123
202
  return task;
124
203
  }
125
204
 
126
- export function getBackgroundTask(id: string, state: BackgroundTaskState = fallbackState): BackgroundTask | undefined {
127
- return state.tasks.get(id);
205
+ export function getBackgroundTask(
206
+ id: string,
207
+ state: BackgroundTaskState = fallbackState,
208
+ ): BackgroundTask | undefined {
209
+ return (
210
+ state.tasks.get(id) ??
211
+ state.tasks.get(id.startsWith('proc_') ? id.slice(5) : `proc_${id}`)
212
+ );
128
213
  }
129
214
 
130
- export function stopBackgroundTask(id: string, state: BackgroundTaskState = fallbackState): BackgroundTask | undefined {
131
- const task = state.tasks.get(id);
215
+ export function stopBackgroundTask(
216
+ id: string,
217
+ state: BackgroundTaskState = fallbackState,
218
+ ): BackgroundTask | undefined {
219
+ const task = getBackgroundTask(id, state);
132
220
  if (!task) return undefined;
133
221
  if (task.status === 'running' && task.child) {
134
- task.status = 'stopped'; task.finishedAt = Date.now();
222
+ task.status = 'stopped';
223
+ task.finishedAt = Date.now();
135
224
  killTree(task.child);
136
225
  }
137
226
  return task;
138
227
  }
139
228
 
140
- export function readTaskLog(task: BackgroundTask, offset = 0, limit = 30_000): { text: string; nextOffset: number; totalBytes: number } {
141
- if (!existsSync(task.logPath)) return { text: '', nextOffset: offset, totalBytes: 0 };
229
+ export function readTaskLog(
230
+ task: BackgroundTask,
231
+ offset = 0,
232
+ limit = 30_000,
233
+ ): { text: string; nextOffset: number; totalBytes: number } {
234
+ if (!existsSync(task.logPath))
235
+ return { text: '', nextOffset: offset, totalBytes: 0 };
142
236
  const totalBytes = statSync(task.logPath).size;
143
237
  const start = Math.max(0, Math.min(offset, totalBytes));
144
238
  const requestedLength = Math.max(0, Math.min(limit, totalBytes - start));
@@ -155,7 +249,8 @@ export function readTaskLog(task: BackgroundTask, offset = 0, limit = 30_000): {
155
249
  closeSync(descriptor);
156
250
  }
157
251
  let pageLength = Math.min(requestedLength, bytesRead);
158
- while (pageLength < bytesRead && (bytes[pageLength]! & 0xc0) === 0x80) pageLength += 1;
252
+ while (pageLength < bytesRead && (bytes[pageLength]! & 0xc0) === 0x80)
253
+ pageLength += 1;
159
254
  const validLength = completeUtf8PrefixLength(bytes.subarray(0, pageLength));
160
255
  return {
161
256
  text: bytes.subarray(0, validLength).toString('utf8'),
@@ -174,14 +269,20 @@ function completeUtf8PrefixLength(bytes: Uint8Array): number {
174
269
  }
175
270
  if (index < 0) return bytes.length;
176
271
  const lead = bytes[index]!;
177
- const expectedLength = lead >= 0xf0 && lead <= 0xf7 ? 4
178
- : lead >= 0xe0 && lead <= 0xef ? 3
179
- : lead >= 0xc0 && lead <= 0xdf ? 2
180
- : 1;
272
+ const expectedLength =
273
+ lead >= 0xf0 && lead <= 0xf7
274
+ ? 4
275
+ : lead >= 0xe0 && lead <= 0xef
276
+ ? 3
277
+ : lead >= 0xc0 && lead <= 0xdf
278
+ ? 2
279
+ : 1;
181
280
  return expectedLength > continuationBytes + 1 ? index : bytes.length;
182
281
  }
183
282
 
184
- export async function stopAllBackgroundTasks(state?: BackgroundTaskState): Promise<void> {
283
+ export async function stopAllBackgroundTasks(
284
+ state?: BackgroundTaskState,
285
+ ): Promise<void> {
185
286
  const states = state ? [state] : [...liveStates];
186
287
  const completions: Promise<void>[] = [];
187
288
  for (const item of states) {
@@ -202,7 +303,9 @@ export async function stopAllBackgroundTasks(state?: BackgroundTaskState): Promi
202
303
  }
203
304
 
204
305
  /** Release all process/task resources owned by a disposed host session. */
205
- export async function disposeBackgroundTaskState(state: BackgroundTaskState): Promise<void> {
306
+ export async function disposeBackgroundTaskState(
307
+ state: BackgroundTaskState,
308
+ ): Promise<void> {
206
309
  await stopAllBackgroundTasks(state);
207
310
  state.tasks.clear();
208
311
  liveStates.delete(state);
@@ -211,7 +314,9 @@ export async function disposeBackgroundTaskState(state: BackgroundTaskState): Pr
211
314
  function pruneCompletedTasks(state: BackgroundTaskState): void {
212
315
  const completed = [...state.tasks.values()]
213
316
  .filter((task) => task.status !== 'running')
214
- .sort((a, b) => (b.finishedAt ?? b.startedAt) - (a.finishedAt ?? a.startedAt));
317
+ .sort(
318
+ (a, b) => (b.finishedAt ?? b.startedAt) - (a.finishedAt ?? a.startedAt),
319
+ );
215
320
  for (const task of completed.slice(MAX_COMPLETED_PER_STATE)) {
216
321
  state.tasks.delete(task.id);
217
322
  task.events.removeAllListeners();
@@ -233,21 +338,32 @@ async function pruneTaskLogsInner(): Promise<void> {
233
338
  }
234
339
  files.sort((a, b) => b.mtime - a.mtime);
235
340
  const activeLogs = new Set(
236
- [...liveStates].flatMap((state) => [...state.tasks.values()]
237
- .filter((task) => task.status === 'running')
238
- .map((task) => task.logPath)),
341
+ [...liveStates].flatMap((state) =>
342
+ [...state.tasks.values()]
343
+ .filter((task) => task.status === 'running')
344
+ .map((task) => task.logPath),
345
+ ),
239
346
  );
240
347
  const completedFiles = files.filter((item) => !activeLogs.has(item.path));
241
348
  const cutoff = Date.now() - LOG_RETENTION_MS;
242
- await Promise.all(completedFiles
243
- .filter((item, index) => index >= MAX_GLOBAL_LOGS || item.mtime < cutoff)
244
- .map((item) => rm(item.path, { force: true })));
349
+ await Promise.all(
350
+ completedFiles
351
+ .filter(
352
+ (item, index) => index >= MAX_GLOBAL_LOGS || item.mtime < cutoff,
353
+ )
354
+ .map((item) => rm(item.path, { force: true })),
355
+ );
245
356
  } catch {}
246
357
  }
247
358
 
248
359
  function pruneTaskLogs(): Promise<void> {
249
- prunePromise ??= pruneTaskLogsInner().finally(() => { prunePromise = undefined; });
360
+ prunePromise ??= pruneTaskLogsInner().finally(() => {
361
+ prunePromise = undefined;
362
+ });
250
363
  return prunePromise;
251
364
  }
252
365
 
253
- process.once('exit', () => { for (const state of liveStates) for (const task of state.tasks.values()) stopBackgroundTask(task.id, state); });
366
+ process.once('exit', () => {
367
+ for (const state of liveStates)
368
+ for (const task of state.tasks.values()) stopBackgroundTask(task.id, state);
369
+ });