@chuckssmith/agentloom 1.0.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.
@@ -3,6 +3,15 @@ import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import { spawnSync } from 'child_process';
5
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)) {
@@ -17,6 +26,21 @@ export async function collect(args) {
17
26
  console.log('Workers still running? Check: loom logs');
18
27
  return;
19
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
+ }
20
44
  console.log(`\nCollecting results from ${resultFiles.length} worker(s)...\n`);
21
45
  const results = [];
22
46
  for (const f of resultFiles) {
@@ -145,7 +145,7 @@ async function launchSerial(sessionId, specs, tasks, contextPath, forcePermissio
145
145
  console.log(` → Worker ${workerId} (${agentType}) starting...`);
146
146
  const claudeArgs = [
147
147
  '--print',
148
- ...(forcePermissions || !READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
148
+ ...(forcePermissions && !READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
149
149
  '-p',
150
150
  prompt,
151
151
  ];
@@ -190,11 +190,16 @@ async function launchBackground(sessionId, specs, tasks, contextPath, forcePermi
190
190
  // Build args declaratively — no positional splicing
191
191
  const claudeArgs = [
192
192
  '--print',
193
- ...(forcePermissions || !READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
193
+ ...(forcePermissions && !READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
194
194
  '-p',
195
195
  prompt,
196
196
  ];
197
197
  const log = await open(logFile, 'w');
198
+ let logClosed = false;
199
+ const closeLog = () => { if (!logClosed) {
200
+ logClosed = true;
201
+ log.close().catch(() => { });
202
+ } };
198
203
  const child = spawn('claude', claudeArgs, {
199
204
  detached: true,
200
205
  stdio: ['ignore', log.fd, log.fd],
@@ -202,9 +207,9 @@ async function launchBackground(sessionId, specs, tasks, contextPath, forcePermi
202
207
  });
203
208
  child.on('error', async (err) => {
204
209
  await writeFile(join(STATE_DIR, 'workers', `${workerId}-result.md`), `# Launch Error\n\nFailed to start worker: ${err.message}\n`).catch(() => { });
205
- log.close().catch(() => { });
210
+ closeLog();
206
211
  });
207
- child.on('close', () => { log.close().catch(() => { }); });
212
+ child.on('close', () => { closeLog(); });
208
213
  if (child.pid != null) {
209
214
  await writeFile(pidFile, String(child.pid));
210
215
  }
@@ -221,11 +226,9 @@ async function launchTmux(sessionId, specs, tasks, contextPath, forcePermissions
221
226
  console.error(`tmux session "${tmuxSession}" already exists. Run: tmux kill-session -t ${tmuxSession}`);
222
227
  process.exit(1);
223
228
  }
224
- try {
225
- execSync(`tmux new-session -d -s ${tmuxSession} -x 220 -y 50`);
226
- }
227
- catch (err) {
228
- 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'}`);
229
232
  process.exit(1);
230
233
  }
231
234
  await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
@@ -238,18 +241,23 @@ async function launchTmux(sessionId, specs, tasks, contextPath, forcePermissions
238
241
  workerIdx++;
239
242
  const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
240
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`);
241
246
  const scriptFile = join(STATE_DIR, 'workers', `${workerId}-run.mjs`);
242
247
  await writeFile(promptFile, prompt);
243
- // 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);
244
251
  await writeFile(scriptFile, [
245
- `import { readFileSync } from 'fs'`,
252
+ `import { readFileSync, writeFileSync, openSync } from 'fs'`,
246
253
  `import { spawnSync } from 'child_process'`,
247
254
  `process.env.AGENTLOOM_WORKER_ID = ${JSON.stringify(workerId)}`,
248
255
  `process.env.AGENTLOOM_SESSION = ${JSON.stringify(sessionId)}`,
256
+ `writeFileSync(${JSON.stringify(pidFile)}, String(process.pid))`,
257
+ `const logFd = openSync(${JSON.stringify(logFile)}, 'w')`,
249
258
  `const prompt = readFileSync(${JSON.stringify(promptFile)}, 'utf8')`,
250
- `const args = ['--print', ${(forcePermissions || !READ_ONLY_ROLES.has(agentType)) ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
251
- `const r = spawnSync('claude', args, { stdio: 'inherit' })`,
252
- `console.log('[worker done]')`,
259
+ `const args = ['--print', ${skipPerms ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
260
+ `const r = spawnSync('claude', args, { stdio: ['ignore', logFd, logFd] })`,
253
261
  `process.exit(r.status ?? 0)`,
254
262
  ].join('\n'));
255
263
  if (workerIdx > 1) {
@@ -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');
@@ -2,9 +2,9 @@ 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
- 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';
@@ -21,8 +21,10 @@ function isProcessAlive(pid) {
21
21
  export async function watch(_args) {
22
22
  if (!existsSync(WORKERS_DIR)) {
23
23
  console.log('No active session. Run: loom crew "<task>"');
24
- process.exit(1);
24
+ return;
25
25
  }
26
+ const config = await loadConfig();
27
+ const STALE_TIMEOUT_MS = config.staleMinutes * 60 * 1000;
26
28
  console.log(`${DIM}Watching worker logs. Ctrl+C to stop.${RESET}\n`);
27
29
  const offsets = {};
28
30
  const lastGrowth = {};
@@ -102,7 +104,7 @@ export async function watch(_args) {
102
104
  }
103
105
  }
104
106
  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}`);
107
+ console.log(`\n${YELLOW}Workers stalled (dead PID, no output for ${config.staleMinutes}min): ${staleWorkers.join(', ')}${RESET}`);
106
108
  console.log(`${DIM}Run: loom logs <workerId> to inspect. loom collect to gather what's available.${RESET}`);
107
109
  break;
108
110
  }
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;
@@ -1,6 +1,8 @@
1
- import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
1
+ import { readFile, writeFile, mkdir, readdir, unlink } from 'fs/promises';
2
2
  import { join } from 'path';
3
- 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');
4
6
  export async function ensureStateDir() {
5
7
  await mkdir(join(STATE_DIR, 'tasks'), { recursive: true });
6
8
  await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
@@ -18,13 +20,32 @@ export async function readSession() {
18
20
  }
19
21
  }
20
22
  export async function writeTask(task) {
21
- await writeFile(join(STATE_DIR, 'tasks', `${task.id}-${task.status}.json`), JSON.stringify(task, null, 2));
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));
22
33
  }
23
34
  export async function readTasks() {
24
35
  const dir = join(STATE_DIR, 'tasks');
25
36
  try {
26
37
  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'))));
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()];
28
49
  }
29
50
  catch {
30
51
  return [];
@@ -28,7 +28,7 @@ export function parseWorkerSpec(args, defaultWorkers = 2, defaultAgentType = 'ge
28
28
  export async function initSession(description, workerCount) {
29
29
  await ensureStateDir();
30
30
  const session = {
31
- id: randomUUID().slice(0, 8),
31
+ id: randomUUID().replace(/-/g, '').slice(0, 16),
32
32
  description,
33
33
  status: 'running',
34
34
  workerCount,
@@ -52,7 +52,7 @@ export async function decomposeTasks(task, specs, dryRun = false) {
52
52
  for (const spec of specs) {
53
53
  for (let i = 0; i < spec.count; i++) {
54
54
  const t = {
55
- id: randomUUID().slice(0, 8),
55
+ id: randomUUID().replace(/-/g, '').slice(0, 16),
56
56
  description: subtasks[idx] ?? task,
57
57
  agentType: spec.agentType,
58
58
  status: 'pending',
@@ -80,10 +80,12 @@ Task: "${task}"`;
80
80
  if (result.status !== 0 || !result.stdout) {
81
81
  throw new Error(result.stderr ?? 'no output');
82
82
  }
83
- const match = result.stdout.match(/\[[\s\S]*\]/);
84
- if (!match)
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)
85
87
  throw new Error('No JSON array in response');
86
- const parsed = JSON.parse(match[0]);
88
+ const parsed = JSON.parse(result.stdout.slice(start, end + 1));
87
89
  if (!Array.isArray(parsed) || parsed.length === 0)
88
90
  throw new Error('Empty array');
89
91
  const subtasks = parsed.map(String);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chuckssmith/agentloom",
3
- "version": "1.0.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",