@loompad/cli 1.2.1 → 1.2.3

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 (97) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/adapters/opencode.d.ts +2 -0
  3. package/dist/adapters/opencode.js +9 -2
  4. package/dist/adapters/opencode.js.map +1 -1
  5. package/dist/cli/doctor.d.ts +1 -1
  6. package/dist/cli/doctor.js +7 -2
  7. package/dist/cli/doctor.js.map +1 -1
  8. package/dist/cli/index.js +111 -15
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/ui.js +2 -1
  11. package/dist/cli/ui.js.map +1 -1
  12. package/dist/core/brain-index.d.ts +0 -18
  13. package/dist/core/brain-index.js +10 -2
  14. package/dist/core/brain-index.js.map +1 -1
  15. package/dist/core/crew.d.ts +26 -1
  16. package/dist/core/crew.js +66 -5
  17. package/dist/core/crew.js.map +1 -1
  18. package/dist/core/orchestra.d.ts +36 -0
  19. package/dist/core/orchestra.js +149 -82
  20. package/dist/core/orchestra.js.map +1 -1
  21. package/dist/core/prices.d.ts +16 -0
  22. package/dist/core/prices.js +29 -1
  23. package/dist/core/prices.js.map +1 -1
  24. package/dist/core/registry.d.ts +7 -0
  25. package/dist/core/registry.js.map +1 -1
  26. package/dist/core/replay.js +2 -0
  27. package/dist/core/replay.js.map +1 -1
  28. package/dist/core/team-hub.d.ts +2 -1
  29. package/dist/core/team-hub.js +1 -1
  30. package/dist/core/team-hub.js.map +1 -1
  31. package/dist/core/team-leases.d.ts +7 -0
  32. package/dist/core/team-leases.js +19 -0
  33. package/dist/core/team-leases.js.map +1 -1
  34. package/dist/daemon/auth.d.ts +7 -1
  35. package/dist/daemon/auth.js +19 -3
  36. package/dist/daemon/auth.js.map +1 -1
  37. package/dist/daemon/client.d.ts +23 -0
  38. package/dist/daemon/client.js +31 -0
  39. package/dist/daemon/client.js.map +1 -1
  40. package/dist/daemon/council.d.ts +8 -0
  41. package/dist/daemon/council.js +115 -36
  42. package/dist/daemon/council.js.map +1 -1
  43. package/dist/daemon/review.d.ts +4 -0
  44. package/dist/daemon/review.js +22 -2
  45. package/dist/daemon/review.js.map +1 -1
  46. package/dist/daemon/routes/brain.js +4 -0
  47. package/dist/daemon/routes/brain.js.map +1 -1
  48. package/dist/daemon/routes/council.js +9 -0
  49. package/dist/daemon/routes/council.js.map +1 -1
  50. package/dist/daemon/routes/crews.js +2 -0
  51. package/dist/daemon/routes/crews.js.map +1 -1
  52. package/dist/daemon/routes/orchestra.js +1 -0
  53. package/dist/daemon/routes/orchestra.js.map +1 -1
  54. package/dist/daemon/routes/pairing.js +6 -1
  55. package/dist/daemon/routes/pairing.js.map +1 -1
  56. package/dist/daemon/routes/team.js +2 -0
  57. package/dist/daemon/routes/team.js.map +1 -1
  58. package/dist/daemon/runner.d.ts +11 -0
  59. package/dist/daemon/runner.js +96 -3
  60. package/dist/daemon/runner.js.map +1 -1
  61. package/dist/daemon/runtime/accounting.js +7 -3
  62. package/dist/daemon/runtime/accounting.js.map +1 -1
  63. package/dist/daemon/runtime/briefings.js +12 -1
  64. package/dist/daemon/runtime/briefings.js.map +1 -1
  65. package/dist/daemon/runtime/queue.d.ts +2 -0
  66. package/dist/daemon/runtime/queue.js +14 -0
  67. package/dist/daemon/runtime/queue.js.map +1 -1
  68. package/dist/daemon/runtime/turns.d.ts +2 -0
  69. package/dist/daemon/runtime/turns.js +28 -0
  70. package/dist/daemon/runtime/turns.js.map +1 -1
  71. package/dist/daemon/runtime-support.d.ts +5 -1
  72. package/dist/daemon/runtime-support.js +8 -2
  73. package/dist/daemon/runtime-support.js.map +1 -1
  74. package/dist/daemon/runtime.d.ts +28 -0
  75. package/dist/daemon/runtime.js +98 -6
  76. package/dist/daemon/runtime.js.map +1 -1
  77. package/dist/daemon/server.js +4 -0
  78. package/dist/daemon/server.js.map +1 -1
  79. package/dist/daemon/shift.d.ts +6 -0
  80. package/dist/daemon/shift.js +10 -1
  81. package/dist/daemon/shift.js.map +1 -1
  82. package/dist/daemon/team-coordinator.d.ts +27 -3
  83. package/dist/daemon/team-coordinator.js +54 -2
  84. package/dist/daemon/team-coordinator.js.map +1 -1
  85. package/dist/daemon/team.d.ts +15 -1
  86. package/dist/daemon/team.js +59 -1
  87. package/dist/daemon/team.js.map +1 -1
  88. package/dist/hub/supabase-client.d.ts +4 -2
  89. package/dist/hub/supabase-client.js +21 -12
  90. package/dist/hub/supabase-client.js.map +1 -1
  91. package/dist/web/app.css +69 -0
  92. package/dist/web/app.js +11990 -11745
  93. package/dist/web/app.js.map +4 -4
  94. package/docs/release-notes/v1.2.2.md +39 -0
  95. package/docs/release-notes/v1.2.3.md +29 -0
  96. package/docs/teams.md +28 -0
  97. 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, ensureDaemon, stopDaemon, } from "../daemon/client.js";
