@chuckssmith/agentloom 0.6.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.
@@ -122,11 +122,13 @@ async function launchBackground(sessionId, specs, tasks, contextPath) {
122
122
  const logFile = join(STATE_DIR, 'workers', `${workerId}.log`);
123
123
  const pidFile = join(STATE_DIR, 'workers', `${workerId}.pid`);
124
124
  await writeFile(join(STATE_DIR, 'workers', `${workerId}-prompt.md`), prompt);
125
- const claudeArgs = ['--print', '-p', prompt];
126
- // Only pass --dangerously-skip-permissions to roles that write files
127
- if (!READ_ONLY_ROLES.has(agentType)) {
128
- claudeArgs.splice(2, 0, '--dangerously-skip-permissions');
129
- }
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
+ ];
130
132
  const log = await open(logFile, 'w');
131
133
  const child = spawn('claude', claudeArgs, {
132
134
  detached: true,
@@ -170,17 +172,20 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
170
172
  const agentType = tasks[workerIdx]?.agentType ?? spec.agentType;
171
173
  workerIdx++;
172
174
  const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
173
- // Write prompt and a runner script to disk — avoids ALL shell escaping issues
174
- const scriptFile = join(STATE_DIR, 'workers', `${workerId}-run.sh`);
175
- const permFlag = READ_ONLY_ROLES.has(agentType) ? '' : '--dangerously-skip-permissions ';
176
- await writeFile(join(STATE_DIR, 'workers', `${workerId}-prompt.md`), prompt);
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
177
179
  await writeFile(scriptFile, [
178
- '#!/bin/sh',
179
- `export AGENTLOOM_WORKER_ID=${workerId}`,
180
- `export AGENTLOOM_SESSION=${sessionId}`,
181
- `claude --print ${permFlag}-p "$(cat '${join(STATE_DIR, 'workers', `${workerId}-prompt.md`)}')"`,
182
- `echo '[worker done]'`,
183
- `read`,
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)`,
184
189
  ].join('\n'));
185
190
  if (workerIdx > 1) {
186
191
  try {
@@ -191,11 +196,12 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
191
196
  // Non-fatal — continue with remaining workers even if layout fails
192
197
  }
193
198
  }
194
- try {
195
- execSync(`tmux send-keys -t ${tmuxSession} "sh '${scriptFile}'" Enter`);
196
- }
197
- catch (err) {
198
- console.error(` ✗ Worker ${workerId}: failed to send tmux keys: ${err instanceof Error ? err.message : err}`);
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`);
199
205
  continue;
200
206
  }
201
207
  console.log(` ✓ Worker ${workerId} (${agentType})${READ_ONLY_ROLES.has(agentType) ? ' [read-only]' : ''} launched in tmux pane`);
@@ -1,10 +1,10 @@
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
3
  import { STATE_DIR } from '../state/session.js';
4
4
  const TASKS_DIR = join(STATE_DIR, 'tasks');
5
- const CLAIM_TTL_MS = 30 * 60 * 1000; // 30 minutes — claimed tasks older than this are re-queued
5
+ const CLAIM_TTL_MS = 30 * 60 * 1000; // 30 minutes
6
6
  // Recover tasks whose worker crashed before completing.
7
- // Finds -claimed- files older than CLAIM_TTL_MS and renames them back to -pending.json.
7
+ // Finds -claimed- files older than CLAIM_TTL_MS and re-queues them as -pending.
8
8
  export async function recoverStaleClaims() {
9
9
  let recovered = 0;
10
10
  let files;
@@ -19,24 +19,21 @@ export async function recoverStaleClaims() {
19
19
  for (const file of claimed) {
20
20
  const filePath = join(TASKS_DIR, file);
21
21
  try {
22
- const { mtimeMs } = await import('fs/promises').then(m => m.stat(filePath));
22
+ const { mtimeMs } = await stat(filePath);
23
23
  if (now - mtimeMs < CLAIM_TTL_MS)
24
24
  continue;
25
- // Parse task id from filename: {id}-claimed-{workerId}.json
26
25
  const taskId = file.split('-claimed-')[0];
27
26
  if (!taskId)
28
27
  continue;
29
- const pendingPath = join(TASKS_DIR, `${taskId}-pending.json`);
30
- // Re-read the file and reset status before writing back as pending
31
28
  const task = JSON.parse(await readFile(filePath, 'utf8'));
32
29
  task.status = 'pending';
33
30
  delete task.workerId;
34
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
35
  await writeFile(pendingPath, JSON.stringify(task, null, 2));
36
- await rename(filePath, pendingPath).catch(() => {
37
- // If the write succeeded but rename fails (e.g. destination now exists from another
38
- // recovery run), leave it — the pending file was already written
39
- });
36
+ await unlink(filePath).catch(() => { });
40
37
  recovered++;
41
38
  }
42
39
  catch {
@@ -61,11 +58,8 @@ export async function claimTask(workerId) {
61
58
  const newFile = file.replace('-pending.json', `-claimed-${workerId}.json`);
62
59
  const newPath = join(TASKS_DIR, newFile);
63
60
  try {
64
- // Prepare the updated task object BEFORE the rename.
65
- // If writeFile fails after a successful rename, we rename back so the task
66
- // re-enters the pending pool rather than being stuck as claimed with stale data.
67
- const raw = await readFile(oldPath, 'utf8');
68
- const task = JSON.parse(raw);
61
+ // Prepare updated task object BEFORE the rename.
62
+ const task = JSON.parse(await readFile(oldPath, 'utf8'));
69
63
  task.status = 'claimed';
70
64
  task.workerId = workerId;
71
65
  task.claimedAt = new Date().toISOString();
@@ -78,12 +72,20 @@ export async function claimTask(workerId) {
78
72
  catch (writeErr) {
79
73
  // Rename succeeded but write failed — roll back so the task isn't orphaned
80
74
  await rename(newPath, oldPath).catch(() => { });
81
- throw writeErr;
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;
82
79
  }
83
80
  return task;
84
81
  }
85
- catch {
86
- // Another worker claimed it first (ENOENT/EPERM), or rollback — 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
+ }
87
89
  continue;
88
90
  }
89
91
  }
@@ -101,7 +103,7 @@ export async function completeTask(task, result) {
101
103
  await rename(claimedFile, doneFile);
102
104
  }
103
105
  catch {
104
- // If the claimed file is already gone (double-complete), write directly to done path
106
+ // Double-complete or missing claimed file write directly to done path
105
107
  await writeFile(doneFile, JSON.stringify(task, null, 2)).catch(() => { });
106
108
  }
107
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chuckssmith/agentloom",
3
- "version": "0.6.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",