@chuckssmith/agentloom 0.5.0 → 0.7.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/cli.js CHANGED
@@ -6,6 +6,7 @@ import { logs } from './commands/logs.js';
6
6
  import { collect } from './commands/collect.js';
7
7
  import { reset } from './commands/reset.js';
8
8
  import { watch } from './commands/watch.js';
9
+ import { stop } from './commands/stop.js';
9
10
  const [, , command, ...args] = process.argv;
10
11
  const usage = `
11
12
  agentloom (loom) — workflow layer for Claude Code
@@ -16,6 +17,8 @@ Usage:
16
17
  loom crew 2:explore "<task>" Spawn typed workers (explore/plan/code-reviewer)
17
18
  loom crew --dry-run [N] "<task>" Preview decomposed subtasks without launching
18
19
  loom watch Live tail all worker logs (Ctrl+C to stop)
20
+ loom stop Kill all background workers (SIGTERM)
21
+ loom stop <workerId> Kill one worker
19
22
  loom status Show active crew session + stale worker detection
20
23
  loom logs Show worker output summary
21
24
  loom logs <workerId> Show full log for a specific worker
@@ -62,6 +65,9 @@ switch (command) {
62
65
  case 'collect':
63
66
  await collect(args);
64
67
  break;
68
+ case 'stop':
69
+ await stop(args);
70
+ break;
65
71
  case 'reset':
66
72
  await reset(args);
67
73
  break;
@@ -1,4 +1,4 @@
1
- import { execSync, spawn } from 'child_process';
1
+ import { execSync, spawn, spawnSync } from 'child_process';
2
2
  import { writeFile, mkdir, open } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { parseWorkerSpec, initSession, writeContextSnapshot, decomposeTasks, } from '../team/orchestrator.js';
@@ -13,17 +13,18 @@ const hasTmux = () => {
13
13
  }
14
14
  };
15
15
  const isWSL = () => process.platform === 'linux' && !!process.env.WSL_DISTRO_NAME;
16
- // Role-specific instructions injected into each worker prompt
16
+ // Roles that must NOT receive --dangerously-skip-permissions
17
+ const READ_ONLY_ROLES = new Set(['explore', 'plan', 'code-reviewer']);
17
18
  const AGENT_ROLE = {
18
- 'explore': `Your role is EXPLORER. You are read-only. Do not modify any files.
19
+ 'explore': `Your role is EXPLORER. You are read-only do not modify, create, or delete any files.
19
20
  - Map out the relevant code, files, and structure
20
21
  - Document what exists, how it connects, and what's notable
21
22
  - Your output feeds the other workers — be thorough and specific`,
22
- 'plan': `Your role is PLANNER. You are read-only. Do not modify any files.
23
+ 'plan': `Your role is PLANNER. You are read-only do not modify, create, or delete any files.
23
24
  - Reason about the best approach to the subtask
24
25
  - Identify risks, dependencies, and open questions
25
26
  - Produce a concrete, ordered action plan other workers can execute`,
26
- 'code-reviewer': `Your role is CODE REVIEWER. You are read-only. Do not modify any files.
27
+ 'code-reviewer': `Your role is CODE REVIEWER. You are read-only do not modify, create, or delete any files.
27
28
  - Audit the relevant code for correctness, security, and quality
28
29
  - Flag specific lines, patterns, or logic that are problematic
29
30
  - Assign severity (critical / high / medium / low) to each finding`,
@@ -88,14 +89,15 @@ export async function crew(args) {
88
89
  console.log('Run without --dry-run to launch workers.');
89
90
  return;
90
91
  }
91
- console.log(`Mode: ${hasTmux() && !isWSL() ? 'tmux' : 'background processes'}\n`);
92
+ const useTmux = hasTmux() && !isWSL() && process.stdout.isTTY;
93
+ console.log(`Mode: ${useTmux ? 'tmux' : 'background processes'}\n`);
92
94
  const session = await initSession(task, totalWorkers);
93
95
  const contextPath = await writeContextSnapshot(slug, task);
94
96
  const tasks = await decomposeTasks(task, specs);
95
97
  console.log(`Session: ${session.id}`);
96
98
  console.log(`Tasks: ${tasks.length} created`);
97
99
  console.log(`Context: ${contextPath}\n`);