30
+ import { DaemonClient, DaemonError, daemonRunning, desktopOwnsDaemon, ensureDaemon, stopDaemon, } 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,8 +200,14 @@ 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
204
  .action(async (opts) => {
204
205
  const running = await daemonRunning();
206
+ if (running && !opts.force && (await desktopOwnsDaemon(running))) {
207
+ // replacing it swapped Desktop onto the CLI's build without a word (#306)
208
+ console.log(pc.yellow("Loom Desktop runs this daemon — quit and reopen Loom Desktop to restart it (or loom up --restart --force to replace it with this CLI's)"));
209
+ return;
210
+ }
205
211
  if (running) {
206
212
  const { BUILD_REV } = await import("../daemon/server.js");
207
213
  const health = (await fetch(`http://${running.host}:${running.port}/api/health`)
@@ -251,15 +257,21 @@ program
251
257
  program
252
258
  .command("status")
253
259
  .description("daemon health and project board")
254
- .action(async () => {
260
+ .option("--json", "print the projects as JSON")
261
+ .action(async (opts) => {
255
262
  const cfg = await daemonRunning();
256
263
  if (!cfg) {
264
+ if (opts.json)
265
+ return void console.log(JSON.stringify({ daemon: null, projects: [] }));
257
266
  console.log(pc.dim("daemon: not running (loom up)"));
258
267
  return;
259
268
  }
260
- console.log(pc.green(`daemon: http://${cfg.host}:${cfg.port}`));
269
+ if (!opts.json)
270
+ console.log(pc.green(`daemon: http://${cfg.host}:${cfg.port}`));
261
271
  const client = new DaemonClient(cfg);
262
272
  const { projects } = await client.listProjects();
273
+ if (opts.json)
274
+ return void console.log(JSON.stringify(projects)); // #315
263
275
  if (!projects.length)
264
276
  console.log(pc.dim("no projects yet — loom init"));
265
277
  for (const p of projects)
@@ -290,6 +302,7 @@ program
290
302
  .command("projects")
291
303
  .description("board of all projects")
292
304
  .option("--forget <idOrName>", "stop tracking a project (its .loom/ stays on disk)")
305
+ .option("--json", "print the projects as JSON")
293
306
  .action(async (opts) => {
294
307
  const client = await ensureDaemon();
295
308
  if (opts.forget) {
@@ -309,6 +322,8 @@ program
309
322
  return;
310
323
  }
311
324
  const { projects } = await client.listProjects();
325
+ if (opts.json)
326
+ return void console.log(JSON.stringify(projects)); // #315
312
327
  if (!projects.length)
313
328
  console.log(pc.dim("no projects yet — loom init"));
314
329
  for (const p of projects)
@@ -1114,7 +1129,8 @@ program
1114
1129
  return pc.dim(`${t} ${e.kind}`);
1115
1130
  }
1116
1131
  };
1117
- console.log(pc.dim(project
1132
+ // the banner is for people: stderr, so --json's stdout is events and nothing else (#314)
1133
+ console.error(pc.dim(project
1118
1134
  ? `watching ${project.name} — ^C to stop`
1119
1135
  : "watching every project — ^C to stop"));
1120
1136
  const close = client.subscribe((pid, e) => {
@@ -1203,6 +1219,9 @@ program
1203
1219
  }
1204
1220
  return fail(err instanceof Error ? err.message : String(err));
1205
1221
  }
1222
+ const shaky = review.unreliable ?? [];
1223
+ if (!opts.json && shaky.length)
1224
+ console.error(pc.yellow(`⚠ ${shaky.join(", ")} hasn't finished a review in this project yet (left out each time) — it may wait out review.timeoutMin`));
1206
1225
  if (!opts.json)
1207
1226
  console.error(pc.cyan(`🔍 ${agents.join(", ")} reviewing ${review.target.kind === "file" ? review.target.file : review.target.kind === "diff" ? "the uncommitted changes" : `“${review.target.label ?? "your request"}”`} · read-only, nobody takes the baton`));
1208
1227
  if (!opts.wait)
@@ -1257,8 +1276,10 @@ program
1257
1276
  await new Promise((r) => setTimeout(r, 1500));
1258
1277
  }
1259
1278
  console.log((council.status === "done" ? pc.green : council.status === "stopped" ? pc.yellow : pc.red)(`\n${council.summary ?? council.status}`));
1260
- if (council.status === "failed")
1279
+ if (council.status === "failed") {
1261
1280
  process.exitCode = 1;
1281
+ console.log(pc.dim(`carry on from where it stopped: loom council:resume ${council.councilId}`));
1282
+ }
1262
1283
  });
1263
1284
  program
1264
1285
  .command("council:status [councilId]")
@@ -1275,6 +1296,21 @@ program
1275
1296
  if (council.summary)
1276
1297
  console.log(pc.dim(`\n${council.summary}`));
1277
1298
  });
1299
+ program
1300
+ .command("council:resume <councilId>")
1301
+ .description("carry on a council that a restart, a failed turn or Stop ended — from the first stage that didn't finish")
1302
+ .action(async (councilId) => {
1303
+ const client = await ensureDaemon();
1304
+ const project = await currentProject(client);
1305
+ try {
1306
+ const { council } = await client.resumeCouncil(project.id, councilId);
1307
+ const next = council.stages.find((st) => !["done", "skipped", "unverified"].includes(st.status) && !(st.key === "prove" && st.status === "failed"));
1308
+ console.log(pc.cyan(`🏛 resuming council ${council.councilId} from ${next?.name ?? "the end"} — loom council:status ${council.councilId}`));
1309
+ }
1310
+ catch (err) {
1311
+ fail(err instanceof Error ? err.message : String(err));
1312
+ }
1313
+ });
1278
1314
  program
1279
1315
  .command("council:stop")
1280
1316
  .description("stop the running council after its current stage")
@@ -1389,6 +1425,8 @@ program
1389
1425
  });
1390
1426
  // ── orchestra: one orchestrator, many parallel workers (core/orchestra.ts) ──
1391
1427
  const RUN_DONE = new Set(["completed", "failed", "aborted", "waiting_human"]);
1428
+ /** A run a restart cut off is "aborted" on disk but resumable: say so, and how (#305). */
1429
+ const runStatus = (r) => r.status === "aborted" && r.interrupted ? "interrupted (resumable)" : r.status;
1392
1430
  const TASK_COLOR = {
1393
1431
  done: pc.green,
1394
1432
  running: pc.cyan,
@@ -1401,8 +1439,8 @@ const TASK_COLOR = {
1401
1439
  function printRun(run) {
1402
1440
  const done = run.tasks.filter((t) => t.status === "done").length;
1403
1441
  console.log(run.race
1404
- ? `${pc.magenta("🏁")} ${pc.bold(run.id)} ${pc.dim(run.status)} race · ${done}/${run.tasks.length} finished · ${fmtUsd(run.costUsd)}`
1405
- : `${pc.magenta("🎼")} ${pc.bold(run.id)} ${pc.dim(run.status)} ${done}/${run.tasks.length} tasks · ` +
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 · ` +
1406
1444
  `round ${run.round}/${run.maxRounds} · ${fmtUsd(run.costUsd)} · ${pc.dim(run.branch)}`);
1407
1445
  console.log(` ${pc.dim("goal")} ${run.goal}`);
1408
1446
  if (run.race)
@@ -1468,6 +1506,7 @@ program
1468
1506
  .option("--plan", "plan mode: write the plan as markdown specs under plans/<run>/ that any agent can pick up")
1469
1507
  .option("--max-usd <n>", "stop this goal when it has spent this much, and say so")
1470
1508
  .option("--race", "race: every worker takes the whole goal in its own worktree; pick one with orchestra:apply <run> --task race-<agent>")
1509
+ .option("--solo", "keep every task on this machine, even when teammates' runners could take some")
1471
1510
  .option("--no-watch", "start it and return instead of following it to the end")
1472
1511
  .action(async (goal, opts) => {
1473
1512
  const client = await ensureDaemon();
@@ -1482,7 +1521,11 @@ program
1482
1521
  ...(opts.plan ? { plan: true } : {}),
1483
1522
  ...(opts.maxUsd ? { maxUsd: Number(opts.maxUsd) } : {}),
1484
1523
  ...(opts.race ? { race: true } : {}),
1524
+ ...(opts.solo ? { team: false } : {}),
1485
1525
  });
1526
+ const mates = run.workers.filter((w) => w.startsWith("team:"));
1527
+ if (mates.length)
1528
+ console.log(pc.dim(` teammates' machines can take tasks too: ${mates.join(", ")}`));
1486
1529
  console.log(run.race
1487
1530
  ? `${pc.magenta("🏁")} ${pc.bold(run.id)} started — ${run.workers.join(", ")} racing on the same goal`
1488
1531
  : `${pc.magenta("🎼")} ${pc.bold(run.id)} started — ${pc.bold(run.orchestrator.agent)} orchestrating ` +
@@ -1515,7 +1558,7 @@ program
1515
1558
  return void console.log(pc.dim('no orchestra runs yet — loom orchestrate "<goal>"'));
1516
1559
  for (const r of runs.slice(0, 15)) {
1517
1560
  const done = r.tasks.filter((t) => t.status === "done").length;
1518
- console.log(`${pc.bold(r.id)} ${r.status.padEnd(13)} ${String(done).padStart(2)}/${String(r.tasks.length).padEnd(2)} ` +
1561
+ console.log(`${pc.bold(r.id)} ${runStatus(r).padEnd(13)} ${String(done).padStart(2)}/${String(r.tasks.length).padEnd(2)} ` +
1519
1562
  `${pc.dim(new Date(r.createdAt).toLocaleString())} ${r.goal.slice(0, 70)}`);
1520
1563
  }
1521
1564
  }
@@ -1803,7 +1846,7 @@ program
1803
1846
  });
1804
1847
  program
1805
1848
  .command("crew [action] [args...]")
1806
- .description("Agent Teams: status | create | goal | say | approve | pause | stop | resume | pick | refresh | budget | overlap | deliver | apply | diff | remove")
1849
+ .description("Agent Teams: status | create | goal | say | approve | pause | stop | resume | accept | pick | refresh | budget | overlap | deliver | apply | diff | remove")
1807
1850
  .option("--crew <id>", "which crew, when the project has several")
1808
1851
  .option("--to <teammate>", "say: to one teammate")
1809
1852
  .option("--no-approval", "create: the Lead's plan starts without your OK")
@@ -1814,15 +1857,26 @@ program
1814
1857
  .option("--reviewer <agent>", "create: who reviews")
1815
1858
  .option("--tester <agent>", "create: who tests")
1816
1859
  .option("--amount <usd>", "budget: new goal budget in USD")
1817
- .option("--card <id>", "overlap: held card id")
1860
+ .option("--card <id>", "overlap: held card id; accept: the failed card to take as it stands")
1818
1861
  .option("--decision <decision>", "overlap: narrow, wait:<goal>, or proceed:<reason>")
1819
1862
  .option("--touches <globs>", "overlap: comma-separated revised paths")
1863
+ .option("--json", "status: machine-readable")
1820
1864
  .action(async (action, args, opts) => {
1821
1865
  const client = await ensureDaemon();
1822
1866
  const project = await currentProject(client);
1823
1867
  const a = (action ?? "status").toLowerCase();
1824
1868
  const words = (args ?? []).join(" ").trim();
1825
1869
  const { crews, templates } = await client.crews(project.id);
1870
+ if (a === "status" && opts.json) {
1871
+ // every crew, its goal and each card with its id — what accept/overlap take (#329)
1872
+ const pick = opts.crew ? crews.filter((c) => c.id === opts.crew || c.name === opts.crew) : crews;
1873
+ console.log(JSON.stringify({ crews: pick.map((c) => ({ id: c.id, name: c.name, busy: Boolean(c.busy), teammates: c.teammates,
1874
+ goal: c.state?.goal ? { id: c.state.goal.id, status: c.state.goal.status, text: c.state.goal.text, branch: c.state.goal.branch,
1875
+ question: c.state.goal.question ?? null, costUsd: c.state.goal.costUsd ?? null,
1876
+ cards: (c.state.goal.cards ?? []).map((k) => ({ id: k.id, title: k.title, stage: k.stage, builder: k.builder ?? null,
1877
+ touches: k.touches ?? [], blockedBy: k.blockedBy ?? [], error: k.error ?? null, hold: k.teamHold ?? k.hold ?? null })) } : null })) }, null, 2));
1878
+ return;
1879
+ }
1826
1880
  if (a === "create") {
1827
1881
  const [first, ...rest] = args ?? [];
1828
1882
  const template = first && templates.includes(first.toLowerCase()) ? first.toLowerCase() : "ship";
@@ -1882,10 +1936,16 @@ function printCrew(c) {
1882
1936
  console.log(` ${color(g.status)} ${g.text.split("\n")[0].slice(0, 80)} ${pc.dim(g.branch)}`);
1883
1937
  for (const card of g.cards ?? []) {
1884
1938
  const m = card.stage === "done" ? pc.green("✓") : card.stage === "failed" ? pc.red("✗") : card.stage === "planned" ? pc.dim("·") : pc.cyan("→");
1885
- console.log(` ${m} ${card.title}${card.builder ? pc.dim(` ${card.builder}`) : ""}${card.stage !== "done" && card.stage !== "planned" ? pc.dim(` [${card.stage}]`) : ""}${card.error ? pc.red(` ${card.error.slice(0, 80)}`) : ""}`);
1939
+ console.log(` ${m} ${pc.dim(card.id)} ${card.title}${card.builder ? pc.dim(` ${card.builder}`) : ""}${card.stage !== "done" && card.stage !== "planned" ? pc.dim(` [${card.stage}]`) : ""}${card.error ? pc.red(` ${card.error.slice(0, 80)}`) : ""}`);
1886
1940
  }
1941
+ // a lease hold is settled with overlap (widen or narrow the card's paths), not by answering it
1942
+ const leaseHold = (g.cards ?? []).find((k) => k.teamHold?.kind === "decide");
1887
1943
  if (g.question)
1888
- console.log(pc.yellow(` ${g.question.teammate} asks: ${g.question.text}`) + pc.dim(" (loom crew say \"…\")"));
1944
+ console.log(pc.yellow(` ${g.question.teammate} asks: ${g.question.text}`) + pc.dim(leaseHold
1945
+ ? ` (loom crew overlap --card ${leaseHold.id} --decision "proceed: <why>" --touches "<paths>")`
1946
+ : " (loom crew say \"…\")"));
1947
+ for (const k of (g.cards ?? []).filter((x) => x.stage === "failed"))
1948
+ console.log(pc.dim(` take ${k.id} as it stands: loom crew accept --card ${k.id}`));
1889
1949
  if (g.status === "awaiting_approval")
1890
1950
  console.log(pc.dim(" loom crew approve — or loom crew say \"…\" to change the plan"));
1891
1951
  if (g.status === "completed" && !g.applied)
@@ -2577,7 +2637,8 @@ program
2577
2637
  const ago = weight.ageMs >= 3_600_000 ? `${Math.round(weight.ageMs / 3_600_000)}h` : `${Math.round(weight.ageMs / 60_000)}m`;
2578
2638
  console.error(pc.yellow(`⚠ ${target}'s session here is ${Math.round(weight.tokens / 1000)}k tokens, last used ${ago} ago — its cache has likely expired, so this turn re-reads all of it` +
2579
2639
  `${weight.estUsd !== undefined ? ` (~${fmtUsd(weight.estUsd)} est.)` : ""}. --fresh starts a new session instead.`));
2580
- if (process.stdin.isTTY && !opts.wait) {
2640
+ // a person at a terminal is asked, --wait or not; a script only gets the warning (#300)
2641
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2581
2642
  const go = await confirm(pc.yellow("send it to the existing session anyway?"));
2582
2643
  if (!go)
2583
2644
  return void console.log(pc.dim(`not sent — loom send --fresh${opts.agent ? ` -a ${opts.agent}` : ""} "…"`));
@@ -3124,6 +3185,22 @@ program
3124
3185
  console.log(pc.dim(" no turns recorded yet"));
3125
3186
  console.log(pc.dim("\n(costs come from agents that report them — claude-code and opencode do)"));
3126
3187
  });
3188
+ program
3189
+ .command("memory:repair")
3190
+ .description("mend decisions recorded before 1.2.1 whose underscores and paths were stripped, from the planner's own words")
3191
+ .option("--dry-run", "show what would change, and change nothing")
3192
+ .action(async (opts) => {
3193
+ const client = await ensureDaemon();
3194
+ const project = await currentProject(client);
3195
+ const { repaired, checked, changes } = await client.repairDecisions(project.id, { dryRun: opts.dryRun });
3196
+ if (opts.dryRun) {
3197
+ for (const c of changes ?? [])
3198
+ console.log(`${pc.dim(c.id)}\n ${pc.red("- " + c.before)}\n ${pc.green("+ " + c.after)}`);
3199
+ console.log(repaired ? pc.yellow(`would mend ${repaired} of ${checked} review decisions — nothing written (run without --dry-run to apply)`) : pc.dim(`nothing to mend among ${checked} review decisions`));
3200
+ return;
3201
+ }
3202
+ console.log(repaired ? pc.green(`✓ mended ${repaired} of ${checked} review decisions`) : pc.dim(`nothing to mend among ${checked} review decisions`));
3203
+ });
3127
3204
  program
3128
3205
  .command("decision <text...>")
3129
3206
  .description("record a decision into shared memory (projected on every handoff)")
@@ -3272,8 +3349,9 @@ program
3272
3349
  }
3273
3350
  const checks = await envChecks();
3274
3351
  checks.push(...(await (await import("./doctor.js")).diskChecks()));
3352
+ const signedOut = checks.filter((c) => c.status === "fail" && /signed out/.test(c.detail)).map((c) => c.name);
3275
3353
  if (dir)
3276
- checks.push(...projectChecks(dir));
3354
+ checks.push(...projectChecks(dir, signedOut));
3277
3355
  else
3278
3356
  checks.push({ name: "project", status: "warn", detail: "not inside a Loom project (loom init)" });
3279
3357
  // The daemon is often the desktop app's, started without this shell's PATH or keys:
@@ -3339,8 +3417,24 @@ program
3339
3417
  .description("list paired devices, revoke one, or ping them with a test push")
3340
3418
  .option("--revoke <id>", "revoke a paired device's access")
3341
3419
  .option("--ping", "send a test push notification to all registered devices", false)
3420
+ .option("--prune", "revoke devices not seen for --days (never one registered for push, nor Loom Desktop)")
3421
+ .option("--days <n>", "with --prune: how long unseen counts as gone", "30")
3422
+ .option("--dry-run", "with --prune: only list what would go")
3342
3423
  .action(async (opts) => {
3343
3424
  const client = await ensureDaemon();
3425
+ if (opts.prune) {
3426
+ // every pairing made a device and nothing retired them: 86 piled up in a day of testing (#320)
3427
+ const { clients } = await client.pairedClients();
3428
+ const cutoff = Date.now() - Math.max(1, Number(opts.days) || 30) * 86_400_000;
3429
+ const gone = clients.filter((c) => !c.push && !c.owner && (c.lastSeen ?? c.createdAt) < cutoff);
3430
+ for (const c of gone) {
3431
+ if (!opts.dryRun)
3432
+ await client.revokeClient(c.id);
3433
+ console.log(`${opts.dryRun ? pc.dim("would revoke") : pc.green("✓ revoked")} ${c.name} ${pc.dim(`(${c.id}) last seen ${new Date(c.lastSeen ?? c.createdAt).toLocaleDateString()}`)}`);
3434
+ }
3435
+ console.log(pc.dim(gone.length ? `${gone.length} of ${clients.length} devices${opts.dryRun ? " would go" : " revoked"}` : `nothing unseen for ${opts.days} days among ${clients.length} devices`));
3436
+ return;
3437
+ }
3344
3438
  if (opts.revoke) {
3345
3439
  await client.revokeClient(opts.revoke);
3346
3440
  console.log(pc.green(`✓ revoked ${opts.revoke}`));
@@ -3358,9 +3452,11 @@ program
3358
3452
  return void console.log(pc.dim("no paired devices — loom pair"));
3359
3453
  for (const c of clients) {
3360
3454
  console.log(` ${pc.bold(c.name)} ${pc.dim(`(${c.id})`)} paired ${new Date(c.createdAt).toLocaleString()}` +
3455
+ pc.dim(` ·seen ${c.lastSeen ? new Date(c.lastSeen).toLocaleDateString() : "never"}`) +
3456
+ (c.owner ? pc.cyan(" ·owner") : "") +
3361
3457
  (c.push ? pc.green(" ·push✓") : pc.dim(" ·no push")));
3362
3458
  }
3363
- console.log(pc.dim("\nrevoke: loom clients --revoke <id> · test push: loom clients --ping"));
3459
+ console.log(pc.dim("\nrevoke: loom clients --revoke <id> · tidy up: loom clients --prune [--days 30] [--dry-run] · test push: loom clients --ping"));
3364
3460
  });
3365
3461
  program
3366
3462
  .command("pair")