@loompad/cli 1.2.3 → 1.2.5

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 (41) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +10 -0
  3. package/dist/adapters/antigravity-cli.d.ts +7 -0
  4. package/dist/adapters/antigravity-cli.js +9 -1
  5. package/dist/adapters/antigravity-cli.js.map +1 -1
  6. package/dist/adapters/base.d.ts +5 -3
  7. package/dist/adapters/base.js +7 -2
  8. package/dist/adapters/base.js.map +1 -1
  9. package/dist/adapters/opencode.d.ts +18 -21
  10. package/dist/adapters/opencode.js +103 -22
  11. package/dist/adapters/opencode.js.map +1 -1
  12. package/dist/cli/index.js +137 -23
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/cli/ui.js +11 -1
  15. package/dist/cli/ui.js.map +1 -1
  16. package/dist/core/orchestra.d.ts +1 -1
  17. package/dist/core/orchestra.js +7 -2
  18. package/dist/core/orchestra.js.map +1 -1
  19. package/dist/core/turn-wait.d.ts +5 -1
  20. package/dist/core/turn-wait.js +3 -3
  21. package/dist/core/turn-wait.js.map +1 -1
  22. package/dist/daemon/client.d.ts +10 -0
  23. package/dist/daemon/client.js +28 -1
  24. package/dist/daemon/client.js.map +1 -1
  25. package/dist/daemon/routes/project-team.js +17 -0
  26. package/dist/daemon/routes/project-team.js.map +1 -1
  27. package/dist/daemon/runner.js +22 -9
  28. package/dist/daemon/runner.js.map +1 -1
  29. package/dist/daemon/team-coordinator.d.ts +1 -1
  30. package/dist/daemon/team-coordinator.js +2 -2
  31. package/dist/daemon/team-coordinator.js.map +1 -1
  32. package/dist/daemon/team.d.ts +33 -0
  33. package/dist/daemon/team.js +48 -0
  34. package/dist/daemon/team.js.map +1 -1
  35. package/dist/web/app.css +8 -0
  36. package/dist/web/app.js +44 -1
  37. package/dist/web/app.js.map +2 -2
  38. package/docs/release-notes/v1.2.4.md +13 -0
  39. package/docs/release-notes/v1.2.5.md +19 -0
  40. package/docs/teams.md +3 -0
  41. 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;
@@ -1894,12 +1923,17 @@ program
1894
1923
  console.log(pc.dim(`no crews here yet — loom crew create [${templates.join("|")}] [name]`));
1895
1924
  return;
1896
1925
  }
1926
+ // status shows every crew unless one is named; everything else needs to know which
1927
+ if (a === "status" && !opts.crew) {
1928
+ for (const c of crews)
1929
+ printCrew(c);
1930
+ return;
1931
+ }
1897
1932
  const crew = opts.crew ? crews.find((c) => c.id === opts.crew || c.name === opts.crew) : crews.length === 1 ? crews[0] : undefined;
1898
1933
  if (!crew)
1899
1934
  throw new Error(`which crew? --crew ${crews.map((c) => c.id).join(" | ")}`);
1900
1935
  if (a === "status") {
1901
- for (const c of opts.crew ? [crew] : crews)
1902
- printCrew(c);
1936
+ printCrew(crew);
1903
1937
  return;
1904
1938
  }
