@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/adapters/antigravity-cli.d.ts +7 -0
  3. package/dist/adapters/antigravity-cli.js +10 -2
  4. package/dist/adapters/antigravity-cli.js.map +1 -1
  5. package/dist/adapters/base.d.ts +5 -3
  6. package/dist/adapters/base.js +7 -2
  7. package/dist/adapters/base.js.map +1 -1
  8. package/dist/adapters/index.d.ts +1 -1
  9. package/dist/adapters/index.js +25 -1
  10. package/dist/adapters/index.js.map +1 -1
  11. package/dist/adapters/opencode.d.ts +18 -21
  12. package/dist/adapters/opencode.js +103 -22
  13. package/dist/adapters/opencode.js.map +1 -1
  14. package/dist/cli/index.js +352 -29
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/ui.js +11 -1
  17. package/dist/cli/ui.js.map +1 -1
  18. package/dist/core/orchestra.js +17 -1
  19. package/dist/core/orchestra.js.map +1 -1
  20. package/dist/core/review-triage.js +3 -13
  21. package/dist/core/review-triage.js.map +1 -1
  22. package/dist/core/secret-scan.d.ts +14 -0
  23. package/dist/core/secret-scan.js +42 -0
  24. package/dist/core/secret-scan.js.map +1 -0
  25. package/dist/core/shares.d.ts +33 -0
  26. package/dist/core/shares.js +54 -0
  27. package/dist/core/shares.js.map +1 -0
  28. package/dist/core/turn-gate.d.ts +53 -0
  29. package/dist/core/turn-gate.js +126 -0
  30. package/dist/core/turn-gate.js.map +1 -0
  31. package/dist/core/turn-wait.d.ts +5 -1
  32. package/dist/core/turn-wait.js +3 -3
  33. package/dist/core/turn-wait.js.map +1 -1
  34. package/dist/daemon/client.d.ts +26 -0
  35. package/dist/daemon/client.js +106 -6
  36. package/dist/daemon/client.js.map +1 -1
  37. package/dist/daemon/routes/chats.js +18 -0
  38. package/dist/daemon/routes/chats.js.map +1 -1
  39. package/dist/daemon/routes/public.d.ts +1 -1
  40. package/dist/daemon/routes/public.js +21 -0
  41. package/dist/daemon/routes/public.js.map +1 -1
  42. package/dist/daemon/runner.js +3 -1
  43. package/dist/daemon/runner.js.map +1 -1
  44. package/docs/release-notes/v1.2.5.md +19 -0
  45. package/docs/release-notes/v1.2.6.md +18 -0
  46. 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
- for (const p of projects)
278
- console.log(formatProjectRow(p));
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 currentProject(client);
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 · ${done}/${run.tasks.length} finished · ${fmtUsd(run.costUsd)}`
1443
- : `${pc.magenta("🎼")} ${pc.bold(run.id)} ${pc.dim(runStatus(run))} ${done}/${run.tasks.length} tasks · ` +
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
- return void printRun((await client.orchestraRun(project.id, runId)).run);
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
- const done = r.tasks.filter((t) => t.status === "done").length;
1561
- console.log(`${pc.bold(r.id)} ${runStatus(r).padEnd(13)} ${String(done).padStart(2)}/${String(r.tasks.length).padEnd(2)} ` +
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 currentProject(client);
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
- 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"))));
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
- if (!interactive)
2634
- fail(`${agentId} doesn't hold the baton (holder: ${holder}) — loom handoff ${agentId}`);
2635
- const yes = await confirm(pc.yellow(`⟶ ${holder} holds the baton. Hand off to ${agentId} (interrupts current work)?`));
2636
- if (!yes)
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
- const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent, !opts.wait || process.stdin.isTTY, Boolean(opts.fresh));
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
- const { events } = await client.events(project.id, cursor, 500);
2682
- if (events.length) {
2683
- seen = seen.concat(events);
2684
- cursor = events.at(-1).id;
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
- outcome = turnOutcome(seen, result.agentId, since);
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 }));