@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.
@@ -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, readTasks } from '../state/session.js';
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}"
@@ -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
- log.close().catch(() => { });
210
+ closeLog();
205
211
  });
206
- child.on('close', () => { log.close().catch(() => { }); });
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
- try {
224
- execSync(`tmux new-session -d -s ${tmuxSession} -x 220 -y 50`);
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', ${!READ_ONLY_ROLES.has(agentType) ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
250
- `const r = spawnSync('claude', args, { stdio: 'inherit' })`,
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) {
@@ -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
- const workerId = args[0];
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`);
@@ -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
  }
@@ -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
- const skills = await readdir(SKILLS_SRC);
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);
@@ -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
  }
@@ -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
- process.exit(1);
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
- const files = await readdir(WORKERS_DIR);
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
- const currentSize = (await stat(filePath)).size;
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
- const buf = await readFile(filePath);
39
- const newContent = buf.slice(offset).toString('utf8');
40
- offsets[workerId] = currentSize;
41
- const lines = newContent.split('\n');
42
- for (const line of lines) {
43
- if (line.trim()) {
44
- process.stdout.write(`${color}[${workerId}]${RESET} ${line}\n`);
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 allDone = logFiles.every(f => {
59
- const id = f.replace('.log', '');
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
- dangerouslySkipPermissions: false,
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))
@@ -1,4 +1,4 @@
1
- export declare const STATE_DIR = ".claude-team";
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[]>;
@@ -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
- import { existsSync } from 'fs';
4
- export const STATE_DIR = '.claude-team';
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
- const path = join(STATE_DIR, 'session.json');
15
- if (!existsSync(path))
15
+ try {
16
+ return JSON.parse(await readFile(join(STATE_DIR, 'session.json'), 'utf8'));
17
+ }
18
+ catch {
16
19
  return null;
17
- return JSON.parse(await readFile(path, 'utf8'));
20
+ }
18
21
  }
19
22
  export async function writeTask(task) {
20
- await writeFile(join(STATE_DIR, 'tasks', `${task.id}-${task.status}.json`), JSON.stringify(task, null, 2));
21
- }
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'))));
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
- if (!existsSync(dir))
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
- const files = await readdir(dir);
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
- return {
21
- count: parseInt(countStr ?? '1'),
22
- agentType: agentType ?? defaultAgentType,
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, 8),
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
- const path = join(STATE_DIR, 'context', `${slug}.md`);
41
- const content = `# Task Context\n\n**Task:** ${task}\n\n**Started:** ${new Date().toISOString()}\n\n## Notes\n\n_Workers will append findings here._\n`;
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, 8),
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
- // Extract JSON array from the response (strip any surrounding prose)
81
- const match = result.stdout.match(/\[[\s\S]*\]/);
82
- if (!match)
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(match[0]);
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
- // Fallback: every worker gets the same task description
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chuckssmith/agentloom",
3
- "version": "0.9.0",
3
+ "version": "1.1.0",
4
4
  "description": "A workflow layer for Claude Code — reusable roles, persistence loops, and multi-agent crew coordination",
5
5
  "keywords": [
6
6
  "ai",