@chuckssmith/agentloom 0.7.0 → 0.8.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
@@ -16,6 +16,7 @@ Usage:
16
16
  loom crew [N] "<task>" Spawn N parallel workers on a task
17
17
  loom crew 2:explore "<task>" Spawn typed workers (explore/plan/code-reviewer)
18
18
  loom crew --dry-run [N] "<task>" Preview decomposed subtasks without launching
19
+ loom crew --serial [N] "<task>" Run workers sequentially (each sees prior results)
19
20
  loom watch Live tail all worker logs (Ctrl+C to stop)
20
21
  loom stop Kill all background workers (SIGTERM)
21
22
  loom stop <workerId> Kill one worker
@@ -1,6 +1,7 @@
1
1
  import { execSync, spawn, spawnSync } from 'child_process';
2
2
  import { writeFile, mkdir, open } from 'fs/promises';
3
3
  import { join } from 'path';
4
+ import { existsSync } from 'fs';
4
5
  import { parseWorkerSpec, initSession, writeContextSnapshot, decomposeTasks, } from '../team/orchestrator.js';
5
6
  import { STATE_DIR } from '../state/session.js';
6
7
  const hasTmux = () => {
@@ -67,7 +68,8 @@ export async function crew(args) {
67
68
  process.exit(1);
68
69
  }
69
70
  const dryRun = args.includes('--dry-run');
70
- const filteredArgs = args.filter(a => a !== '--dry-run');
71
+ const serial = args.includes('--serial');
72
+ const filteredArgs = args.filter(a => a !== '--dry-run' && a !== '--serial');
71
73
  const { specs, task } = parseWorkerSpec(filteredArgs);
72
74
  const totalWorkers = specs.reduce((sum, s) => sum + s.count, 0);
73
75
  const slug = task.slice(0, 30).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
@@ -90,25 +92,78 @@ export async function crew(args) {
90
92
  return;
91
93
  }
92
94
  const useTmux = hasTmux() && !isWSL() && process.stdout.isTTY;
93
- console.log(`Mode: ${useTmux ? 'tmux' : 'background processes'}\n`);
95
+ const mode = serial ? 'serial' : useTmux ? 'tmux' : 'background processes';
96
+ console.log(`Mode: ${mode}\n`);
94
97
  const session = await initSession(task, totalWorkers);
95
98
  const contextPath = await writeContextSnapshot(slug, task);
96
99
  const tasks = await decomposeTasks(task, specs);
97
100
  console.log(`Session: ${session.id}`);
98
101
  console.log(`Tasks: ${tasks.length} created`);
99
102
  console.log(`Context: ${contextPath}\n`);
100
- if (useTmux) {
103
+ if (serial) {
104
+ await launchSerial(session.id, specs, tasks, contextPath);
105
+ console.log(`\nAll workers finished. Run: loom collect`);
106
+ }
107
+ else if (useTmux) {
101
108
  await launchTmux(session.id, specs, tasks, contextPath);
109
+ console.log(`\nWorkers launched. Monitor with:`);
110
+ console.log(` loom status`);
111
+ console.log(` loom stop (kill all workers)`);
102
112
  }
103
113
  else {
104
114
  await launchBackground(session.id, specs, tasks, contextPath);
115
+ console.log(`\nWorkers launched. Monitor with:`);
116
+ console.log(` loom status`);
117
+ console.log(` loom watch`);
118
+ console.log(` loom stop (kill all workers)`);
105
119
  }
106
- console.log(`\nWorkers launched. Monitor with:`);
107
- console.log(` loom status`);
108
- console.log(` loom watch`);
109
- console.log(` loom stop (kill all workers)`);
110
120
  console.log(`State dir: ${STATE_DIR}/`);
111
121
  }
122
+ async function launchSerial(sessionId, specs, tasks, contextPath) {
123
+ await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
124
+ let workerIdx = 0;
125
+ for (const spec of specs) {
126
+ for (let i = 0; i < spec.count; i++) {
127
+ const workerId = `w${String(workerIdx).padStart(2, '0')}`;
128
+ const subtask = tasks[workerIdx]?.description ?? tasks[0]?.description ?? '';
129
+ const agentType = tasks[workerIdx]?.agentType ?? spec.agentType;
130
+ workerIdx++;
131
+ // Each worker receives results from all previous workers via the context file
132
+ const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
133
+ const logFile = join(STATE_DIR, 'workers', `${workerId}.log`);
134
+ await writeFile(join(STATE_DIR, 'workers', `${workerId}-prompt.md`), prompt);
135
+ console.log(` → Worker ${workerId} (${agentType}) starting...`);
136
+ const claudeArgs = [
137
+ '--print',
138
+ ...(!READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
139
+ '-p',
140
+ prompt,
141
+ ];
142
+ // Run synchronously — block until this worker is done before starting the next
143
+ const result = spawnSync('claude', claudeArgs, {
144
+ encoding: 'utf8',
145
+ timeout: 30 * 60 * 1000, // 30 min max per worker
146
+ env: { ...process.env, AGENTLOOM_WORKER_ID: workerId, AGENTLOOM_SESSION: sessionId },
147
+ });
148
+ const output = (result.stdout ?? '') + (result.stderr ?? '');
149
+ await writeFile(logFile, output);
150
+ if (result.status !== 0) {
151
+ const resultFile = join(STATE_DIR, 'workers', `${workerId}-result.md`);
152
+ await writeFile(resultFile, `# Error\n\nWorker exited with code ${result.status ?? 'unknown'}\n\n${output.slice(-500)}`);
153
+ console.log(` ✗ Worker ${workerId} failed (exit ${result.status ?? '?'})`);
154
+ }
155
+ else {
156
+ // If worker didn't write its own result file, write a placeholder
157
+ const resultFile = join(STATE_DIR, 'workers', `${workerId}-result.md`);
158
+ if (!existsSync(resultFile)) {
159
+ const lastLines = output.trim().split('\n').slice(-20).join('\n');
160
+ await writeFile(resultFile, `# Result\n\n${lastLines}`);
161
+ }
162
+ console.log(` ✓ Worker ${workerId} done`);
163
+ }
164
+ }
165
+ }
166
+ }
112
167
  async function launchBackground(sessionId, specs, tasks, contextPath) {
113
168
  await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
114
169
  let workerIdx = 0;
@@ -1,8 +1,17 @@
1
1
  import { readSession, readTasks, STATE_DIR } from '../state/session.js';
2
2
  import { existsSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
- import { readdir } from 'fs/promises';
5
- const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes with no log growth = stale
4
+ import { readdir, readFile } from 'fs/promises';
5
+ const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 min with no log growth AND no live PID = stale
6
+ function isProcessAlive(pid) {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
6
15
  export async function status() {
7
16
  if (!existsSync(STATE_DIR)) {
8
17
  console.log('No active session. Run: loom crew "<task>"');
@@ -42,21 +51,34 @@ export async function status() {
42
51
  console.log(` [${workerId}] done ✓`);
43
52
  continue;
44
53
  }
45
- // Check if log is growing (worker is alive) or stale
54
+ // Check PID liveness first a quiet log doesn't mean a dead worker
55
+ const pidPath = join(workersDir, `${workerId}.pid`);
56
+ let pidAlive = false;
57
+ if (existsSync(pidPath)) {
58
+ const pid = parseInt(await readFile(pidPath, 'utf8').catch(() => ''), 10);
59
+ if (!isNaN(pid))
60
+ pidAlive = isProcessAlive(pid);
61
+ }
46
62
  const logStat = statSync(logPath);
47
63
  const msSinceWrite = now - logStat.mtimeMs;
48
- const isStale = msSinceWrite > STALE_THRESHOLD_MS;
49
64
  const logSize = logStat.size;
50
- if (logSize === 0) {
65
+ if (logSize === 0 && pidAlive) {
66
+ console.log(` [${workerId}] starting... (pid alive)`);
67
+ }
68
+ else if (logSize === 0) {
51
69
  console.log(` [${workerId}] starting...`);
52
70
  }
53
- else if (isStale) {
71
+ else if (pidAlive) {
72
+ const secs = Math.round(msSinceWrite / 1000);
73
+ console.log(` [${workerId}] running (pid alive, last log ${secs}s ago)`);
74
+ }
75
+ else if (msSinceWrite > STALE_THRESHOLD_MS) {
54
76
  const mins = Math.round(msSinceWrite / 60000);
55
- console.log(` [${workerId}] STALE — no activity for ${mins}m (log: ${logPath})`);
77
+ console.log(` [${workerId}] STALE — pid dead, no log activity for ${mins}m`);
56
78
  }
57
79
  else {
58
80
  const secs = Math.round(msSinceWrite / 1000);
59
- console.log(` [${workerId}] running (last activity ${secs}s ago)`);
81
+ console.log(` [${workerId}] stopped? — pid dead, last log ${secs}s ago`);
60
82
  }
61
83
  }
62
84
  const allDone = logFiles.every(f => existsSync(join(workersDir, f.replace('.log', '-result.md'))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chuckssmith/agentloom",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",