@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.
@@ -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, readTasks } from '../state/session.js';
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}"
@@ -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)`,
@@ -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
  }
@@ -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
  }
@@ -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
- // A rotating set of ANSI colors for worker prefixes
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
- const files = await readdir(WORKERS_DIR);
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
- const currentSize = (await stat(filePath)).size;
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
- 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`);
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 allDone = logFiles.every(f => {
59
- const id = f.replace('.log', '');
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
  }
@@ -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,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
- const path = join(STATE_DIR, 'session.json');
15
- if (!existsSync(path))
13
+ try {
14
+ return JSON.parse(await readFile(join(STATE_DIR, 'session.json'), 'utf8'));
15
+ }
16
+ catch {
16
17
  return null;
17
- return JSON.parse(await readFile(path, 'utf8'));
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
- if (!existsSync(dir))
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
- 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'))));
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
- 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
  }
@@ -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
  }
@@ -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
- // Extract JSON array from the response (strip any surrounding prose)
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
- // Fallback: every worker gets the same task description
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chuckssmith/agentloom",
3
- "version": "0.9.0",
3
+ "version": "1.0.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",