@chuckssmith/agentloom 0.9.0 → 1.1.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 +26 -3
- package/dist/commands/crew.js +30 -21
- package/dist/commands/logs.js +4 -2
- package/dist/commands/reset.js +27 -2
- package/dist/commands/setup.js +9 -2
- package/dist/commands/stop.js +3 -0
- package/dist/commands/watch.js +64 -19
- package/dist/config.js +3 -1
- package/dist/state/session.d.ts +1 -11
- package/dist/state/session.js +35 -22
- package/dist/team/orchestrator.d.ts +1 -1
- package/dist/team/orchestrator.js +20 -17
- package/package.json +1 -1
package/dist/commands/collect.js
CHANGED
|
@@ -2,7 +2,16 @@ 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
|
+
function isProcessAlive(pid) {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
6
15
|
const WORKERS_DIR = join(STATE_DIR, 'workers');
|
|
7
16
|
export async function collect(args) {
|
|
8
17
|
if (!existsSync(WORKERS_DIR)) {
|
|
@@ -10,7 +19,6 @@ export async function collect(args) {
|
|
|
10
19
|
return;
|
|
11
20
|
}
|
|
12
21
|
const session = await readSession();
|
|
13
|
-
const tasks = await readTasks();
|
|
14
22
|
const files = await readdir(WORKERS_DIR);
|
|
15
23
|
const resultFiles = files.filter(f => f.endsWith('-result.md')).sort();
|
|
16
24
|
if (resultFiles.length === 0) {
|
|
@@ -18,6 +26,21 @@ export async function collect(args) {
|
|
|
18
26
|
console.log('Workers still running? Check: loom logs');
|
|
19
27
|
return;
|
|
20
28
|
}
|
|
29
|
+
// Warn if any workers are still running — synthesis will be incomplete
|
|
30
|
+
const pidFiles = files.filter(f => f.endsWith('.pid'));
|
|
31
|
+
const stillRunning = [];
|
|
32
|
+
for (const pidFile of pidFiles) {
|
|
33
|
+
const id = pidFile.replace('.pid', '');
|
|
34
|
+
if (existsSync(join(WORKERS_DIR, `${id}-result.md`)))
|
|
35
|
+
continue;
|
|
36
|
+
const pid = parseInt(await readFile(join(WORKERS_DIR, pidFile), 'utf8').catch(() => ''), 10);
|
|
37
|
+
if (!isNaN(pid) && isProcessAlive(pid))
|
|
38
|
+
stillRunning.push(id);
|
|
39
|
+
}
|
|
40
|
+
if (stillRunning.length > 0) {
|
|
41
|
+
console.log(`⚠ Workers still running: ${stillRunning.join(', ')} — results will be incomplete`);
|
|
42
|
+
console.log(` Wait for completion or run: loom stop\n`);
|
|
43
|
+
}
|
|
21
44
|
console.log(`\nCollecting results from ${resultFiles.length} worker(s)...\n`);
|
|
22
45
|
const results = [];
|
|
23
46
|
for (const f of resultFiles) {
|
|
@@ -34,7 +57,7 @@ export async function collect(args) {
|
|
|
34
57
|
const synthesize = !args.includes('--no-ai');
|
|
35
58
|
let synthesis = '';
|
|
36
59
|
if (synthesize) {
|
|
37
|
-
console.log('\nSynthesizing with Claude...');
|
|
60
|
+
console.log('\nSynthesizing with Claude... (may take up to 60s)');
|
|
38
61
|
const prompt = `You are summarizing the results of a multi-agent crew that worked on this task:
|
|
39
62
|
|
|
40
63
|
"${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,11 +190,16 @@ 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
|
];
|
|
196
197
|
const log = await open(logFile, 'w');
|
|
198
|
+
let logClosed = false;
|
|
199
|
+
const closeLog = () => { if (!logClosed) {
|
|
200
|
+
logClosed = true;
|
|
201
|
+
log.close().catch(() => { });
|
|
202
|
+
} };
|
|
197
203
|
const child = spawn('claude', claudeArgs, {
|
|
198
204
|
detached: true,
|
|
199
205
|
stdio: ['ignore', log.fd, log.fd],
|
|
@@ -201,9 +207,9 @@ async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
|
201
207
|
});
|
|
202
208
|
child.on('error', async (err) => {
|
|
203
209
|
await writeFile(join(STATE_DIR, 'workers', `${workerId}-result.md`), `# Launch Error\n\nFailed to start worker: ${err.message}\n`).catch(() => { });
|
|
204
|
-
|
|
210
|
+
closeLog();
|
|
205
211
|
});
|
|
206
|
-
child.on('close', () => {
|
|
212
|
+
child.on('close', () => { closeLog(); });
|
|
207
213
|
if (child.pid != null) {
|
|
208
214
|
await writeFile(pidFile, String(child.pid));
|
|
209
215
|
}
|
|
@@ -212,7 +218,7 @@ async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
|
212
218
|
}
|
|
213
219
|
}
|
|
214
220
|
}
|
|
215
|
-
async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
221
|
+
async function launchTmux(sessionId, specs, tasks, contextPath, forcePermissions = false) {
|
|
216
222
|
const tmuxSession = `loom-${sessionId}`;
|
|
217
223
|
// Check for session name collision
|
|
218
224
|
const existing = spawnSync('tmux', ['has-session', '-t', tmuxSession], { stdio: 'ignore' });
|
|
@@ -220,11 +226,9 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
|
220
226
|
console.error(`tmux session "${tmuxSession}" already exists. Run: tmux kill-session -t ${tmuxSession}`);
|
|
221
227
|
process.exit(1);
|
|
222
228
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
catch (err) {
|
|
227
|
-
console.error(`Failed to create tmux session: ${err instanceof Error ? err.message : err}`);
|
|
229
|
+
const newSession = spawnSync('tmux', ['new-session', '-d', '-s', tmuxSession, '-x', '220', '-y', '50'], { stdio: 'ignore' });
|
|
230
|
+
if (newSession.status !== 0) {
|
|
231
|
+
console.error(`Failed to create tmux session: ${newSession.stderr?.toString().trim() ?? 'unknown error'}`);
|
|
228
232
|
process.exit(1);
|
|
229
233
|
}
|
|
230
234
|
await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
|
|
@@ -237,18 +241,23 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
|
237
241
|
workerIdx++;
|
|
238
242
|
const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
|
|
239
243
|
const promptFile = join(STATE_DIR, 'workers', `${workerId}-prompt.md`);
|
|
244
|
+
const logFile = join(STATE_DIR, 'workers', `${workerId}.log`);
|
|
245
|
+
const pidFile = join(STATE_DIR, 'workers', `${workerId}.pid`);
|
|
240
246
|
const scriptFile = join(STATE_DIR, 'workers', `${workerId}-run.mjs`);
|
|
241
247
|
await writeFile(promptFile, prompt);
|
|
242
|
-
// Node.js runner — JSON.stringify safely encodes all values, no shell expansion possible
|
|
248
|
+
// Node.js runner — JSON.stringify safely encodes all values, no shell expansion possible.
|
|
249
|
+
// Writes PID and logs to the same files as launchBackground so loom stop/watch/status work.
|
|
250
|
+
const skipPerms = forcePermissions && !READ_ONLY_ROLES.has(agentType);
|
|
243
251
|
await writeFile(scriptFile, [
|
|
244
|
-
`import { readFileSync } from 'fs'`,
|
|
252
|
+
`import { readFileSync, writeFileSync, openSync } from 'fs'`,
|
|
245
253
|
`import { spawnSync } from 'child_process'`,
|
|
246
254
|
`process.env.AGENTLOOM_WORKER_ID = ${JSON.stringify(workerId)}`,
|
|
247
255
|
`process.env.AGENTLOOM_SESSION = ${JSON.stringify(sessionId)}`,
|
|
256
|
+
`writeFileSync(${JSON.stringify(pidFile)}, String(process.pid))`,
|
|
257
|
+
`const logFd = openSync(${JSON.stringify(logFile)}, 'w')`,
|
|
248
258
|
`const prompt = readFileSync(${JSON.stringify(promptFile)}, 'utf8')`,
|
|
249
|
-
`const args = ['--print', ${
|
|
250
|
-
`const r = spawnSync('claude', args, { stdio: '
|
|
251
|
-
`console.log('[worker done]')`,
|
|
259
|
+
`const args = ['--print', ${skipPerms ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
|
|
260
|
+
`const r = spawnSync('claude', args, { stdio: ['ignore', logFd, logFd] })`,
|
|
252
261
|
`process.exit(r.status ?? 0)`,
|
|
253
262
|
].join('\n'));
|
|
254
263
|
if (workerIdx > 1) {
|
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
|
@@ -2,13 +2,13 @@ import { mkdir, copyFile, readdir } from 'fs/promises';
|
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const SKILLS_SRC = join(__dirname, '../../skills');
|
|
7
8
|
const SKILLS_DEST = join(homedir(), '.claude', 'skills');
|
|
8
9
|
export async function setup() {
|
|
9
10
|
console.log('agentloom setup\n');
|
|
10
11
|
// 1. Validate claude CLI exists
|
|
11
|
-
const { execSync } = await import('child_process');
|
|
12
12
|
try {
|
|
13
13
|
execSync('claude --version', { stdio: 'ignore' });
|
|
14
14
|
console.log('✓ claude CLI found');
|
|
@@ -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
|
@@ -2,26 +2,43 @@ import { readdir, stat, readFile } from 'fs/promises';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { STATE_DIR } from '../state/session.js';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
5
6
|
const WORKERS_DIR = join(STATE_DIR, 'workers');
|
|
6
7
|
const POLL_MS = 800;
|
|
7
|
-
// A rotating set of ANSI colors for worker prefixes
|
|
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
|
+
return;
|
|
15
25
|
}
|
|
26
|
+
const config = await loadConfig();
|
|
27
|
+
const STALE_TIMEOUT_MS = config.staleMinutes * 60 * 1000;
|
|
16
28
|
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
29
|
const offsets = {};
|
|
30
|
+
const lastGrowth = {};
|
|
19
31
|
const seen = new Set();
|
|
20
|
-
// eslint-disable-next-line no-constant-condition
|
|
21
32
|
while (true) {
|
|
22
33
|
if (!existsSync(WORKERS_DIR))
|
|
23
34
|
break;
|
|
24
|
-
|
|
35
|
+
let files;
|
|
36
|
+
try {
|
|
37
|
+
files = await readdir(WORKERS_DIR);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
25
42
|
const logFiles = files.filter(f => f.endsWith('.log')).sort();
|
|
26
43
|
for (const logFile of logFiles) {
|
|
27
44
|
const workerId = logFile.replace('.log', '');
|
|
@@ -32,20 +49,27 @@ export async function watch(_args) {
|
|
|
32
49
|
const resultExists = existsSync(join(WORKERS_DIR, `${workerId}-result.md`));
|
|
33
50
|
console.log(`${color}[${workerId}]${RESET} ${DIM}started${resultExists ? ' (already done)' : ''}${RESET}`);
|
|
34
51
|
}
|
|
35
|
-
|
|
52
|
+
// Guard stat — file may be deleted mid-poll (e.g. loom reset)
|
|
53
|
+
let currentSize;
|
|
54
|
+
try {
|
|
55
|
+
currentSize = (await stat(filePath)).size;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
36
60
|
const offset = offsets[workerId] ?? 0;
|
|
37
61
|
if (currentSize > offset) {
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
lastGrowth[workerId] = Date.now();
|
|
63
|
+
const buf = await readFile(filePath).catch(() => null);
|
|
64
|
+
if (buf) {
|
|
65
|
+
const newContent = buf.slice(offset).toString('utf8');
|
|
66
|
+
offsets[workerId] = currentSize;
|
|
67
|
+
for (const line of newContent.split('\n')) {
|
|
68
|
+
if (line.trim())
|
|
69
|
+
process.stdout.write(`${color}[${workerId}]${RESET} ${line}\n`);
|
|
45
70
|
}
|
|
46
71
|
}
|
|
47
72
|
}
|
|
48
|
-
// Check if worker just finished (result file appeared)
|
|
49
73
|
const resultPath = join(WORKERS_DIR, `${workerId}-result.md`);
|
|
50
74
|
const doneKey = `${workerId}-done`;
|
|
51
75
|
if (existsSync(resultPath) && !seen.has(doneKey)) {
|
|
@@ -55,14 +79,35 @@ export async function watch(_args) {
|
|
|
55
79
|
}
|
|
56
80
|
// Exit when all known workers have results
|
|
57
81
|
if (logFiles.length > 0) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
return existsSync(join(WORKERS_DIR, `${id}-result.md`));
|
|
61
|
-
});
|
|
62
|
-
if (allDone) {
|
|
82
|
+
const workersDone = logFiles.map(f => f.replace('.log', '')).filter(id => existsSync(join(WORKERS_DIR, `${id}-result.md`)));
|
|
83
|
+
if (workersDone.length === logFiles.length) {
|
|
63
84
|
console.log(`\n${DIM}All workers done. Run: loom collect${RESET}`);
|
|
64
85
|
break;
|
|
65
86
|
}
|
|
87
|
+
// Stale detection: workers with no result, dead PID, and log silent for >15min
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const staleWorkers = [];
|
|
90
|
+
for (const logFile of logFiles) {
|
|
91
|
+
const id = logFile.replace('.log', '');
|
|
92
|
+
if (existsSync(join(WORKERS_DIR, `${id}-result.md`)))
|
|
93
|
+
continue;
|
|
94
|
+
const pidPath = join(WORKERS_DIR, `${id}.pid`);
|
|
95
|
+
let pidAlive = false;
|
|
96
|
+
if (existsSync(pidPath)) {
|
|
97
|
+
const pid = parseInt(await readFile(pidPath, 'utf8').catch(() => ''), 10);
|
|
98
|
+
if (!isNaN(pid))
|
|
99
|
+
pidAlive = isProcessAlive(pid);
|
|
100
|
+
}
|
|
101
|
+
const sinceGrowth = now - (lastGrowth[id] ?? now);
|
|
102
|
+
if (!pidAlive && sinceGrowth > STALE_TIMEOUT_MS) {
|
|
103
|
+
staleWorkers.push(id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (staleWorkers.length > 0 && staleWorkers.length + workersDone.length === logFiles.length) {
|
|
107
|
+
console.log(`\n${YELLOW}Workers stalled (dead PID, no output for ${config.staleMinutes}min): ${staleWorkers.join(', ')}${RESET}`);
|
|
108
|
+
console.log(`${DIM}Run: loom logs <workerId> to inspect. loom collect to gather what's available.${RESET}`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
66
111
|
}
|
|
67
112
|
await new Promise(resolve => setTimeout(resolve, POLL_MS));
|
|
68
113
|
}
|
package/dist/config.js
CHANGED
|
@@ -6,7 +6,9 @@ const DEFAULTS = {
|
|
|
6
6
|
agentType: 'general-purpose',
|
|
7
7
|
claimTtlMinutes: 30,
|
|
8
8
|
staleMinutes: 10,
|
|
9
|
-
|
|
9
|
+
// Default true: background workers must skip permission prompts to run non-interactively.
|
|
10
|
+
// Set to false in .loomrc to require interactive approval (workers will pause on each action).
|
|
11
|
+
dangerouslySkipPermissions: true,
|
|
10
12
|
};
|
|
11
13
|
export async function loadConfig() {
|
|
12
14
|
if (!existsSync(LOOMRC))
|
package/dist/state/session.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const STATE_DIR
|
|
1
|
+
export declare const STATE_DIR: string;
|
|
2
2
|
export type TaskStatus = 'pending' | 'claimed' | 'done' | 'failed';
|
|
3
3
|
export type Task = {
|
|
4
4
|
id: string;
|
|
@@ -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,7 +1,8 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir, unlink } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// Absolute path so loom works correctly regardless of which subdirectory it's invoked from.
|
|
4
|
+
// NOTE: resolves to cwd at process start — does not walk up to find project root.
|
|
5
|
+
export const STATE_DIR = join(process.cwd(), '.claude-team');
|
|
5
6
|
export async function ensureStateDir() {
|
|
6
7
|
await mkdir(join(STATE_DIR, 'tasks'), { recursive: true });
|
|
7
8
|
await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
|
|
@@ -11,30 +12,42 @@ export async function writeSession(session) {
|
|
|
11
12
|
await writeFile(join(STATE_DIR, 'session.json'), JSON.stringify(session, null, 2));
|
|
12
13
|
}
|
|
13
14
|
export async function readSession() {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(await readFile(join(STATE_DIR, 'session.json'), 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
16
19
|
return null;
|
|
17
|
-
|
|
20
|
+
}
|
|
18
21
|
}
|
|
19
22
|
export async function writeTask(task) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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'))));
|
|
23
|
+
const dir = join(STATE_DIR, 'tasks');
|
|
24
|
+
// Remove any existing files for this task id to prevent ghost duplicates on status change.
|
|
25
|
+
try {
|
|
26
|
+
const files = await readdir(dir);
|
|
27
|
+
await Promise.all(files
|
|
28
|
+
.filter(f => f.startsWith(`${task.id}-`) && f.endsWith('.json'))
|
|
29
|
+
.map(f => unlink(join(dir, f)).catch(() => { })));
|
|
30
|
+
}
|
|
31
|
+
catch { /* dir may not exist yet — ensureStateDir handles this */ }
|
|
32
|
+
await writeFile(join(dir, `${task.id}-${task.status}.json`), JSON.stringify(task, null, 2));
|
|
32
33
|
}
|
|
33
34
|
export async function readTasks() {
|
|
34
|
-
const { readdir } = await import('fs/promises');
|
|
35
35
|
const dir = join(STATE_DIR, 'tasks');
|
|
36
|
-
|
|
36
|
+
try {
|
|
37
|
+
const files = await readdir(dir);
|
|
38
|
+
const tasks = await Promise.all(files.filter(f => f.endsWith('.json')).map(async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'))));
|
|
39
|
+
// Deduplicate by id — keep highest-priority status in case of ghost duplicates.
|
|
40
|
+
const STATUS_PRIORITY = { done: 4, failed: 3, claimed: 2, pending: 1 };
|
|
41
|
+
const byId = new Map();
|
|
42
|
+
for (const task of tasks) {
|
|
43
|
+
const existing = byId.get(task.id);
|
|
44
|
+
if (!existing || (STATUS_PRIORITY[task.status] ?? 0) > (STATUS_PRIORITY[existing.status] ?? 0)) {
|
|
45
|
+
byId.set(task.id, task);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...byId.values()];
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
37
51
|
return [];
|
|
38
|
-
|
|
39
|
-
return Promise.all(files.filter(f => f.endsWith('.json')).map(async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'))));
|
|
52
|
+
}
|
|
40
53
|
}
|
|
@@ -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,17 +17,18 @@ 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
|
}
|
|
27
28
|
export async function initSession(description, workerCount) {
|
|
28
29
|
await ensureStateDir();
|
|
29
30
|
const session = {
|
|
30
|
-
id: randomUUID().slice(0,
|
|
31
|
+
id: randomUUID().replace(/-/g, '').slice(0, 16),
|
|
31
32
|
description,
|
|
32
33
|
status: 'running',
|
|
33
34
|
workerCount,
|
|
@@ -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
|
}
|
|
@@ -50,7 +52,7 @@ export async function decomposeTasks(task, specs, dryRun = false) {
|
|
|
50
52
|
for (const spec of specs) {
|
|
51
53
|
for (let i = 0; i < spec.count; i++) {
|
|
52
54
|
const t = {
|
|
53
|
-
id: randomUUID().slice(0,
|
|
55
|
+
id: randomUUID().replace(/-/g, '').slice(0, 16),
|
|
54
56
|
description: subtasks[idx] ?? task,
|
|
55
57
|
agentType: spec.agentType,
|
|
56
58
|
status: 'pending',
|
|
@@ -75,23 +77,24 @@ 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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
}
|
|
83
|
+
// Use first '[' and last ']' to avoid greedy regex over-capturing trailing brackets
|
|
84
|
+
const start = result.stdout.indexOf('[');
|
|
85
|
+
const end = result.stdout.lastIndexOf(']');
|
|
86
|
+
if (start === -1 || end === -1 || end < start)
|
|
83
87
|
throw new Error('No JSON array in response');
|
|
84
|
-
const parsed = JSON.parse(
|
|
88
|
+
const parsed = JSON.parse(result.stdout.slice(start, end + 1));
|
|
85
89
|
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
86
90
|
throw new Error('Empty array');
|
|
87
91
|
const subtasks = parsed.map(String);
|
|
88
|
-
// Pad or trim to exactly n
|
|
89
92
|
while (subtasks.length < n)
|
|
90
93
|
subtasks.push(task);
|
|
91
94
|
return subtasks.slice(0, n);
|
|
92
95
|
}
|
|
93
|
-
catch {
|
|
94
|
-
|
|
96
|
+
catch (err) {
|
|
97
|
+
process.stderr.write(`[agentloom] Task decomposition failed (${err instanceof Error ? err.message : err}) — all workers will receive the same task description\n`);
|
|
95
98
|
return Array(n).fill(task);
|
|
96
99
|
}
|
|
97
100
|
}
|