@loompad/cli 1.2.4 → 1.2.6
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/CHANGELOG.md +44 -0
- package/dist/adapters/antigravity-cli.d.ts +7 -0
- package/dist/adapters/antigravity-cli.js +10 -2
- package/dist/adapters/antigravity-cli.js.map +1 -1
- package/dist/adapters/base.d.ts +5 -3
- package/dist/adapters/base.js +7 -2
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.js +25 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/opencode.d.ts +18 -21
- package/dist/adapters/opencode.js +103 -22
- package/dist/adapters/opencode.js.map +1 -1
- package/dist/cli/index.js +352 -29
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/ui.js +11 -1
- package/dist/cli/ui.js.map +1 -1
- package/dist/core/orchestra.js +17 -1
- package/dist/core/orchestra.js.map +1 -1
- package/dist/core/review-triage.js +3 -13
- package/dist/core/review-triage.js.map +1 -1
- package/dist/core/secret-scan.d.ts +14 -0
- package/dist/core/secret-scan.js +42 -0
- package/dist/core/secret-scan.js.map +1 -0
- package/dist/core/shares.d.ts +33 -0
- package/dist/core/shares.js +54 -0
- package/dist/core/shares.js.map +1 -0
- package/dist/core/turn-gate.d.ts +53 -0
- package/dist/core/turn-gate.js +126 -0
- package/dist/core/turn-gate.js.map +1 -0
- package/dist/core/turn-wait.d.ts +5 -1
- package/dist/core/turn-wait.js +3 -3
- package/dist/core/turn-wait.js.map +1 -1
- package/dist/daemon/client.d.ts +26 -0
- package/dist/daemon/client.js +106 -6
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/routes/chats.js +18 -0
- package/dist/daemon/routes/chats.js.map +1 -1
- package/dist/daemon/routes/public.d.ts +1 -1
- package/dist/daemon/routes/public.js +21 -0
- package/dist/daemon/routes/public.js.map +1 -1
- package/dist/daemon/runner.js +3 -1
- package/dist/daemon/runner.js.map +1 -1
- package/docs/release-notes/v1.2.5.md +19 -0
- package/docs/release-notes/v1.2.6.md +18 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import qrcode from "qrcode-terminal";
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
import { exitCodeFor, turnOutcome } from "../core/turn-wait.js";
|
|
30
|
-
import { DaemonClient, DaemonError, daemonRunning, desktopOwnsDaemon, ensureDaemon, stopDaemon, } from "../daemon/client.js";
|
|
30
|
+
import { DaemonClient, DaemonError, daemonRunning, desktopOwnsDaemon, ensureDaemon, stopDaemon, turnsInFlight, } from "../daemon/client.js";
|
|
31
31
|
import { installCrashGuards } from "../daemon/guards.js";
|
|
32
32
|
import { LoomDaemon, DEFAULT_PORT } from "../daemon/server.js";
|
|
33
33
|
import { ensureLoomHome, findProject, loomHome, readDaemonConfig } from "../core/registry.js";
|
|
@@ -200,7 +200,7 @@ program
|
|
|
200
200
|
.description("start the loom daemon in the background")
|
|
201
201
|
.option("--tailnet", "bind to this machine's Tailscale IP", false)
|
|
202
202
|
.option("--restart", "restart even if a daemon is already running", false)
|
|
203
|
-
.option("--force", "with --restart: replace even Loom Desktop's daemon with this CLI's")
|
|
203
|
+
.option("--force", "with --restart: replace even Loom Desktop's daemon with this CLI's, and restart even with turns running")
|
|
204
204
|
.action(async (opts) => {
|
|
205
205
|
const running = await daemonRunning();
|
|
206
206
|
if (running && !opts.force && (await desktopOwnsDaemon(running))) {
|
|
@@ -218,6 +218,14 @@ program
|
|
|
218
218
|
console.log(pc.dim("daemon already running (loom up --restart to bounce it)"));
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
221
|
+
// a restart kills turns in flight: say whose, and leave them be unless told otherwise
|
|
222
|
+
const busy = opts.force ? [] : await turnsInFlight(new DaemonClient(running));
|
|
223
|
+
if (busy.length) {
|
|
224
|
+
console.log(pc.yellow(`${busy.length} turn${busy.length === 1 ? " is" : "s are"} running (${busy.slice(0, 4).join(", ")}${busy.length > 4 ? ", …" : ""}) — ` +
|
|
225
|
+
`a restart would kill ${busy.length === 1 ? "it" : "them"}. Try again when they finish, or loom up --restart --force.`));
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
221
229
|
console.log(pc.dim(stale ? "daemon is running an older build — restarting" : "restarting daemon"));
|
|
222
230
|
await stopDaemon();
|
|
223
231
|
const gone = Date.now() + 6000;
|
|
@@ -274,9 +282,27 @@ program
|
|
|
274
282
|
return void console.log(JSON.stringify(projects)); // #315
|
|
275
283
|
if (!projects.length)
|
|
276
284
|
console.log(pc.dim("no projects yet — loom init"));
|
|
277
|
-
|
|
278
|
-
|
|
285
|
+
// inside a project, it comes first and says so: "is my turn running here?" is the usual question
|
|
286
|
+
const here = await resolveCurrentProject(client).catch(() => null);
|
|
287
|
+
const ordered = here ? [...projects.filter((p) => p.id === here.id), ...projects.filter((p) => p.id !== here.id)] : projects;
|
|
288
|
+
for (const p of ordered)
|
|
289
|
+
console.log(formatProjectRow(p) + (here && p.id === here.id ? pc.bold(pc.cyan(" ← here")) : ""));
|
|
279
290
|
});
|
|
291
|
+
/**
|
|
292
|
+
* Outside a project there's no roster, but the question "what can I run?" still has an
|
|
293
|
+
* answer: the agents installed on this machine (#feedback: agents outside a project).
|
|
294
|
+
*/
|
|
295
|
+
async function printMachineAgents(json) {
|
|
296
|
+
const { ADES, detectAdes } = await import("../core/ades.js");
|
|
297
|
+
const found = await detectAdes();
|
|
298
|
+
const rows = ADES.map((a) => ({ kind: a.kind, label: a.label, tier: a.tier, installed: a.tier === "adapter" ? Boolean(found[a.kind]) : null }));
|
|
299
|
+
if (json)
|
|
300
|
+
return void console.log(JSON.stringify({ project: null, machine: rows }));
|
|
301
|
+
console.log(pc.dim("not in a Loom project — this machine can drive:"));
|
|
302
|
+
for (const r of rows)
|
|
303
|
+
console.log(` ${r.kind.padEnd(18)} ${r.label.padEnd(20)} ${r.installed === false ? pc.dim("not installed") : r.installed ? pc.green("installed") : pc.dim("app (launch it)")}`);
|
|
304
|
+
console.log(pc.dim("\nloom init here to make this folder a project with them"));
|
|
305
|
+
}
|
|
280
306
|
// ---------------------------------------------------------------------------
|
|
281
307
|
// init / projects / agents
|
|
282
308
|
// ---------------------------------------------------------------------------
|
|
@@ -335,7 +361,10 @@ program
|
|
|
335
361
|
.option("--json", "print the agent roster as JSON")
|
|
336
362
|
.action(async (opts) => {
|
|
337
363
|
const client = await ensureDaemon();
|
|
338
|
-
const project = await
|
|
364
|
+
const project = await resolveCurrentProject(client).catch((err) => { if (err instanceof NoProjectError)
|
|
365
|
+
return null; throw err; });
|
|
366
|
+
if (!project)
|
|
367
|
+
return void (await printMachineAgents(Boolean(opts.json)));
|
|
339
368
|
if (opts.json) {
|
|
340
369
|
console.log(JSON.stringify(project.agents));
|
|
341
370
|
return;
|
|
@@ -1436,11 +1465,34 @@ const TASK_COLOR = {
|
|
|
1436
1465
|
needs_input: pc.yellow,
|
|
1437
1466
|
cancelled: pc.dim,
|
|
1438
1467
|
};
|
|
1468
|
+
/**
|
|
1469
|
+
* A run's tasks by outcome (#342: "completed 9/13" said nothing about the other four — dropped,
|
|
1470
|
+
* merged elsewhere, failed?). Only the non-zero ones, done first.
|
|
1471
|
+
*/
|
|
1472
|
+
function taskCounts(run) {
|
|
1473
|
+
const order = ["done", "running", "pending", "needs_input", "conflict", "failed", "cancelled"];
|
|
1474
|
+
const n = {};
|
|
1475
|
+
for (const t of run.tasks)
|
|
1476
|
+
n[t.status] = (n[t.status] ?? 0) + 1;
|
|
1477
|
+
return Object.fromEntries(order.filter((k) => n[k]).map((k) => [k, n[k]]).concat(Object.entries(n).filter(([k]) => !order.includes(k))));
|
|
1478
|
+
}
|
|
1479
|
+
const countsLine = (run) => {
|
|
1480
|
+
const c = taskCounts(run);
|
|
1481
|
+
return Object.keys(c).length ? Object.entries(c).map(([k, v]) => `${v} ${k.replace("_", " ")}`).join(" · ") : "no tasks";
|
|
1482
|
+
};
|
|
1483
|
+
/** What `loom orchestra --json` says about a run — status is one of OrchestraStatus (#341). */
|
|
1484
|
+
function runJson(run) {
|
|
1485
|
+
return {
|
|
1486
|
+
id: run.id, goal: run.goal, status: run.status, ...(run.status === "aborted" && run.interrupted ? { resumable: true } : {}),
|
|
1487
|
+
terminal: isTerminalStatus(run.status), tasks: { total: run.tasks.length, ...taskCounts(run) }, branch: run.branch, costUsd: run.costUsd,
|
|
1488
|
+
...(run.landing?.pr ? { pr: run.landing.pr } : {}), ...(run.question ? { question: run.question } : {}), createdAt: run.createdAt, updatedAt: run.updatedAt,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
const isTerminalStatus = (s) => s === "completed" || s === "failed" || s === "aborted" || s === "moved";
|
|
1439
1492
|
function printRun(run) {
|
|
1440
|
-
const done = run.tasks.filter((t) => t.status === "done").length;
|
|
1441
1493
|
console.log(run.race
|
|
1442
|
-
? `${pc.magenta("🏁")} ${pc.bold(run.id)} ${pc.dim(runStatus(run))} race · ${
|
|
1443
|
-
: `${pc.magenta("🎼")} ${pc.bold(run.id)} ${pc.dim(runStatus(run))} ${
|
|
1494
|
+
? `${pc.magenta("🏁")} ${pc.bold(run.id)} ${pc.dim(runStatus(run))} race · ${countsLine(run)} · ${fmtUsd(run.costUsd)}`
|
|
1495
|
+
: `${pc.magenta("🎼")} ${pc.bold(run.id)} ${pc.dim(runStatus(run))} ${countsLine(run)} · ` +
|
|
1444
1496
|
`round ${run.round}/${run.maxRounds} · ${fmtUsd(run.costUsd)} · ${pc.dim(run.branch)}`);
|
|
1445
1497
|
console.log(` ${pc.dim("goal")} ${run.goal}`);
|
|
1446
1498
|
if (run.race)
|
|
@@ -1475,6 +1527,58 @@ function printRun(run) {
|
|
|
1475
1527
|
if (run.race && run.applied?.task)
|
|
1476
1528
|
console.log(` ${pc.green("kept:")} ${run.applied.task} → ${run.applied.into}`);
|
|
1477
1529
|
}
|
|
1530
|
+
/**
|
|
1531
|
+
* #340: wait for a run to end; if it completed, scan everything it changed for secrets, merge it
|
|
1532
|
+
* into the project's current branch, and (asked to) push. Stops at the first thing that's wrong,
|
|
1533
|
+
* and says what — a run that failed, a key in the diff, a merge or push that didn't go.
|
|
1534
|
+
*/
|
|
1535
|
+
async function waitScanApply(client, projectId, dir, runId, opts) {
|
|
1536
|
+
let run = (await client.orchestraRun(projectId, runId)).run;
|
|
1537
|
+
while (!isTerminalStatus(run.status) && run.status !== "waiting_human") {
|
|
1538
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
1539
|
+
run = (await client.orchestraRun(projectId, runId)).run;
|
|
1540
|
+
}
|
|
1541
|
+
if (run.status !== "completed")
|
|
1542
|
+
return { ok: false, runId, status: run.status, error: run.status === "waiting_human" ? `it's waiting on you: ${run.question ?? "a question"}` : (run.error ?? run.status) };
|
|
1543
|
+
const { execFileSync } = await import("node:child_process");
|
|
1544
|
+
const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
1545
|
+
let diff = "";
|
|
1546
|
+
try {
|
|
1547
|
+
diff = git("diff", `${run.baseCommit}..${run.branch}`);
|
|
1548
|
+
}
|
|
1549
|
+
catch (e) {
|
|
1550
|
+
return { ok: false, runId, status: run.status, error: `couldn't read the run's changes: ${e.message.split("\n")[0]}` };
|
|
1551
|
+
}
|
|
1552
|
+
const { findSecrets } = await import("../core/secret-scan.js");
|
|
1553
|
+
const secrets = findSecrets(diff);
|
|
1554
|
+
if (secrets.length)
|
|
1555
|
+
return { ok: false, runId, status: run.status, secrets, error: "secrets in the run's changes — not applied" };
|
|
1556
|
+
let out;
|
|
1557
|
+
try {
|
|
1558
|
+
out = await client.orchestraAction(projectId, runId, "apply");
|
|
1559
|
+
}
|
|
1560
|
+
catch (e) {
|
|
1561
|
+
return { ok: false, runId, status: run.status, error: `merge failed: ${e.message}` };
|
|
1562
|
+
}
|
|
1563
|
+
const res = { ok: true, runId, status: run.status, merged: String(out.merged ?? run.branch), into: String(out.into ?? "") };
|
|
1564
|
+
if (opts.push) {
|
|
1565
|
+
try {
|
|
1566
|
+
git("push", "origin", "HEAD");
|
|
1567
|
+
res.pushed = true;
|
|
1568
|
+
}
|
|
1569
|
+
catch (e) {
|
|
1570
|
+
return { ...res, ok: false, pushed: false, error: `merged, but the push failed: ${e.message.split("\n")[0]}` };
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return res;
|
|
1574
|
+
}
|
|
1575
|
+
function printApplyResult(r) {
|
|
1576
|
+
if (r.ok)
|
|
1577
|
+
return void console.log(`${pc.green("✓")} ${r.runId} merged into ${pc.bold(r.into || "your branch")}${r.pushed ? " and pushed" : ""} — no secrets in its changes`);
|
|
1578
|
+
console.log(`${pc.red("✗")} ${r.runId} (${r.status}): ${r.error}`);
|
|
1579
|
+
for (const s of r.secrets ?? [])
|
|
1580
|
+
console.log(pc.red(` ${s.file}${s.line ? `:${s.line}` : ""} — ${s.what}`));
|
|
1581
|
+
}
|
|
1478
1582
|
async function watchRun(client, projectId, runId) {
|
|
1479
1583
|
let last = "";
|
|
1480
1584
|
for (;;) {
|
|
@@ -1507,6 +1611,8 @@ program
|
|
|
1507
1611
|
.option("--max-usd <n>", "stop this goal when it has spent this much, and say so")
|
|
1508
1612
|
.option("--race", "race: every worker takes the whole goal in its own worktree; pick one with orchestra:apply <run> --task race-<agent>")
|
|
1509
1613
|
.option("--solo", "keep every task on this machine, even when teammates' runners could take some")
|
|
1614
|
+
.option("--apply", "when the run completes, scan its changes for secrets and merge it into your branch (#340)")
|
|
1615
|
+
.option("--push", "with --apply: push the branch after merging")
|
|
1510
1616
|
.option("--no-watch", "start it and return instead of following it to the end")
|
|
1511
1617
|
.action(async (goal, opts) => {
|
|
1512
1618
|
const client = await ensureDaemon();
|
|
@@ -1532,8 +1638,14 @@ program
|
|
|
1532
1638
|
`${run.workers.join(", ")} (${run.maxParallel} in parallel) on ${pc.dim(run.branch)}`);
|
|
1533
1639
|
if (opts.watch)
|
|
1534
1640
|
await watchRun(client, project.id, run.id);
|
|
1535
|
-
else
|
|
1641
|
+
else if (!opts.apply)
|
|
1536
1642
|
console.log(pc.dim(` follow it: loom orchestra ${run.id} --watch`));
|
|
1643
|
+
if (opts.apply) {
|
|
1644
|
+
const res = await waitScanApply(client, project.id, project.dir, run.id, { push: Boolean(opts.push) });
|
|
1645
|
+
printApplyResult(res);
|
|
1646
|
+
if (!res.ok)
|
|
1647
|
+
process.exitCode = 1;
|
|
1648
|
+
}
|
|
1537
1649
|
}
|
|
1538
1650
|
catch (err) {
|
|
1539
1651
|
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
@@ -1542,24 +1654,41 @@ program
|
|
|
1542
1654
|
});
|
|
1543
1655
|
program
|
|
1544
1656
|
.command("orchestra [runId]")
|
|
1545
|
-
.description("orchestra runs in this project — or one run in detail")
|
|
1657
|
+
.description("orchestra runs in this project — or one run in detail. Status: starting | planning | running | reviewing | waiting_human | completed | failed | aborted | moved")
|
|
1546
1658
|
.option("--watch", "follow the run until it finishes")
|
|
1659
|
+
.option("--json", "print runs (or one run) as JSON")
|
|
1660
|
+
.option("--wait", "with a run id: wait until it's completed, failed or aborted (or asks you something); exit 0 completed, 1 failed/aborted, 2 waiting on you")
|
|
1661
|
+
.option("--timeout <seconds>", "with --wait: give up after this long (exit 124)", "7200")
|
|
1547
1662
|
.action(async (runId, opts) => {
|
|
1548
1663
|
const client = await ensureDaemon();
|
|
1549
1664
|
const project = await currentProject(client);
|
|
1550
1665
|
try {
|
|
1551
1666
|
if (runId) {
|
|
1667
|
+
if (opts.wait) {
|
|
1668
|
+
// "reviewing" is the orchestrator reading results, not the end (#341) — only these are
|
|
1669
|
+
const deadline = Date.now() + Math.max(1, Number(opts.timeout) || 7200) * 1000;
|
|
1670
|
+
let run = (await client.orchestraRun(project.id, runId)).run;
|
|
1671
|
+
while (!isTerminalStatus(run.status) && run.status !== "waiting_human" && Date.now() < deadline) {
|
|
1672
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1673
|
+
run = (await client.orchestraRun(project.id, runId)).run;
|
|
1674
|
+
}
|
|
1675
|
+
const done = isTerminalStatus(run.status) || run.status === "waiting_human";
|
|
1676
|
+
process.exitCode = !done ? 124 : run.status === "completed" ? 0 : run.status === "waiting_human" ? 2 : 1;
|
|
1677
|
+
return void (opts.json ? console.log(JSON.stringify(runJson(run))) : printRun(run));
|
|
1678
|
+
}
|
|
1552
1679
|
if (opts.watch)
|
|
1553
1680
|
return void (await watchRun(client, project.id, runId));
|
|
1554
|
-
|
|
1681
|
+
const { run } = await client.orchestraRun(project.id, runId);
|
|
1682
|
+
return void (opts.json ? console.log(JSON.stringify(runJson(run))) : printRun(run));
|
|
1555
1683
|
}
|
|
1556
1684
|
const { runs } = await client.orchestraRuns(project.id);
|
|
1685
|
+
if (opts.json)
|
|
1686
|
+
return void console.log(JSON.stringify(runs.map(runJson)));
|
|
1557
1687
|
if (!runs.length)
|
|
1558
1688
|
return void console.log(pc.dim('no orchestra runs yet — loom orchestrate "<goal>"'));
|
|
1559
1689
|
for (const r of runs.slice(0, 15)) {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
`${pc.dim(new Date(r.createdAt).toLocaleString())} ${r.goal.slice(0, 70)}`);
|
|
1690
|
+
console.log(`${pc.bold(r.id)} ${runStatus(r).padEnd(13)} ${countsLine(r).padEnd(30)} ` +
|
|
1691
|
+
`${pc.dim(new Date(r.createdAt).toLocaleString())} ${r.goal.slice(0, 60)}`);
|
|
1563
1692
|
}
|
|
1564
1693
|
}
|
|
1565
1694
|
catch (err) {
|
|
@@ -2528,13 +2657,175 @@ program
|
|
|
2528
2657
|
process.exitCode = 1;
|
|
2529
2658
|
}
|
|
2530
2659
|
});
|
|
2660
|
+
program
|
|
2661
|
+
.command("share [chat]")
|
|
2662
|
+
.description("a read-only link to a chat (default: main) for a teammate: prompts, replies, what ran and changed. It expires; --list shows yours, --revoke ends one (#199)")
|
|
2663
|
+
.option("--hours <n>", "how long the link works (1 to 720, default 24)")
|
|
2664
|
+
.option("--list", "the links that still work")
|
|
2665
|
+
.option("--revoke <id>", "end a link now")
|
|
2666
|
+
.option("--json", "print as JSON")
|
|
2667
|
+
.action(async (chat, opts) => {
|
|
2668
|
+
const client = await ensureDaemon();
|
|
2669
|
+
if (opts.revoke) {
|
|
2670
|
+
const { revoked } = await client.revokeShare(opts.revoke);
|
|
2671
|
+
if (!revoked)
|
|
2672
|
+
fail(`no link ${opts.revoke} — loom share --list`);
|
|
2673
|
+
return void console.log(`${pc.green("✓")} link ${opts.revoke} no longer opens`);
|
|
2674
|
+
}
|
|
2675
|
+
if (opts.list) {
|
|
2676
|
+
const { shares } = await client.shares();
|
|
2677
|
+
if (opts.json)
|
|
2678
|
+
return void console.log(JSON.stringify(shares));
|
|
2679
|
+
if (!shares.length)
|
|
2680
|
+
return void console.log(pc.dim("no links open — loom share [chat]"));
|
|
2681
|
+
for (const s of shares)
|
|
2682
|
+
console.log(`${pc.bold(s.id)} ${s.title} ${pc.dim(`until ${new Date(s.expiresAt).toLocaleString()}`)}`);
|
|
2683
|
+
return;
|
|
2684
|
+
}
|
|
2685
|
+
const project = await currentProject(client);
|
|
2686
|
+
let chatId = "main";
|
|
2687
|
+
if (chat && chat !== "main") {
|
|
2688
|
+
const { chats } = await client.chats(project.id);
|
|
2689
|
+
const hit = chats.find((c) => c.id === chat || c.title.toLowerCase() === chat.toLowerCase());
|
|
2690
|
+
if (!hit)
|
|
2691
|
+
fail(`no chat "${chat}" here — ${chats.slice(0, 6).map((c) => c.title).join(", ") || "only main"}`);
|
|
2692
|
+
chatId = hit.id;
|
|
2693
|
+
}
|
|
2694
|
+
const out = await client.shareChat(project.id, chatId, opts.hours ? Number(opts.hours) : undefined);
|
|
2695
|
+
if (opts.json)
|
|
2696
|
+
return void console.log(JSON.stringify(out));
|
|
2697
|
+
console.log(`${pc.green("✓")} ${out.title} — read-only, until ${new Date(out.expiresAt).toLocaleString()}\n\n ${out.url}\n`);
|
|
2698
|
+
console.log(pc.dim(`anyone who can reach this machine with the link can read it (on another computer: start the daemon with loom up --tailnet). loom share --revoke ${out.id} ends it`));
|
|
2699
|
+
});
|
|
2700
|
+
program
|
|
2701
|
+
.command("chain [file]")
|
|
2702
|
+
.description("run goals in several projects, one after another: each line is `<project> :: <goal>` (project = name or folder). Each completed run is scanned for secrets and merged before the next starts (#340)")
|
|
2703
|
+
.option("--push", "push each project's branch after merging its run")
|
|
2704
|
+
.option("--keep-going", "carry on to the next project when one fails")
|
|
2705
|
+
.option("--json", "print a JSON summary at the end")
|
|
2706
|
+
.action(async (file, opts) => {
|
|
2707
|
+
const text = file && file !== "-" ? fs.readFileSync(file, "utf8") : await new Promise((resolve) => {
|
|
2708
|
+
let buf = "";
|
|
2709
|
+
process.stdin.setEncoding("utf8");
|
|
2710
|
+
process.stdin.on("data", (d) => (buf += d));
|
|
2711
|
+
process.stdin.on("end", () => resolve(buf));
|
|
2712
|
+
});
|
|
2713
|
+
const steps = text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => {
|
|
2714
|
+
const at = l.indexOf("::");
|
|
2715
|
+
if (at < 0)
|
|
2716
|
+
fail(`each line is "<project> :: <goal>" — got: ${l}`);
|
|
2717
|
+
return { project: l.slice(0, at).trim(), goal: l.slice(at + 2).trim() };
|
|
2718
|
+
});
|
|
2719
|
+
if (!steps.length)
|
|
2720
|
+
fail("nothing to run — one `<project> :: <goal>` per line");
|
|
2721
|
+
const client = await ensureDaemon();
|
|
2722
|
+
const { projects } = await client.listProjects();
|
|
2723
|
+
const results = [];
|
|
2724
|
+
for (const step of steps) {
|
|
2725
|
+
const p = projects.find((x) => x.name === step.project || x.id === step.project || path.resolve(x.dir) === path.resolve(step.project));
|
|
2726
|
+
if (!p) {
|
|
2727
|
+
results.push({ project: step.project, ok: false, runId: "-", status: "not started", error: `no project "${step.project}" — loom projects` });
|
|
2728
|
+
if (!opts.keepGoing)
|
|
2729
|
+
break;
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
if (!opts.json)
|
|
2733
|
+
console.log(`${pc.magenta("▶")} ${pc.bold(p.name)} — ${step.goal.slice(0, 80)}`);
|
|
2734
|
+
try {
|
|
2735
|
+
const { run } = await client.startOrchestra(p.id, { goal: step.goal });
|
|
2736
|
+
const r = { project: p.name, ...(await waitScanApply(client, p.id, p.dir, run.id, { push: Boolean(opts.push) })) };
|
|
2737
|
+
results.push(r);
|
|
2738
|
+
if (!opts.json)
|
|
2739
|
+
printApplyResult(r);
|
|
2740
|
+
if (!r.ok && !opts.keepGoing)
|
|
2741
|
+
break;
|
|
2742
|
+
}
|
|
2743
|
+
catch (e) {
|
|
2744
|
+
results.push({ project: p.name, ok: false, runId: "-", status: "not started", error: e.message });
|
|
2745
|
+
if (!opts.json)
|
|
2746
|
+
console.log(`${pc.red("✗")} ${p.name}: ${e.message}`);
|
|
2747
|
+
if (!opts.keepGoing)
|
|
2748
|
+
break;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
2752
|
+
if (opts.json)
|
|
2753
|
+
console.log(JSON.stringify({ ok: failed === 0, steps: results }));
|
|
2754
|
+
else
|
|
2755
|
+
console.log(pc.dim(`\n${results.length - failed}/${steps.length} merged${failed ? ` · ${failed} failed` : ""}${results.length < steps.length ? ` · ${steps.length - results.length} not run` : ""}`));
|
|
2756
|
+
if (failed || results.length < steps.length)
|
|
2757
|
+
process.exitCode = 1;
|
|
2758
|
+
});
|
|
2759
|
+
program
|
|
2760
|
+
.command("limits")
|
|
2761
|
+
.description("how many agent turns run at once across every project, and how much memory stays free — past either, turns queue (#339)")
|
|
2762
|
+
.option("--turns <n>", "turns at once across all projects (0 = no cap)")
|
|
2763
|
+
.option("--min-free <pct>", "don't start a turn while less than this % of memory is free (0 = off)")
|
|
2764
|
+
.option("--json", "print as JSON")
|
|
2765
|
+
.action(async (opts) => {
|
|
2766
|
+
const { readLimits, writeLimits, availableMemoryPct, defaultLimits } = await import("../core/turn-gate.js");
|
|
2767
|
+
const patch = {};
|
|
2768
|
+
if (opts.turns !== undefined) {
|
|
2769
|
+
const n = Number(opts.turns);
|
|
2770
|
+
if (!Number.isInteger(n) || n < 0)
|
|
2771
|
+
fail("--turns takes a whole number (0 = no cap)");
|
|
2772
|
+
patch.maxTurns = n;
|
|
2773
|
+
}
|
|
2774
|
+
if (opts.minFree !== undefined) {
|
|
2775
|
+
const n = Number(opts.minFree);
|
|
2776
|
+
if (!(n >= 0 && n < 100))
|
|
2777
|
+
fail("--min-free takes a % from 0 to 99");
|
|
2778
|
+
patch.minFreePct = n;
|
|
2779
|
+
}
|
|
2780
|
+
const limits = Object.keys(patch).length ? writeLimits(patch) : readLimits();
|
|
2781
|
+
const free = Math.round(availableMemoryPct());
|
|
2782
|
+
const cfg = await daemonRunning();
|
|
2783
|
+
const running = cfg ? await turnsInFlight(new DaemonClient(cfg)) : [];
|
|
2784
|
+
if (opts.json)
|
|
2785
|
+
return void console.log(JSON.stringify({ ...limits, freePct: free, running, defaults: defaultLimits() }));
|
|
2786
|
+
if (Object.keys(patch).length)
|
|
2787
|
+
console.log(pc.green("✓ saved — the daemon picks it up within a few seconds"));
|
|
2788
|
+
console.log(`turns at once ${limits.maxTurns ? pc.bold(String(limits.maxTurns)) : pc.dim("no cap")} ${pc.dim(`(running now: ${running.length}${running.length ? ` — ${running.slice(0, 4).join(", ")}` : ""})`)}`);
|
|
2789
|
+
console.log(`memory kept free ${limits.minFreePct ? pc.bold(`${limits.minFreePct}%`) : pc.dim("off")} ${pc.dim(`(free now: ${free}%)`)}`);
|
|
2790
|
+
console.log(pc.dim("past either, a new turn waits for a slot and says so in its thread; one that waits 10 min starts anyway"));
|
|
2791
|
+
});
|
|
2792
|
+
program
|
|
2793
|
+
.command("agents:mode <agentId> [mode]")
|
|
2794
|
+
.description("an agent's access: bypass (no sandbox — can commit, push, use the network), auto (the default), or ask. No mode: show what each means for it")
|
|
2795
|
+
.action(async (agentId, mode) => {
|
|
2796
|
+
const client = await ensureDaemon();
|
|
2797
|
+
const project = await currentProject(client);
|
|
2798
|
+
const agent = project.agents.find((a) => a.id === agentId);
|
|
2799
|
+
if (!agent)
|
|
2800
|
+
fail(`no agent "${agentId}" here — loom agents`);
|
|
2801
|
+
const { PERMISSION_PROFILES } = await import("../core/permissions.js");
|
|
2802
|
+
const profile = PERMISSION_PROFILES[agent.kind];
|
|
2803
|
+
if (!profile)
|
|
2804
|
+
fail(`${agent.kind} has no access modes to choose from`);
|
|
2805
|
+
const now = (agent.permissions ?? profile.default);
|
|
2806
|
+
if (!mode) {
|
|
2807
|
+
for (const [m, cell] of Object.entries(profile.modes)) {
|
|
2808
|
+
const off = cell.unsupported;
|
|
2809
|
+
console.log(`${m === now ? pc.green("●") : pc.dim("○")} ${pc.bold(m.padEnd(6))} ${off ? pc.dim(`${cell.label} — unavailable: ${off}`) : cell.label}`);
|
|
2810
|
+
}
|
|
2811
|
+
return void console.log(pc.dim(`\nloom agents:mode ${agentId} <bypass|auto|ask>`));
|
|
2812
|
+
}
|
|
2813
|
+
const res = await client.setAgentPermissions(project.id, agentId, mode);
|
|
2814
|
+
const cell = profile.modes[(res.agent.permissions ?? mode)];
|
|
2815
|
+
console.log(`${pc.green("✓")} ${agentId}: ${cell?.label ?? mode}`);
|
|
2816
|
+
if (mode === "bypass")
|
|
2817
|
+
console.log(pc.yellow(" no sandbox: it can commit, push, reach the network and run anything — on this machine, as you"));
|
|
2818
|
+
});
|
|
2531
2819
|
program
|
|
2532
2820
|
.command("agents:available")
|
|
2533
2821
|
.description("which agents this machine can drive, and how many sessions are here")
|
|
2534
2822
|
.option("--json", "print as JSON")
|
|
2535
2823
|
.action(async (opts) => {
|
|
2536
2824
|
const client = await ensureDaemon();
|
|
2537
|
-
const project = await
|
|
2825
|
+
const project = await resolveCurrentProject(client).catch((err) => { if (err instanceof NoProjectError)
|
|
2826
|
+
return null; throw err; });
|
|
2827
|
+
if (!project)
|
|
2828
|
+
return void (await printMachineAgents(Boolean(opts.json)));
|
|
2538
2829
|
const { ades } = await client.availableAgents(project.id);
|
|
2539
2830
|
if (opts.json)
|
|
2540
2831
|
return void console.log(JSON.stringify(ades, null, 2));
|
|
@@ -2615,26 +2906,31 @@ async function offerGitignore(dir, choice) {
|
|
|
2615
2906
|
return; // already ignored
|
|
2616
2907
|
}
|
|
2617
2908
|
catch { /* not ignored (exit 1), or no git: fall through */ }
|
|
2618
|
-
|
|
2909
|
+
// in a terminal, ask; in a script (no one to ask), do it — .loom/ is never meant for the repo
|
|
2910
|
+
const yes = choice === true || !process.stdin.isTTY || (await confirm(pc.yellow("add .loom/ to .gitignore? it's this machine's state (event log, attachments) — not for the repo")));
|
|
2619
2911
|
if (!yes)
|
|
2620
2912
|
return void console.log(pc.dim("tip: .loom/ is local state — add it to .gitignore (loom init --gitignore)"));
|
|
2621
2913
|
const file = path.join(dir, ".gitignore");
|
|
2622
2914
|
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
2623
2915
|
fs.writeFileSync(file, `${before}${before && !before.endsWith("\n") ? "\n" : ""}# Loom's local state (event log, attachments, machine config)\n.loom/\n`);
|
|
2624
|
-
console.log(pc.green("✓ added .loom/ to .gitignore"));
|
|
2916
|
+
console.log(pc.green("✓ added .loom/ to .gitignore") + (process.stdin.isTTY ? "" : pc.dim(" (loom init --no-gitignore to leave it alone)")));
|
|
2625
2917
|
}
|
|
2626
|
-
async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true, fresh = false) {
|
|
2918
|
+
async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true, fresh = false, handoff = false) {
|
|
2627
2919
|
try {
|
|
2628
2920
|
return await client.send(projectId, text, agentId, { fresh });
|
|
2629
2921
|
}
|
|
2630
2922
|
catch (err) {
|
|
2631
2923
|
if (err instanceof DaemonError && err.status === 409 && agentId) {
|
|
2632
2924
|
const holder = String(err.body?.holder ?? "another agent");
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2925
|
+
// no one to ask (a script, --json) and no --handoff: fail loudly, never a quiet exit 0
|
|
2926
|
+
if (!handoff && !interactive)
|
|
2927
|
+
fail(`${agentId} doesn't hold the baton (holder: ${holder}) — add --handoff, or loom handoff ${agentId}`);
|
|
2928
|
+
const yes = handoff || (await confirm(pc.yellow(`⟶ ${holder} holds the baton. Hand off to ${agentId} (interrupts current work)?`)));
|
|
2929
|
+
if (!yes) {
|
|
2930
|
+
process.exitCode = 1;
|
|
2931
|
+
console.error(pc.dim(`not sent — ${holder} keeps the baton (--handoff hands it over without asking)`));
|
|
2637
2932
|
return null;
|
|
2933
|
+
}
|
|
2638
2934
|
await client.handoff(projectId, agentId);
|
|
2639
2935
|
return await client.send(projectId, text, agentId, { fresh });
|
|
2640
2936
|
}
|
|
@@ -2645,10 +2941,11 @@ program
|
|
|
2645
2941
|
.command("send <text...>")
|
|
2646
2942
|
.description("send one message into the shared thread")
|
|
2647
2943
|
.option("-a, --agent <id>", "address a specific agent (may require a handoff)")
|
|
2648
|
-
.option("--wait", "wait for the turn to end; print the reply and cost; exit 0 done, 1 failed, 2 asked a question, 124 timed out")
|
|
2944
|
+
.option("--wait", "wait for the turn to end; print the reply and cost; exit 0 done, 1 failed, 2 asked a question, 75 interrupted (the daemon restarted), 124 timed out")
|
|
2649
2945
|
.option("--json", "with --wait: print the outcome as one JSON object")
|
|
2650
2946
|
.option("--timeout <seconds>", "with --wait: give up after this long", "1800")
|
|
2651
2947
|
.option("--fresh", "start the agent's session in this chat over — a short question needn't re-read a long one")
|
|
2948
|
+
.option("--handoff", "with --agent: take the baton from whoever holds it, without asking")
|
|
2652
2949
|
.action(async (words, opts) => {
|
|
2653
2950
|
const client = await ensureDaemon();
|
|
2654
2951
|
const project = await currentProject(client);
|
|
@@ -2670,23 +2967,49 @@ program
|
|
|
2670
2967
|
}
|
|
2671
2968
|
// where the log is now, so the wait reads only what this send causes (#254)
|
|
2672
2969
|
const since = opts.wait ? ((await client.events(project.id, undefined, 1)).events.at(-1)?.id ?? 0) : 0;
|
|
2673
|
-
|
|
2970
|
+
// a question needs a person at a terminal — not a script, and never --json output
|
|
2971
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !opts.json;
|
|
2972
|
+
const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent, interactive, Boolean(opts.fresh), Boolean(opts.handoff));
|
|
2674
2973
|
if (!result)
|
|
2675
2974
|
return;
|
|
2676
2975
|
if (!opts.wait)
|
|
2677
2976
|
return void console.log(pc.dim(`→ sent to ${result.agentId} (loom log --follow to watch)`));
|
|
2678
2977
|
const deadline = Date.now() + Math.max(1, Number(opts.timeout) || 1800) * 1000;
|
|
2679
2978
|
let seen = [], outcome = null, cursor = since;
|
|
2979
|
+
// the daemon this turn runs in: if it goes away or comes back as another process, the turn is gone
|
|
2980
|
+
const daemonPid = await client.version().then((v) => v.pid).catch(() => null);
|
|
2981
|
+
let lastPidCheck = Date.now(), interrupted = false;
|
|
2680
2982
|
while (Date.now() < deadline && !outcome) {
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2983
|
+
try {
|
|
2984
|
+
const { events } = await client.events(project.id, cursor, 500);
|
|
2985
|
+
if (events.length) {
|
|
2986
|
+
seen = seen.concat(events);
|
|
2987
|
+
cursor = events.at(-1).id;
|
|
2988
|
+
}
|
|
2989
|
+
outcome = turnOutcome(seen, result.agentId, since);
|
|
2990
|
+
if (!outcome && daemonPid && Date.now() - lastPidCheck > 5000) {
|
|
2991
|
+
lastPidCheck = Date.now();
|
|
2992
|
+
if ((await client.version()).pid !== daemonPid)
|
|
2993
|
+
interrupted = true;
|
|
2994
|
+
}
|
|
2685
2995
|
}
|
|
2686
|
-
|
|
2996
|
+
catch {
|
|
2997
|
+
interrupted = true; // the daemon stopped answering (or answered "event log is closed") mid-turn
|
|
2998
|
+
}
|
|
2999
|
+
if (interrupted)
|
|
3000
|
+
break;
|
|
2687
3001
|
if (!outcome)
|
|
2688
3002
|
await new Promise((r) => setTimeout(r, 400));
|
|
2689
3003
|
}
|
|
3004
|
+
if (interrupted && !outcome) {
|
|
3005
|
+
// EX_TEMPFAIL: not the agent's failure, and worth trying again
|
|
3006
|
+
process.exitCode = 75;
|
|
3007
|
+
const again = `loom send -a ${result.agentId} --wait "continue where you left off"`;
|
|
3008
|
+
if (opts.json)
|
|
3009
|
+
return void console.log(JSON.stringify({ status: "interrupted", agent: result.agentId, reason: "the Loom daemon restarted or stopped during the turn", resume: again }));
|
|
3010
|
+
return void console.error(pc.yellow(`⚠ the Loom daemon restarted or stopped while ${result.agentId} was working — the turn was interrupted. ` +
|
|
3011
|
+
`What it did so far is still in the working tree. To carry on: ${again}`));
|
|
3012
|
+
}
|
|
2690
3013
|
process.exitCode = exitCodeFor(outcome);
|
|
2691
3014
|
if (opts.json)
|
|
2692
3015
|
return void console.log(JSON.stringify(outcome ?? { status: "timeout", agent: result.agentId }));
|