@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.
- package/dist/commands/collect.js +24 -0
- package/dist/commands/crew.js +22 -14
- package/dist/commands/setup.js +1 -1
- package/dist/commands/watch.js +5 -3
- package/dist/config.js +3 -1
- package/dist/state/session.d.ts +1 -1
- package/dist/state/session.js +25 -4
- package/dist/team/orchestrator.js +7 -5
- package/package.json +1 -1
package/dist/commands/collect.js
CHANGED
|
@@ -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) {
|
package/dist/commands/crew.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
210
|
+
closeLog();
|
|
206
211
|
});
|
|
207
|
-
child.on('close', () => {
|
|
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
|
-
|
|
225
|
-
|
|
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', ${
|
|
251
|
-
`const r = spawnSync('claude', args, { stdio: '
|
|
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) {
|
package/dist/commands/setup.js
CHANGED
|
@@ -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');
|
package/dist/commands/watch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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))
|
package/dist/state/session.d.ts
CHANGED
package/dist/state/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
84
|
-
|
|
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(
|
|
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);
|