1905
1939
  if (a === "diff") {
@@ -1955,7 +1989,7 @@ function printCrew(c) {
1955
1989
  }
1956
1990
  program
1957
1991
  .command("team [action] [args...]")
1958
- .description("Loom Teams: status | signin [hub] | create <name> | invite | join <link> | share | unshare | brain [inbox|promote|resolve|correct|trust|private] | landing | doctor [fix] | adopt <pr> | deploys | release-notes <since> | webhook [--repo r] [--install] | remove <github> | leave")
1992
+ .description("Loom Teams: status | auto [on] | signin [hub] | create <name> | invite | join <link> | share | unshare | brain [inbox|promote|resolve|correct|trust|private] | landing | doctor [fix] | adopt <pr> | deploys | release-notes <since> | webhook [--repo r] [--install] | remove <github> | leave")
1959
1993
  .option("--github <login>", "your GitHub login (defaults to the gh CLI's)")
1960
1994
  .option("--repo <owner/name>", "webhook: which repo (defaults to this project's)")
1961
1995
  .option("--install", "webhook: create it on the repo with gh (needs repo admin)")
@@ -1971,6 +2005,24 @@ program
1971
2005
  try {
1972
2006
  if (a === "status")
1973
2007
  return void printTeam(await client.team());
2008
+ if (a === "auto") {
2009
+ // team auto-orchestration: your goals split across teammates' machines, theirs across yours
2010
+ const project = await currentProject(client);
2011
+ const st = await client.teamAuto(project.id, arg === "on");
2012
+ if (st.wrotePolicy)
2013
+ console.log(`${pc.green("✓")} wrote loom.team.json (runners.shared) — commit and merge it; policy is reviewed like code`);
2014
+ const mark = (ok) => (ok ? pc.green("✓") : pc.dim("·"));
2015
+ console.log(`${mark(st.shared)} project shared with your team`);
2016
+ console.log(`${mark(st.policyShared)} repo policy lets teammates' runners take its work`);
2017
+ console.log(`${mark(st.myRunner.running && st.myRunner.shared)} your runner is on and shared (teammates' goals can use your agents)`);
2018
+ const on = st.teammates.filter((t) => t.online);
2019
+ console.log(`${mark(on.length > 0)} teammates' runners online: ${on.length ? on.map((t) => `${t.github} (${t.kinds.join(", ")})`).join(", ") : "none yet"}`);
2020
+ if (st.ready)
2021
+ console.log(`\n${pc.green("ready")} — goals you start get ${st.workers.join(", ")} as workers. ${pc.dim("loom orchestrate \"…\" (--solo to keep one local)")}`);
2022
+ else
2023
+ console.log(`\n${pc.yellow("not yet")} — ${st.missing.join("; ")}`);
2024
+ return;
2025
+ }
1974
2026
  if (a === "signin") {
1975
2027
  const { hostedHubUrl, hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
1976
2028
  const supabaseUrl = hostedSupabaseUrl(arg);
@@ -2505,13 +2557,43 @@ program
2505
2557
  process.exitCode = 1;
2506
2558
  }
2507
2559
  });
2560
+ program
2561
+ .command("agents:mode <agentId> [mode]")
2562
+ .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")
2563
+ .action(async (agentId, mode) => {
2564
+ const client = await ensureDaemon();
2565
+ const project = await currentProject(client);
2566
+ const agent = project.agents.find((a) => a.id === agentId);
2567
+ if (!agent)
2568
+ fail(`no agent "${agentId}" here — loom agents`);
2569
+ const { PERMISSION_PROFILES } = await import("../core/permissions.js");
2570
+ const profile = PERMISSION_PROFILES[agent.kind];
2571
+ if (!profile)
2572
+ fail(`${agent.kind} has no access modes to choose from`);
2573
+ const now = (agent.permissions ?? profile.default);
2574
+ if (!mode) {
2575
+ for (const [m, cell] of Object.entries(profile.modes)) {
2576
+ const off = cell.unsupported;
2577
+ console.log(`${m === now ? pc.green("●") : pc.dim("○")} ${pc.bold(m.padEnd(6))} ${off ? pc.dim(`${cell.label} — unavailable: ${off}`) : cell.label}`);
2578
+ }
2579
+ return void console.log(pc.dim(`\nloom agents:mode ${agentId} <bypass|auto|ask>`));
2580
+ }
2581
+ const res = await client.setAgentPermissions(project.id, agentId, mode);
2582
+ const cell = profile.modes[(res.agent.permissions ?? mode)];
2583
+ console.log(`${pc.green("✓")} ${agentId}: ${cell?.label ?? mode}`);
2584
+ if (mode === "bypass")
2585
+ console.log(pc.yellow(" no sandbox: it can commit, push, reach the network and run anything — on this machine, as you"));
2586
+ });
2508
2587
  program
2509
2588
  .command("agents:available")
2510
2589
  .description("which agents this machine can drive, and how many sessions are here")
2511
2590
  .option("--json", "print as JSON")
2512
2591
  .action(async (opts) => {
2513
2592
  const client = await ensureDaemon();
2514
- const project = await currentProject(client);
2593
+ const project = await resolveCurrentProject(client).catch((err) => { if (err instanceof NoProjectError)
2594
+ return null; throw err; });
2595
+ if (!project)
2596
+ return void (await printMachineAgents(Boolean(opts.json)));
2515
2597
  const { ades } = await client.availableAgents(project.id);
2516
2598
  if (opts.json)
2517
2599
  return void console.log(JSON.stringify(ades, null, 2));
@@ -2592,26 +2674,31 @@ async function offerGitignore(dir, choice) {
2592
2674
  return; // already ignored
2593
2675
  }
2594
2676
  catch { /* not ignored (exit 1), or no git: fall through */ }
2595
- 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"))));
2677
+ // in a terminal, ask; in a script (no one to ask), do it — .loom/ is never meant for the repo
2678
+ 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")));
2596
2679
  if (!yes)
2597
2680
  return void console.log(pc.dim("tip: .loom/ is local state — add it to .gitignore (loom init --gitignore)"));
2598
2681
  const file = path.join(dir, ".gitignore");
2599
2682
  const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
2600
2683
  fs.writeFileSync(file, `${before}${before && !before.endsWith("\n") ? "\n" : ""}# Loom's local state (event log, attachments, machine config)\n.loom/\n`);
2601
- console.log(pc.green("✓ added .loom/ to .gitignore"));
2684
+ console.log(pc.green("✓ added .loom/ to .gitignore") + (process.stdin.isTTY ? "" : pc.dim(" (loom init --no-gitignore to leave it alone)")));
2602
2685
  }
2603
- async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true, fresh = false) {
2686
+ async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true, fresh = false, handoff = false) {
2604
2687
  try {
2605
2688
  return await client.send(projectId, text, agentId, { fresh });
2606
2689
  }
2607
2690
  catch (err) {
2608
2691
  if (err instanceof DaemonError && err.status === 409 && agentId) {
2609
2692
  const holder = String(err.body?.holder ?? "another agent");
2610
- if (!interactive)
2611
- fail(`${agentId} doesn't hold the baton (holder: ${holder}) — loom handoff ${agentId}`);
2612
- const yes = await confirm(pc.yellow(`⟶ ${holder} holds the baton. Hand off to ${agentId} (interrupts current work)?`));
2613
- if (!yes)
2693
+ // no one to ask (a script, --json) and no --handoff: fail loudly, never a quiet exit 0
2694
+ if (!handoff && !interactive)
2695
+ fail(`${agentId} doesn't hold the baton (holder: ${holder}) — add --handoff, or loom handoff ${agentId}`);
2696
+ const yes = handoff || (await confirm(pc.yellow(`⟶ ${holder} holds the baton. Hand off to ${agentId} (interrupts current work)?`)));
2697
+ if (!yes) {
2698
+ process.exitCode = 1;
2699
+ console.error(pc.dim(`not sent — ${holder} keeps the baton (--handoff hands it over without asking)`));
2614
2700
  return null;
2701
+ }
2615
2702
  await client.handoff(projectId, agentId);
2616
2703
  return await client.send(projectId, text, agentId, { fresh });
2617
2704
  }
@@ -2622,10 +2709,11 @@ program
2622
2709
  .command("send <text...>")
2623
2710
  .description("send one message into the shared thread")
2624
2711
  .option("-a, --agent <id>", "address a specific agent (may require a handoff)")
2625
- .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")
2712
+ .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")
2626
2713
  .option("--json", "with --wait: print the outcome as one JSON object")
2627
2714
  .option("--timeout <seconds>", "with --wait: give up after this long", "1800")
2628
2715
  .option("--fresh", "start the agent's session in this chat over — a short question needn't re-read a long one")
2716
+ .option("--handoff", "with --agent: take the baton from whoever holds it, without asking")
2629
2717
  .action(async (words, opts) => {
2630
2718
  const client = await ensureDaemon();
2631
2719
  const project = await currentProject(client);
@@ -2647,23 +2735,49 @@ program
2647
2735
  }
2648
2736
  // where the log is now, so the wait reads only what this send causes (#254)
2649
2737
  const since = opts.wait ? ((await client.events(project.id, undefined, 1)).events.at(-1)?.id ?? 0) : 0;
2650
- const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent, !opts.wait || process.stdin.isTTY, Boolean(opts.fresh));
2738
+ // a question needs a person at a terminal — not a script, and never --json output
2739
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !opts.json;
2740
+ const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent, interactive, Boolean(opts.fresh), Boolean(opts.handoff));
2651
2741
  if (!result)
2652
2742
  return;
2653
2743
  if (!opts.wait)
2654
2744
  return void console.log(pc.dim(`→ sent to ${result.agentId} (loom log --follow to watch)`));
2655
2745
  const deadline = Date.now() + Math.max(1, Number(opts.timeout) || 1800) * 1000;
2656
2746
  let seen = [], outcome = null, cursor = since;
2747
+ // the daemon this turn runs in: if it goes away or comes back as another process, the turn is gone
2748
+ const daemonPid = await client.version().then((v) => v.pid).catch(() => null);
2749
+ let lastPidCheck = Date.now(), interrupted = false;
2657
2750
  while (Date.now() < deadline && !outcome) {
2658
- const { events } = await client.events(project.id, cursor, 500);
2659
- if (events.length) {
2660
- seen = seen.concat(events);
2661
- cursor = events.at(-1).id;
2751
+ try {
2752
+ const { events } = await client.events(project.id, cursor, 500);
2753
+ if (events.length) {
2754
+ seen = seen.concat(events);
2755
+ cursor = events.at(-1).id;
2756
+ }
2757
+ outcome = turnOutcome(seen, result.agentId, since);
2758
+ if (!outcome && daemonPid && Date.now() - lastPidCheck > 5000) {
2759
+ lastPidCheck = Date.now();
2760
+ if ((await client.version()).pid !== daemonPid)
2761
+ interrupted = true;
2762
+ }
2763
+ }
2764
+ catch {
2765
+ interrupted = true; // the daemon stopped answering (or answered "event log is closed") mid-turn
2662
2766
  }
2663
- outcome = turnOutcome(seen, result.agentId, since);
2767
+ if (interrupted)
2768
+ break;
2664
2769
  if (!outcome)
2665
2770
  await new Promise((r) => setTimeout(r, 400));
2666
2771
  }
2772
+ if (interrupted && !outcome) {
2773
+ // EX_TEMPFAIL: not the agent's failure, and worth trying again
2774
+ process.exitCode = 75;
2775
+ const again = `loom send -a ${result.agentId} --wait "continue where you left off"`;
2776
+ if (opts.json)
2777
+ return void console.log(JSON.stringify({ status: "interrupted", agent: result.agentId, reason: "the Loom daemon restarted or stopped during the turn", resume: again }));
2778
+ return void console.error(pc.yellow(`⚠ the Loom daemon restarted or stopped while ${result.agentId} was working — the turn was interrupted. ` +
2779
+ `What it did so far is still in the working tree. To carry on: ${again}`));
2780
+ }
2667
2781
  process.exitCode = exitCodeFor(outcome);
2668
2782
  if (opts.json)
2669
2783
  return void console.log(JSON.stringify(outcome ?? { status: "timeout", agent: result.agentId }));