98
- if (hasTmux() && !isWSL()) {
100
+ if (useTmux) {
99
101
  await launchTmux(session.id, specs, tasks, contextPath);
100
102
  }
101
103
  else {
@@ -103,8 +105,8 @@ export async function crew(args) {
103
105
  }
104
106
  console.log(`\nWorkers launched. Monitor with:`);
105
107
  console.log(` loom status`);
106
- console.log(` loom logs`);
107
- console.log(` loom crew --watch (live tail)`);
108
+ console.log(` loom watch`);
109
+ console.log(` loom stop (kill all workers)`);
108
110
  console.log(`State dir: ${STATE_DIR}/`);
109
111
  }
110
112
  async function launchBackground(sessionId, specs, tasks, contextPath) {
@@ -118,22 +120,50 @@ async function launchBackground(sessionId, specs, tasks, contextPath) {
118
120
  workerIdx++;
119
121
  const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
120
122
  const logFile = join(STATE_DIR, 'workers', `${workerId}.log`);
123
+ const pidFile = join(STATE_DIR, 'workers', `${workerId}.pid`);
121
124
  await writeFile(join(STATE_DIR, 'workers', `${workerId}-prompt.md`), prompt);
125
+ // Build args declaratively — no positional splicing
126
+ const claudeArgs = [
127
+ '--print',
128
+ ...(!READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
129
+ '-p',
130
+ prompt,
131
+ ];
122
132
  const log = await open(logFile, 'w');
123
- const child = spawn('claude', ['--print', '--dangerously-skip-permissions', '-p', prompt], {
133
+ const child = spawn('claude', claudeArgs, {
124
134
  detached: true,
125
135
  stdio: ['ignore', log.fd, log.fd],
126
136
  env: { ...process.env, AGENTLOOM_WORKER_ID: workerId, AGENTLOOM_SESSION: sessionId },
127
137
  });
128
- child.on('close', () => log.close());
138
+ child.on('error', async (err) => {
139
+ await writeFile(join(STATE_DIR, 'workers', `${workerId}-result.md`), `# Launch Error\n\nFailed to start worker: ${err.message}\n`).catch(() => { });
140
+ log.close().catch(() => { });
141
+ });
142
+ child.on('close', () => { log.close().catch(() => { }); });
143
+ if (child.pid != null) {
144
+ await writeFile(pidFile, String(child.pid));
145
+ }
129
146
  child.unref();
130
- console.log(` ✓ Worker ${workerId} (${agentType}) launched [pid ${child.pid}] → ${logFile}`);
147
+ console.log(` ✓ Worker ${workerId} (${agentType})${READ_ONLY_ROLES.has(agentType) ? ' [read-only]' : ''} launched [pid ${child.pid ?? '?'}] → ${logFile}`);
131
148
  }
132
149
  }
133
150
  }
134
151
  async function launchTmux(sessionId, specs, tasks, contextPath) {
135
152
  const tmuxSession = `loom-${sessionId}`;
136
- execSync(`tmux new-session -d -s ${tmuxSession} -x 220 -y 50`);
153
+ // Check for session name collision
154
+ const existing = spawnSync('tmux', ['has-session', '-t', tmuxSession], { stdio: 'ignore' });
155
+ if (existing.status === 0) {
156
+ console.error(`tmux session "${tmuxSession}" already exists. Run: tmux kill-session -t ${tmuxSession}`);
157
+ process.exit(1);
158
+ }
159
+ try {
160
+ execSync(`tmux new-session -d -s ${tmuxSession} -x 220 -y 50`);
161
+ }
162
+ catch (err) {
163
+ console.error(`Failed to create tmux session: ${err instanceof Error ? err.message : err}`);
164
+ process.exit(1);
165
+ }
166
+ await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
137
167
  let workerIdx = 0;
138
168
  for (const spec of specs) {
139
169
  for (let i = 0; i < spec.count; i++) {
@@ -142,14 +172,47 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
142
172
  const agentType = tasks[workerIdx]?.agentType ?? spec.agentType;
143
173
  workerIdx++;
144
174
  const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
175
+ const promptFile = join(STATE_DIR, 'workers', `${workerId}-prompt.md`);
176
+ const scriptFile = join(STATE_DIR, 'workers', `${workerId}-run.mjs`);
177
+ await writeFile(promptFile, prompt);
178
+ // Node.js runner — JSON.stringify safely encodes all values, no shell expansion possible
179
+ await writeFile(scriptFile, [
180
+ `import { readFileSync } from 'fs'`,
181
+ `import { spawnSync } from 'child_process'`,
182
+ `process.env.AGENTLOOM_WORKER_ID = ${JSON.stringify(workerId)}`,
183
+ `process.env.AGENTLOOM_SESSION = ${JSON.stringify(sessionId)}`,
184
+ `const prompt = readFileSync(${JSON.stringify(promptFile)}, 'utf8')`,
185
+ `const args = ['--print', ${!READ_ONLY_ROLES.has(agentType) ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
186
+ `const r = spawnSync('claude', args, { stdio: 'inherit' })`,
187
+ `console.log('[worker done]')`,
188
+ `process.exit(r.status ?? 0)`,
189
+ ].join('\n'));
145
190
  if (workerIdx > 1) {
146
- execSync(`tmux split-window -h -t ${tmuxSession}`);
147
- execSync(`tmux select-layout -t ${tmuxSession} tiled`);
191
+ try {
192
+ execSync(`tmux split-window -h -t ${tmuxSession}`);
193
+ execSync(`tmux select-layout -t ${tmuxSession} tiled`);
194
+ }
195
+ catch {
196
+ // Non-fatal — continue with remaining workers even if layout fails
197
+ }
198
+ }
199
+ // Use spawnSync (no shell) so the scriptFile path is passed as a literal argument.
200
+ // Escape single quotes in the path for the shell inside the tmux pane.
201
+ const shellSafePath = scriptFile.replace(/'/g, "'\\''");
202
+ const sendResult = spawnSync('tmux', ['send-keys', '-t', tmuxSession, `node '${shellSafePath}'`, 'Enter'], { stdio: 'ignore' });
203
+ if (sendResult.status !== 0) {
204
+ console.error(` ✗ Worker ${workerId}: failed to send tmux keys`);
205
+ continue;
148
206
  }
149
- const cmd = `AGENTLOOM_WORKER_ID=${workerId} AGENTLOOM_SESSION=${sessionId} claude --print --dangerously-skip-permissions -p '${prompt.replace(/'/g, "'\"'\"'")}'; echo '[worker done]'; read`;
150
- execSync(`tmux send-keys -t ${tmuxSession} "${cmd}" Enter`);
151
- console.log(` ✓ Worker ${workerId} (${agentType}) launched in tmux pane`);
207
+ console.log(` ✓ Worker ${workerId} (${agentType})${READ_ONLY_ROLES.has(agentType) ? ' [read-only]' : ''} launched in tmux pane`);
152
208
  }
153
209
  }
154
- execSync(`tmux attach-session -t ${tmuxSession}`);
210
+ // Attach only in interactive terminals
211
+ if (process.stdout.isTTY) {
212
+ spawnSync('tmux', ['attach-session', '-t', tmuxSession], { stdio: 'inherit' });
213
+ }
214
+ else {
215
+ console.log(`\nTmux session: ${tmuxSession}`);
216
+ console.log(`Attach with: tmux attach-session -t ${tmuxSession}`);
217
+ }
155
218
  }
@@ -0,0 +1 @@
1
+ export declare function stop(args: string[]): Promise<void>;
@@ -0,0 +1,50 @@
1
+ import { readFile, readdir } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import { STATE_DIR } from '../state/session.js';
5
+ const WORKERS_DIR = join(STATE_DIR, 'workers');
6
+ export async function stop(args) {
7
+ if (!existsSync(WORKERS_DIR)) {
8
+ console.log('No active session.');
9
+ return;
10
+ }
11
+ const targetId = args[0]; // optional: stop a single worker
12
+ const files = await readdir(WORKERS_DIR);
13
+ const pidFiles = files
14
+ .filter(f => f.endsWith('.pid'))
15
+ .filter(f => !targetId || f === `${targetId}.pid`)
16
+ .sort();
17
+ if (pidFiles.length === 0) {
18
+ console.log(targetId ? `No PID file found for ${targetId}.` : 'No worker PID files found.');
19
+ return;
20
+ }
21
+ let killed = 0;
22
+ let notFound = 0;
23
+ for (const pidFile of pidFiles) {
24
+ const workerId = pidFile.replace('.pid', '');
25
+ const pidPath = join(WORKERS_DIR, pidFile);
26
+ const pid = parseInt(await readFile(pidPath, 'utf8').catch(() => ''), 10);
27
+ if (!pid || isNaN(pid)) {
28
+ console.log(` [${workerId}] no valid PID`);
29
+ continue;
30
+ }
31
+ try {
32
+ process.kill(pid, 'SIGTERM');
33
+ killed++;
34
+ console.log(` [${workerId}] killed (pid ${pid})`);
35
+ }
36
+ catch (err) {
37
+ if (err instanceof Error && 'code' in err && err.code === 'ESRCH') {
38
+ notFound++;
39
+ console.log(` [${workerId}] not running (pid ${pid} not found)`);
40
+ }
41
+ else {
42
+ console.log(` [${workerId}] error: ${err instanceof Error ? err.message : err}`);
43
+ }
44
+ }
45
+ }
46
+ console.log(`\n${killed} killed, ${notFound} already stopped.`);
47
+ if (killed > 0) {
48
+ console.log('State preserved. Run: loom reset --force to clear it.');
49
+ }
50
+ }
@@ -1,4 +1,5 @@
1
1
  import { type Task } from '../state/session.js';
2
+ export declare function recoverStaleClaims(): Promise<number>;
2
3
  export declare function claimTask(workerId: string): Promise<Task | null>;
3
4
  export declare function completeTask(task: Task, result: string): Promise<void>;
4
5
  export declare function failTask(task: Task, error: string): Promise<void>;
@@ -1,62 +1,142 @@
1
- import { readdir, readFile, rename, writeFile } from 'fs/promises';
1
+ import { readdir, readFile, rename, writeFile, stat, unlink } from 'fs/promises';
2
2
  import { join } from 'path';
3
- import { existsSync } from 'fs';
4
3
  import { STATE_DIR } from '../state/session.js';
5
4
  const TASKS_DIR = join(STATE_DIR, 'tasks');
5
+ const CLAIM_TTL_MS = 30 * 60 * 1000; // 30 minutes
6
+ // Recover tasks whose worker crashed before completing.
7
+ // Finds -claimed- files older than CLAIM_TTL_MS and re-queues them as -pending.
8
+ export async function recoverStaleClaims() {
9
+ let recovered = 0;
10
+ let files;
11
+ try {
12
+ files = await readdir(TASKS_DIR);
13
+ }
14
+ catch {
15
+ return 0;
16
+ }
17
+ const now = Date.now();
18
+ const claimed = files.filter(f => f.includes('-claimed-'));
19
+ for (const file of claimed) {
20
+ const filePath = join(TASKS_DIR, file);
21
+ try {
22
+ const { mtimeMs } = await stat(filePath);
23
+ if (now - mtimeMs < CLAIM_TTL_MS)
24
+ continue;
25
+ const taskId = file.split('-claimed-')[0];
26
+ if (!taskId)
27
+ continue;
28
+ const task = JSON.parse(await readFile(filePath, 'utf8'));
29
+ task.status = 'pending';
30
+ delete task.workerId;
31
+ delete task.claimedAt;
32
+ const pendingPath = join(TASKS_DIR, `${taskId}-pending.json`);
33
+ // Write the reset task to the pending path, then remove the stale claimed file.
34
+ // (Do NOT rename claimed→pending: that would overwrite our fresh write with stale data.)
35
+ await writeFile(pendingPath, JSON.stringify(task, null, 2));
36
+ await unlink(filePath).catch(() => { });
37
+ recovered++;
38
+ }
39
+ catch {
40
+ // Skip files we can't read/stat — don't crash the recovery pass
41
+ }
42
+ }
43
+ return recovered;
44
+ }
6
45
  export async function claimTask(workerId) {
7
- if (!existsSync(TASKS_DIR))
46
+ // Self-heal: recover any stale claimed tasks before scanning for pending ones
47
+ await recoverStaleClaims();
48
+ let files;
49
+ try {
50
+ files = await readdir(TASKS_DIR);
51
+ }
52
+ catch {
8
53
  return null;
9
- const files = await readdir(TASKS_DIR);
54
+ }
10
55
  const pending = files.filter(f => f.endsWith('-pending.json'));
11
56
  for (const file of pending) {
12
57
  const oldPath = join(TASKS_DIR, file);
13
- const task = JSON.parse(await readFile(oldPath, 'utf8'));
14
58
  const newFile = file.replace('-pending.json', `-claimed-${workerId}.json`);
15
59
  const newPath = join(TASKS_DIR, newFile);
16
- // Atomic rename = claim. First writer wins.
17
60
  try {
18
- await rename(oldPath, newPath);
61
+ // Prepare updated task object BEFORE the rename.
62
+ const task = JSON.parse(await readFile(oldPath, 'utf8'));
19
63
  task.status = 'claimed';
20
64
  task.workerId = workerId;
21
65
  task.claimedAt = new Date().toISOString();
22
- await writeFile(newPath, JSON.stringify(task, null, 2));
66
+ const updated = JSON.stringify(task, null, 2);
67
+ // Atomic claim — first writer wins
68
+ await rename(oldPath, newPath);
69
+ try {
70
+ await writeFile(newPath, updated);
71
+ }
72
+ catch (writeErr) {
73
+ // Rename succeeded but write failed — roll back so the task isn't orphaned
74
+ await rename(newPath, oldPath).catch(() => { });
75
+ // Log genuine I/O errors (disk full, permissions) — these are not race conditions
76
+ process.stderr.write(`[agentloom] claimTask writeFile failed for ${file}: ${writeErr}\n`);
77
+ // Continue to next task rather than crashing — another task may succeed
78
+ continue;
79
+ }
23
80
  return task;
24
81
  }
25
- catch {
26
- // Another worker claimed it first — try next
82
+ catch (err) {
83
+ // ENOENT/EPERM = another worker claimed it first — expected, try next file
84
+ // Any other error is unexpected; log and skip
85
+ const code = err.code;
86
+ if (code !== 'ENOENT' && code !== 'EPERM' && code !== 'EACCES') {
87
+ process.stderr.write(`[agentloom] claimTask unexpected error for ${file}: ${err}\n`);
88
+ }
27
89
  continue;
28
90
  }
29
91
  }
30
92
  return null;
31
93
  }
32
94
  export async function completeTask(task, result) {
33
- const claimedFile = join(TASKS_DIR, `${task.id}-claimed-${task.workerId}.json`);
34
- const doneFile = join(TASKS_DIR, `${task.id}-done-${task.workerId}.json`);
95
+ const workerId = task.workerId ?? 'unknown';
96
+ const claimedFile = join(TASKS_DIR, `${task.id}-claimed-${workerId}.json`);
97
+ const doneFile = join(TASKS_DIR, `${task.id}-done-${workerId}.json`);
35
98
  task.status = 'done';
36
99
  task.result = result;
37
100
  task.completedAt = new Date().toISOString();
38
- await writeFile(claimedFile, JSON.stringify(task, null, 2));
39
- await rename(claimedFile, doneFile);
101
+ try {
102
+ await writeFile(claimedFile, JSON.stringify(task, null, 2));
103
+ await rename(claimedFile, doneFile);
104
+ }
105
+ catch {
106
+ // Double-complete or missing claimed file — write directly to done path
107
+ await writeFile(doneFile, JSON.stringify(task, null, 2)).catch(() => { });
108
+ }
40
109
  }
41
110
  export async function failTask(task, error) {
42
- const claimedFile = join(TASKS_DIR, `${task.id}-claimed-${task.workerId}.json`);
43
- const failedFile = join(TASKS_DIR, `${task.id}-failed-${task.workerId}.json`);
111
+ const workerId = task.workerId ?? 'unknown';
112
+ const claimedFile = join(TASKS_DIR, `${task.id}-claimed-${workerId}.json`);
113
+ const failedFile = join(TASKS_DIR, `${task.id}-failed-${workerId}.json`);
44
114
  task.status = 'failed';
45
115
  task.error = error;
46
116
  task.completedAt = new Date().toISOString();
47
- await writeFile(claimedFile, JSON.stringify(task, null, 2));
48
- await rename(claimedFile, failedFile);
117
+ try {
118
+ await writeFile(claimedFile, JSON.stringify(task, null, 2));
119
+ await rename(claimedFile, failedFile);
120
+ }
121
+ catch {
122
+ await writeFile(failedFile, JSON.stringify(task, null, 2)).catch(() => { });
123
+ }
49
124
  }
50
125
  export async function pendingCount() {
51
- if (!existsSync(TASKS_DIR))
126
+ try {
127
+ const files = await readdir(TASKS_DIR);
128
+ return files.filter(f => f.endsWith('-pending.json')).length;
129
+ }
130
+ catch {
52
131
  return 0;
53
- const files = await readdir(TASKS_DIR);
54
- return files.filter(f => f.includes('-pending.json')).length;
132
+ }
55
133
  }
56
134
  export async function allDone() {
57
- if (!existsSync(TASKS_DIR))
135
+ try {
136
+ const files = await readdir(TASKS_DIR);
137
+ return !files.some(f => f.endsWith('-pending.json') || f.includes('-claimed-'));
138
+ }
139
+ catch {
58
140
  return true;
59
- const files = await readdir(TASKS_DIR);
60
- const active = files.filter(f => f.includes('-pending.json') || f.includes('-claimed-'));
61
- return active.length === 0;
141
+ }
62
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chuckssmith/agentloom",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",