@chuckssmith/agentloom 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/collect.js +2 -3
- package/dist/commands/crew.js +11 -10
- package/dist/commands/logs.js +4 -2
- package/dist/commands/reset.js +27 -2
- package/dist/commands/setup.js +8 -1
- package/dist/commands/stop.js +3 -0
- package/dist/commands/watch.js +61 -18
- package/dist/state/session.d.ts +0 -10
- package/dist/state/session.js +12 -20
- package/dist/team/orchestrator.d.ts +1 -1
- package/dist/team/orchestrator.js +13 -12
- package/package.json +1 -1
package/dist/commands/collect.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile, readdir, writeFile } from 'fs/promises';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { spawnSync } from 'child_process';
|
|
5
|
-
import { STATE_DIR, readSession
|
|
5
|
+
import { STATE_DIR, readSession } from '../state/session.js';
|
|
6
6
|
const WORKERS_DIR = join(STATE_DIR, 'workers');
|
|
7
7
|
export async function collect(args) {
|
|
8
8
|
if (!existsSync(WORKERS_DIR)) {
|
|
@@ -10,7 +10,6 @@ export async function collect(args) {
|
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
12
|
const session = await readSession();
|
|
13
|
-
const tasks = await readTasks();
|
|
14
13
|
const files = await readdir(WORKERS_DIR);
|
|
15
14
|
const resultFiles = files.filter(f => f.endsWith('-result.md')).sort();
|
|
16
15
|
if (resultFiles.length === 0) {
|
|
@@ -34,7 +33,7 @@ export async function collect(args) {
|
|
|
34
33
|
const synthesize = !args.includes('--no-ai');
|
|
35
34
|
let synthesis = '';
|
|
36
35
|
if (synthesize) {
|
|
37
|
-
console.log('\nSynthesizing with Claude...');
|
|
36
|
+
console.log('\nSynthesizing with Claude... (may take up to 60s)');
|
|
38
37
|
const prompt = `You are summarizing the results of a multi-agent crew that worked on this task:
|
|
39
38
|
|
|
40
39
|
"${taskDesc}"
|
package/dist/commands/crew.js
CHANGED
|
@@ -74,6 +74,7 @@ export async function crew(args) {
|
|
|
74
74
|
const watchAfter = args.includes('--watch');
|
|
75
75
|
const filteredArgs = args.filter(a => !['--dry-run', '--serial', '--watch'].includes(a));
|
|
76
76
|
const config = await loadConfig();
|
|
77
|
+
const forcePermissions = config.dangerouslySkipPermissions === true;
|
|
77
78
|
const { specs, task } = parseWorkerSpec(filteredArgs, config.workers, config.agentType);
|
|
78
79
|
const totalWorkers = specs.reduce((sum, s) => sum + s.count, 0);
|
|
79
80
|
const slug = task.slice(0, 30).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
@@ -99,23 +100,23 @@ export async function crew(args) {
|
|
|
99
100
|
const mode = serial ? 'serial' : useTmux ? 'tmux' : 'background processes';
|
|
100
101
|
console.log(`Mode: ${mode}\n`);
|
|
101
102
|
const session = await initSession(task, totalWorkers);
|
|
102
|
-
const contextPath = await writeContextSnapshot(slug, task);
|
|
103
|
+
const contextPath = await writeContextSnapshot(slug, session.id, task);
|
|
103
104
|
const tasks = await decomposeTasks(task, specs);
|
|
104
105
|
console.log(`Session: ${session.id}`);
|
|
105
106
|
console.log(`Tasks: ${tasks.length} created`);
|
|
106
107
|
console.log(`Context: ${contextPath}\n`);
|
|
107
108
|
if (serial) {
|
|
108
|
-
await launchSerial(session.id, specs, tasks, contextPath);
|
|
109
|
+
await launchSerial(session.id, specs, tasks, contextPath, forcePermissions);
|
|
109
110
|
console.log(`\nAll workers finished. Run: loom collect`);
|
|
110
111
|
}
|
|
111
112
|
else if (useTmux) {
|
|
112
|
-
await launchTmux(session.id, specs, tasks, contextPath);
|
|
113
|
+
await launchTmux(session.id, specs, tasks, contextPath, forcePermissions);
|
|
113
114
|
console.log(`\nWorkers launched. Monitor with:`);
|
|
114
115
|
console.log(` loom status`);
|
|
115
116
|
console.log(` loom stop (kill all workers)`);
|
|
116
117
|
}
|
|
117
118
|
else {
|
|
118
|
-
await launchBackground(session.id, specs, tasks, contextPath);
|
|
119
|
+
await launchBackground(session.id, specs, tasks, contextPath, forcePermissions);
|
|
119
120
|
if (watchAfter) {
|
|
120
121
|
console.log();
|
|
121
122
|
await watch([]);
|
|
@@ -128,7 +129,7 @@ export async function crew(args) {
|
|
|
128
129
|
}
|
|
129
130
|
console.log(`State dir: ${STATE_DIR}/`);
|
|
130
131
|
}
|
|
131
|
-
async function launchSerial(sessionId, specs, tasks, contextPath) {
|
|
132
|
+
async function launchSerial(sessionId, specs, tasks, contextPath, forcePermissions = false) {
|
|
132
133
|
await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
|
|
133
134
|
let workerIdx = 0;
|
|
134
135
|
for (const spec of specs) {
|
|
@@ -144,7 +145,7 @@ async function launchSerial(sessionId, specs, tasks, contextPath) {
|
|
|
144
145
|
console.log(` → Worker ${workerId} (${agentType}) starting...`);
|
|
145
146
|
const claudeArgs = [
|
|
146
147
|
'--print',
|
|
147
|
-
...(!READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
|
|
148
|
+
...(forcePermissions || !READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
|
|
148
149
|
'-p',
|
|
149
150
|
prompt,
|
|
150
151
|
];
|
|
@@ -173,7 +174,7 @@ async function launchSerial(sessionId, specs, tasks, contextPath) {
|
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
|
-
async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
177
|
+
async function launchBackground(sessionId, specs, tasks, contextPath, forcePermissions = false) {
|
|
177
178
|
await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
|
|
178
179
|
let workerIdx = 0;
|
|
179
180
|
for (const spec of specs) {
|
|
@@ -189,7 +190,7 @@ async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
|
189
190
|
// Build args declaratively — no positional splicing
|
|
190
191
|
const claudeArgs = [
|
|
191
192
|
'--print',
|
|
192
|
-
...(!READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
|
|
193
|
+
...(forcePermissions || !READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
|
|
193
194
|
'-p',
|
|
194
195
|
prompt,
|
|
195
196
|
];
|
|
@@ -212,7 +213,7 @@ async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
|
212
213
|
}
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
|
-
async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
216
|
+
async function launchTmux(sessionId, specs, tasks, contextPath, forcePermissions = false) {
|
|
216
217
|
const tmuxSession = `loom-${sessionId}`;
|
|
217
218
|
// Check for session name collision
|
|
218
219
|
const existing = spawnSync('tmux', ['has-session', '-t', tmuxSession], { stdio: 'ignore' });
|
|
@@ -246,7 +247,7 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
|
246
247
|
`process.env.AGENTLOOM_WORKER_ID = ${JSON.stringify(workerId)}`,
|
|
247
248
|
`process.env.AGENTLOOM_SESSION = ${JSON.stringify(sessionId)}`,
|
|
248
249
|
`const prompt = readFileSync(${JSON.stringify(promptFile)}, 'utf8')`,
|
|
249
|
-
`const args = ['--print', ${!READ_ONLY_ROLES.has(agentType) ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
|
|
250
|
+
`const args = ['--print', ${(forcePermissions || !READ_ONLY_ROLES.has(agentType)) ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
|
|
250
251
|
`const r = spawnSync('claude', args, { stdio: 'inherit' })`,
|
|
251
252
|
`console.log('[worker done]')`,
|
|
252
253
|
`process.exit(r.status ?? 0)`,
|
package/dist/commands/logs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, readdir } from 'fs/promises';
|
|
2
|
-
import { join } from 'path';
|
|
2
|
+
import { join, basename } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { STATE_DIR } from '../state/session.js';
|
|
5
5
|
const WORKERS_DIR = join(STATE_DIR, 'workers');
|
|
@@ -8,7 +8,9 @@ export async function logs(args) {
|
|
|
8
8
|
console.log('No worker logs found. Run: loom crew "<task>"');
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
// Sanitize workerId to prevent path traversal
|
|
12
|
+
const rawId = args[0];
|
|
13
|
+
const workerId = rawId ? basename(rawId) : undefined;
|
|
12
14
|
if (workerId) {
|
|
13
15
|
// Show log for a specific worker
|
|
14
16
|
const logFile = join(WORKERS_DIR, `${workerId}.log`);
|
package/dist/commands/reset.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { rm } from 'fs/promises';
|
|
1
|
+
import { rm, readdir, readFile } from 'fs/promises';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
3
4
|
import { STATE_DIR } from '../state/session.js';
|
|
5
|
+
const WORKERS_DIR = join(STATE_DIR, 'workers');
|
|
4
6
|
export async function reset(args) {
|
|
5
7
|
if (!existsSync(STATE_DIR)) {
|
|
6
8
|
console.log('Nothing to reset.');
|
|
@@ -8,10 +10,33 @@ export async function reset(args) {
|
|
|
8
10
|
}
|
|
9
11
|
const force = args.includes('--force') || args.includes('-f');
|
|
10
12
|
if (!force) {
|
|
11
|
-
console.log(`This will delete all session state in ${STATE_DIR}/`);
|
|
13
|
+
console.log(`This will kill running workers and delete all session state in ${STATE_DIR}/`);
|
|
12
14
|
console.log('Run with --force to confirm: loom reset --force');
|
|
13
15
|
return;
|
|
14
16
|
}
|
|
17
|
+
// Kill any live workers before deleting their PID files
|
|
18
|
+
if (existsSync(WORKERS_DIR)) {
|
|
19
|
+
try {
|
|
20
|
+
const files = await readdir(WORKERS_DIR);
|
|
21
|
+
const pidFiles = files.filter(f => f.endsWith('.pid'));
|
|
22
|
+
for (const pidFile of pidFiles) {
|
|
23
|
+
const pid = parseInt(await readFile(join(WORKERS_DIR, pidFile), 'utf8').catch(() => ''), 10);
|
|
24
|
+
if (!isNaN(pid)) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 'SIGTERM');
|
|
27
|
+
const workerId = pidFile.replace('.pid', '');
|
|
28
|
+
console.log(` killed worker ${workerId} (pid ${pid})`);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Process already dead — ignore
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Workers dir unreadable — proceed with delete anyway
|
|
38
|
+
}
|
|
39
|
+
}
|
|
15
40
|
await rm(STATE_DIR, { recursive: true, force: true });
|
|
16
41
|
console.log(`✓ Session state cleared (${STATE_DIR}/)`);
|
|
17
42
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -19,7 +19,14 @@ export async function setup() {
|
|
|
19
19
|
}
|
|
20
20
|
// 2. Install skills
|
|
21
21
|
await mkdir(SKILLS_DEST, { recursive: true });
|
|
22
|
-
|
|
22
|
+
let skills;
|
|
23
|
+
try {
|
|
24
|
+
skills = await readdir(SKILLS_SRC);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
console.error('✗ Could not find skills directory — package may be misconfigured');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
23
30
|
for (const skill of skills.filter(f => f.endsWith('.md'))) {
|
|
24
31
|
const dest = join(SKILLS_DEST, skill);
|
|
25
32
|
await copyFile(join(SKILLS_SRC, skill), dest);
|
package/dist/commands/stop.js
CHANGED
|
@@ -45,6 +45,9 @@ export async function stop(args) {
|
|
|
45
45
|
}
|
|
46
46
|
console.log(`\n${killed} killed, ${notFound} already stopped.`);
|
|
47
47
|
if (killed > 0) {
|
|
48
|
+
if (process.platform === 'win32') {
|
|
49
|
+
console.log(' note: SIGTERM on Windows is a force kill (TerminateProcess)');
|
|
50
|
+
}
|
|
48
51
|
console.log('State preserved. Run: loom reset --force to clear it.');
|
|
49
52
|
}
|
|
50
53
|
}
|
package/dist/commands/watch.js
CHANGED
|
@@ -4,24 +4,39 @@ import { existsSync } from 'fs';
|
|
|
4
4
|
import { STATE_DIR } from '../state/session.js';
|
|
5
5
|
const WORKERS_DIR = join(STATE_DIR, 'workers');
|
|
6
6
|
const POLL_MS = 800;
|
|
7
|
-
//
|
|
7
|
+
const STALE_TIMEOUT_MS = 15 * 60 * 1000; // 15 min no log growth + dead PID = give up
|
|
8
8
|
const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m', '\x1b[34m', '\x1b[31m'];
|
|
9
9
|
const RESET = '\x1b[0m';
|
|
10
10
|
const DIM = '\x1b[2m';
|
|
11
|
+
const YELLOW = '\x1b[33m';
|
|
12
|
+
function isProcessAlive(pid) {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
11
21
|
export async function watch(_args) {
|
|
12
22
|
if (!existsSync(WORKERS_DIR)) {
|
|
13
23
|
console.log('No active session. Run: loom crew "<task>"');
|
|
14
24
|
process.exit(1);
|
|
15
25
|
}
|
|
16
26
|
console.log(`${DIM}Watching worker logs. Ctrl+C to stop.${RESET}\n`);
|
|
17
|
-
// Track how many bytes we've read from each log file
|
|
18
27
|
const offsets = {};
|
|
28
|
+
const lastGrowth = {};
|
|
19
29
|
const seen = new Set();
|
|
20
|
-
// eslint-disable-next-line no-constant-condition
|
|
21
30
|
while (true) {
|
|
22
31
|
if (!existsSync(WORKERS_DIR))
|
|
23
32
|
break;
|
|
24
|
-
|
|
33
|
+
let files;
|
|
34
|
+
try {
|
|
35
|
+
files = await readdir(WORKERS_DIR);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
25
40
|
const logFiles = files.filter(f => f.endsWith('.log')).sort();
|
|
26
41
|
for (const logFile of logFiles) {
|
|
27
42
|
const workerId = logFile.replace('.log', '');
|
|
@@ -32,20 +47,27 @@ export async function watch(_args) {
|
|
|
32
47
|
const resultExists = existsSync(join(WORKERS_DIR, `${workerId}-result.md`));
|
|
33
48
|
console.log(`${color}[${workerId}]${RESET} ${DIM}started${resultExists ? ' (already done)' : ''}${RESET}`);
|
|
34
49
|
}
|
|
35
|
-
|
|
50
|
+
// Guard stat — file may be deleted mid-poll (e.g. loom reset)
|
|
51
|
+
let currentSize;
|
|
52
|
+
try {
|
|
53
|
+
currentSize = (await stat(filePath)).size;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
36
58
|
const offset = offsets[workerId] ?? 0;
|
|
37
59
|
if (currentSize > offset) {
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
lastGrowth[workerId] = Date.now();
|
|
61
|
+
const buf = await readFile(filePath).catch(() => null);
|
|
62
|
+
if (buf) {
|
|
63
|
+
const newContent = buf.slice(offset).toString('utf8');
|
|
64
|
+
offsets[workerId] = currentSize;
|
|
65
|
+
for (const line of newContent.split('\n')) {
|
|
66
|
+
if (line.trim())
|
|
67
|
+
process.stdout.write(`${color}[${workerId}]${RESET} ${line}\n`);
|
|
45
68
|
}
|
|
46
69
|
}
|
|
47
70
|
}
|
|
48
|
-
// Check if worker just finished (result file appeared)
|
|
49
71
|
const resultPath = join(WORKERS_DIR, `${workerId}-result.md`);
|
|
50
72
|
const doneKey = `${workerId}-done`;
|
|
51
73
|
if (existsSync(resultPath) && !seen.has(doneKey)) {
|
|
@@ -55,14 +77,35 @@ export async function watch(_args) {
|
|
|
55
77
|
}
|
|
56
78
|
// Exit when all known workers have results
|
|
57
79
|
if (logFiles.length > 0) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
return existsSync(join(WORKERS_DIR, `${id}-result.md`));
|
|
61
|
-
});
|
|
62
|
-
if (allDone) {
|
|
80
|
+
const workersDone = logFiles.map(f => f.replace('.log', '')).filter(id => existsSync(join(WORKERS_DIR, `${id}-result.md`)));
|
|
81
|
+
if (workersDone.length === logFiles.length) {
|
|
63
82
|
console.log(`\n${DIM}All workers done. Run: loom collect${RESET}`);
|
|
64
83
|
break;
|
|
65
84
|
}
|
|
85
|
+
// Stale detection: workers with no result, dead PID, and log silent for >15min
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const staleWorkers = [];
|
|
88
|
+
for (const logFile of logFiles) {
|
|
89
|
+
const id = logFile.replace('.log', '');
|
|
90
|
+
if (existsSync(join(WORKERS_DIR, `${id}-result.md`)))
|
|
91
|
+
continue;
|
|
92
|
+
const pidPath = join(WORKERS_DIR, `${id}.pid`);
|
|
93
|
+
let pidAlive = false;
|
|
94
|
+
if (existsSync(pidPath)) {
|
|
95
|
+
const pid = parseInt(await readFile(pidPath, 'utf8').catch(() => ''), 10);
|
|
96
|
+
if (!isNaN(pid))
|
|
97
|
+
pidAlive = isProcessAlive(pid);
|
|
98
|
+
}
|
|
99
|
+
const sinceGrowth = now - (lastGrowth[id] ?? now);
|
|
100
|
+
if (!pidAlive && sinceGrowth > STALE_TIMEOUT_MS) {
|
|
101
|
+
staleWorkers.push(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (staleWorkers.length > 0 && staleWorkers.length + workersDone.length === logFiles.length) {
|
|
105
|
+
console.log(`\n${YELLOW}Workers stalled (dead PID, no output for 15min): ${staleWorkers.join(', ')}${RESET}`);
|
|
106
|
+
console.log(`${DIM}Run: loom logs <workerId> to inspect. loom collect to gather what's available.${RESET}`);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
66
109
|
}
|
|
67
110
|
await new Promise(resolve => setTimeout(resolve, POLL_MS));
|
|
68
111
|
}
|
package/dist/state/session.d.ts
CHANGED
|
@@ -12,14 +12,6 @@ export type Task = {
|
|
|
12
12
|
claimedAt?: string;
|
|
13
13
|
completedAt?: string;
|
|
14
14
|
};
|
|
15
|
-
export type Worker = {
|
|
16
|
-
id: string;
|
|
17
|
-
agentType: string;
|
|
18
|
-
status: 'idle' | 'working' | 'done';
|
|
19
|
-
currentTaskId?: string;
|
|
20
|
-
startedAt: string;
|
|
21
|
-
completedAt?: string;
|
|
22
|
-
};
|
|
23
15
|
export type Session = {
|
|
24
16
|
id: string;
|
|
25
17
|
description: string;
|
|
@@ -34,6 +26,4 @@ export declare function ensureStateDir(): Promise<void>;
|
|
|
34
26
|
export declare function writeSession(session: Session): Promise<void>;
|
|
35
27
|
export declare function readSession(): Promise<Session | null>;
|
|
36
28
|
export declare function writeTask(task: Task): Promise<void>;
|
|
37
|
-
export declare function writeWorker(worker: Worker): Promise<void>;
|
|
38
|
-
export declare function readWorkers(): Promise<Worker[]>;
|
|
39
29
|
export declare function readTasks(): Promise<Task[]>;
|
package/dist/state/session.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import { existsSync } from 'fs';
|
|
4
3
|
export const STATE_DIR = '.claude-team';
|
|
5
4
|
export async function ensureStateDir() {
|
|
6
5
|
await mkdir(join(STATE_DIR, 'tasks'), { recursive: true });
|
|
@@ -11,30 +10,23 @@ export async function writeSession(session) {
|
|
|
11
10
|
await writeFile(join(STATE_DIR, 'session.json'), JSON.stringify(session, null, 2));
|
|
12
11
|
}
|
|
13
12
|
export async function readSession() {
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await readFile(join(STATE_DIR, 'session.json'), 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
16
17
|
return null;
|
|
17
|
-
|
|
18
|
+
}
|
|
18
19
|
}
|
|
19
20
|
export async function writeTask(task) {
|
|
20
21
|
await writeFile(join(STATE_DIR, 'tasks', `${task.id}-${task.status}.json`), JSON.stringify(task, null, 2));
|
|
21
22
|
}
|
|
22
|
-
export async function writeWorker(worker) {
|
|
23
|
-
await writeFile(join(STATE_DIR, 'workers', `${worker.id}.json`), JSON.stringify(worker, null, 2));
|
|
24
|
-
}
|
|
25
|
-
export async function readWorkers() {
|
|
26
|
-
const { readdir } = await import('fs/promises');
|
|
27
|
-
const dir = join(STATE_DIR, 'workers');
|
|
28
|
-
if (!existsSync(dir))
|
|
29
|
-
return [];
|
|
30
|
-
const files = await readdir(dir);
|
|
31
|
-
return Promise.all(files.filter(f => f.endsWith('.json')).map(async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'))));
|
|
32
|
-
}
|
|
33
23
|
export async function readTasks() {
|
|
34
|
-
const { readdir } = await import('fs/promises');
|
|
35
24
|
const dir = join(STATE_DIR, 'tasks');
|
|
36
|
-
|
|
25
|
+
try {
|
|
26
|
+
const files = await readdir(dir);
|
|
27
|
+
return Promise.all(files.filter(f => f.endsWith('.json')).map(async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'))));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
37
30
|
return [];
|
|
38
|
-
|
|
39
|
-
return Promise.all(files.filter(f => f.endsWith('.json')).map(async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'))));
|
|
31
|
+
}
|
|
40
32
|
}
|
|
@@ -8,5 +8,5 @@ export declare function parseWorkerSpec(args: string[], defaultWorkers?: number,
|
|
|
8
8
|
task: string;
|
|
9
9
|
};
|
|
10
10
|
export declare function initSession(description: string, workerCount: number): Promise<Session>;
|
|
11
|
-
export declare function writeContextSnapshot(slug: string, task: string): Promise<string>;
|
|
11
|
+
export declare function writeContextSnapshot(slug: string, sessionId: string, task: string): Promise<string>;
|
|
12
12
|
export declare function decomposeTasks(task: string, specs: WorkerSpec[], dryRun?: boolean): Promise<Task[]>;
|
|
@@ -17,10 +17,11 @@ export function parseWorkerSpec(args, defaultWorkers = 2, defaultAgentType = 'ge
|
|
|
17
17
|
const parts = specArg.split('+');
|
|
18
18
|
const specs = parts.map(part => {
|
|
19
19
|
const [countStr, agentType] = part.split(':');
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
20
|
+
const count = parseInt(countStr ?? '1');
|
|
21
|
+
if (isNaN(count) || count < 1) {
|
|
22
|
+
throw new Error(`Invalid worker spec "${part}" — count must be a positive integer`);
|
|
23
|
+
}
|
|
24
|
+
return { count, agentType: agentType ?? defaultAgentType };
|
|
24
25
|
});
|
|
25
26
|
return { specs, task };
|
|
26
27
|
}
|
|
@@ -36,9 +37,10 @@ export async function initSession(description, workerCount) {
|
|
|
36
37
|
await writeSession(session);
|
|
37
38
|
return session;
|
|
38
39
|
}
|
|
39
|
-
export async function writeContextSnapshot(slug, task) {
|
|
40
|
-
|
|
41
|
-
const
|
|
40
|
+
export async function writeContextSnapshot(slug, sessionId, task) {
|
|
41
|
+
// Include session ID so concurrent runs with similar task names don't clobber each other
|
|
42
|
+
const path = join(STATE_DIR, 'context', `${slug}-${sessionId.slice(0, 6)}.md`);
|
|
43
|
+
const content = `# Task Context\n\n**Task:** ${task}\n\n**Session:** ${sessionId}\n\n**Started:** ${new Date().toISOString()}\n\n## Notes\n\n_Workers will append findings here._\n`;
|
|
42
44
|
await writeFile(path, content);
|
|
43
45
|
return path;
|
|
44
46
|
}
|
|
@@ -75,9 +77,9 @@ Task: "${task}"`;
|
|
|
75
77
|
encoding: 'utf8',
|
|
76
78
|
timeout: 30_000,
|
|
77
79
|
});
|
|
78
|
-
if (result.status !== 0 || !result.stdout)
|
|
80
|
+
if (result.status !== 0 || !result.stdout) {
|
|
79
81
|
throw new Error(result.stderr ?? 'no output');
|
|
80
|
-
|
|
82
|
+
}
|
|
81
83
|
const match = result.stdout.match(/\[[\s\S]*\]/);
|
|
82
84
|
if (!match)
|
|
83
85
|
throw new Error('No JSON array in response');
|
|
@@ -85,13 +87,12 @@ Task: "${task}"`;
|
|
|
85
87
|
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
86
88
|
throw new Error('Empty array');
|
|
87
89
|
const subtasks = parsed.map(String);
|
|
88
|
-
// Pad or trim to exactly n
|
|
89
90
|
while (subtasks.length < n)
|
|
90
91
|
subtasks.push(task);
|
|
91
92
|
return subtasks.slice(0, n);
|
|
92
93
|
}
|
|
93
|
-
catch {
|
|
94
|
-
|
|
94
|
+
catch (err) {
|
|
95
|
+
process.stderr.write(`[agentloom] Task decomposition failed (${err instanceof Error ? err.message : err}) — all workers will receive the same task description\n`);
|
|
95
96
|
return Array(n).fill(task);
|
|
96
97
|
}
|
|
97
98
|
}
|