@loompad/cli 1.1.2 → 1.2.1

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 (134) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +31 -10
  3. package/dist/adapters/antigravity-cli.d.ts +2 -0
  4. package/dist/adapters/antigravity-cli.js +27 -4
  5. package/dist/adapters/antigravity-cli.js.map +1 -1
  6. package/dist/adapters/model.js +1 -1
  7. package/dist/adapters/model.js.map +1 -1
  8. package/dist/adapters/opencode.d.ts +4 -0
  9. package/dist/adapters/opencode.js +18 -0
  10. package/dist/adapters/opencode.js.map +1 -1
  11. package/dist/cli/doctor.d.ts +38 -1
  12. package/dist/cli/doctor.js +157 -3
  13. package/dist/cli/doctor.js.map +1 -1
  14. package/dist/cli/index.js +357 -20
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/ui.js +11 -7
  17. package/dist/cli/ui.js.map +1 -1
  18. package/dist/core/auto-policy.d.ts +46 -0
  19. package/dist/core/auto-policy.js +181 -0
  20. package/dist/core/auto-policy.js.map +1 -0
  21. package/dist/core/council.d.ts +55 -0
  22. package/dist/core/council.js +73 -0
  23. package/dist/core/council.js.map +1 -0
  24. package/dist/core/crew-protocol.js +4 -1
  25. package/dist/core/crew-protocol.js.map +1 -1
  26. package/dist/core/crew.d.ts +13 -1
  27. package/dist/core/crew.js +40 -6
  28. package/dist/core/crew.js.map +1 -1
  29. package/dist/core/digest.d.ts +2 -0
  30. package/dist/core/digest.js +9 -0
  31. package/dist/core/digest.js.map +1 -1
  32. package/dist/core/loops.js +22 -2
  33. package/dist/core/loops.js.map +1 -1
  34. package/dist/core/orchestra.d.ts +16 -0
  35. package/dist/core/orchestra.js +45 -2
  36. package/dist/core/orchestra.js.map +1 -1
  37. package/dist/core/permissions.js +2 -2
  38. package/dist/core/permissions.js.map +1 -1
  39. package/dist/core/prices.d.ts +66 -0
  40. package/dist/core/prices.js +109 -0
  41. package/dist/core/prices.js.map +1 -0
  42. package/dist/core/proof.d.ts +13 -1
  43. package/dist/core/proof.js +89 -17
  44. package/dist/core/proof.js.map +1 -1
  45. package/dist/core/providers.d.ts +13 -0
  46. package/dist/core/providers.js +17 -0
  47. package/dist/core/providers.js.map +1 -1
  48. package/dist/core/replay.js +14 -0
  49. package/dist/core/replay.js.map +1 -1
  50. package/dist/core/review.d.ts +60 -0
  51. package/dist/core/review.js +121 -0
  52. package/dist/core/review.js.map +1 -0
  53. package/dist/core/routes.js +14 -0
  54. package/dist/core/routes.js.map +1 -1
  55. package/dist/core/setup.d.ts +27 -0
  56. package/dist/core/setup.js +39 -7
  57. package/dist/core/setup.js.map +1 -1
  58. package/dist/core/shield.js +7 -3
  59. package/dist/core/shield.js.map +1 -1
  60. package/dist/core/shift.d.ts +2 -0
  61. package/dist/core/shift.js +14 -2
  62. package/dist/core/shift.js.map +1 -1
  63. package/dist/core/turn-wait.d.ts +20 -0
  64. package/dist/core/turn-wait.js +39 -0
  65. package/dist/core/turn-wait.js.map +1 -0
  66. package/dist/core/usage.d.ts +7 -1
  67. package/dist/core/usage.js +11 -3
  68. package/dist/core/usage.js.map +1 -1
  69. package/dist/daemon/client.d.ts +60 -1
  70. package/dist/daemon/client.js +40 -1
  71. package/dist/daemon/client.js.map +1 -1
  72. package/dist/daemon/council.d.ts +98 -0
  73. package/dist/daemon/council.js +254 -0
  74. package/dist/daemon/council.js.map +1 -0
  75. package/dist/daemon/proof.d.ts +6 -0
  76. package/dist/daemon/proof.js +32 -9
  77. package/dist/daemon/proof.js.map +1 -1
  78. package/dist/daemon/push.js +7 -0
  79. package/dist/daemon/push.js.map +1 -1
  80. package/dist/daemon/review.d.ts +95 -0
  81. package/dist/daemon/review.js +154 -0
  82. package/dist/daemon/review.js.map +1 -0
  83. package/dist/daemon/routes/council.d.ts +4 -0
  84. package/dist/daemon/routes/council.js +29 -0
  85. package/dist/daemon/routes/council.js.map +1 -0
  86. package/dist/daemon/routes/crews.js +2 -0
  87. package/dist/daemon/routes/crews.js.map +1 -1
  88. package/dist/daemon/routes/messages.js +8 -2
  89. package/dist/daemon/routes/messages.js.map +1 -1
  90. package/dist/daemon/routes/review.d.ts +4 -0
  91. package/dist/daemon/routes/review.js +40 -0
  92. package/dist/daemon/routes/review.js.map +1 -0
  93. package/dist/daemon/routes/routing.js +2 -1
  94. package/dist/daemon/routes/routing.js.map +1 -1
  95. package/dist/daemon/routes/setup.d.ts +2 -1
  96. package/dist/daemon/routes/setup.js +22 -6
  97. package/dist/daemon/routes/setup.js.map +1 -1
  98. package/dist/daemon/runtime/accounting.d.ts +6 -0
  99. package/dist/daemon/runtime/accounting.js +37 -1
  100. package/dist/daemon/runtime/accounting.js.map +1 -1
  101. package/dist/daemon/runtime/briefings.d.ts +8 -3
  102. package/dist/daemon/runtime/briefings.js +26 -14
  103. package/dist/daemon/runtime/briefings.js.map +1 -1
  104. package/dist/daemon/runtime/queue.d.ts +7 -0
  105. package/dist/daemon/runtime/queue.js +13 -1
  106. package/dist/daemon/runtime/queue.js.map +1 -1
  107. package/dist/daemon/runtime/relay.d.ts +2 -0
  108. package/dist/daemon/runtime/relay.js +7 -0
  109. package/dist/daemon/runtime/relay.js.map +1 -1
  110. package/dist/daemon/runtime/turns.d.ts +2 -0
  111. package/dist/daemon/runtime/turns.js +1 -0
  112. package/dist/daemon/runtime/turns.js.map +1 -1
  113. package/dist/daemon/runtime-support.js +1 -1
  114. package/dist/daemon/runtime-support.js.map +1 -1
  115. package/dist/daemon/runtime.d.ts +65 -3
  116. package/dist/daemon/runtime.js +238 -5
  117. package/dist/daemon/runtime.js.map +1 -1
  118. package/dist/daemon/server.js +5 -1
  119. package/dist/daemon/server.js.map +1 -1
  120. package/dist/daemon/shift.js +1 -1
  121. package/dist/daemon/shift.js.map +1 -1
  122. package/dist/providers/agent.js +6 -0
  123. package/dist/providers/agent.js.map +1 -1
  124. package/dist/providers/claude/adapter.d.ts +3 -0
  125. package/dist/providers/claude/adapter.js +20 -1
  126. package/dist/providers/claude/adapter.js.map +1 -1
  127. package/dist/types.d.ts +31 -1
  128. package/dist/types.js.map +1 -1
  129. package/dist/web/app.css +51 -0
  130. package/dist/web/app.js +172 -18
  131. package/dist/web/app.js.map +3 -3
  132. package/docs/release-notes/v1.2.0.md +48 -0
  133. package/docs/release-notes/v1.2.1.md +40 -0
  134. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -26,15 +26,16 @@ import qrcode from "qrcode-terminal";
26
26
  process.exit(1);
27
27
  }
28
28
  }
29
+ import { exitCodeFor, turnOutcome } from "../core/turn-wait.js";
29
30
  import { DaemonClient, DaemonError, daemonRunning, ensureDaemon, stopDaemon, } from "../daemon/client.js";
30
31
  import { installCrashGuards } from "../daemon/guards.js";
31
32
  import { LoomDaemon, DEFAULT_PORT } from "../daemon/server.js";
32
- import { ensureLoomHome, loomHome } from "../core/registry.js";
33
+ import { ensureLoomHome, findProject, loomHome, readDaemonConfig } from "../core/registry.js";
33
34
  import { VERSION } from "../version.js";
34
35
  import { makeBackup } from "../core/backup.js";
35
36
  import { threadMarkdown } from "../core/thread-md.js";
36
37
  import { EventLog } from "../core/eventlog.js";
37
- import { allModels, fetchModels, forgetProvider, listProviders, resolveProvider, setProvider, } from "../core/providers.js";
38
+ import { allModels, fetchModels, forgetProvider, listProviders, resolveProvider, setProvider, mergeProviderViews, providerOfModel } from "../core/providers.js";
38
39
  import { serviceFile, tokenFindings } from "../core/runner-setup.js";
39
40
  import { readRunnerConfig, readRunnerToken, scrubEnv, writeRunnerToken } from "../daemon/runner.js";
40
41
  import { NoProjectError, currentProjectDir, resolveCurrentProject } from "./common.js";
@@ -271,6 +272,8 @@ program
271
272
  .command("init")
272
273
  .description("make the current directory a Loom project")
273
274
  .option("--name <name>", "project name (default: directory name)")
275
+ .option("--gitignore", "add .loom/ to .gitignore without asking")
276
+ .option("--no-gitignore", "leave .gitignore alone")
274
277
  .action(async (opts) => {
275
278
  const client = await ensureDaemon();
276
279
  const dir = process.cwd();
@@ -279,6 +282,7 @@ program
279
282
  console.log(pc.green(`✓ project "${project.name}" (${project.id})`));
280
283
  for (const a of project.agents)
281
284
  console.log(formatAgentRow(a));
285
+ await offerGitignore(dir, opts.gitignore);
282
286
  console.log(pc.dim("\nedit .loom/config.json to add/remove agents or change roles"));
283
287
  console.log(pc.dim("then: loom chat"));
284
288
  });
@@ -330,16 +334,36 @@ program
330
334
  });
331
335
  program
332
336
  .command("providers")
333
- .description("where model agents send their turns, and whether each has a key")
334
- .action(() => {
335
- const rows = listProviders();
337
+ .description("where model agents send their turns, and whether each has a key — as the daemon sees it")
338
+ .action(async () => {
339
+ // the daemon makes the calls, so its view is the true one; a key only this shell has is called out (#249)
340
+ const local = listProviders();
341
+ const cfg = await daemonRunning();
342
+ let rows = local;
343
+ if (cfg) {
344
+ try {
345
+ rows = mergeProviderViews((await new DaemonClient(cfg).providers()).providers, local);
346
+ }
347
+ catch {
348
+ console.log(pc.yellow("couldn't ask the daemon — showing this shell's view"));
349
+ }
350
+ }
351
+ else
352
+ console.log(pc.dim("the daemon isn't running — this is this shell's view; a daemon started elsewhere (the desktop app) may see different keys"));
353
+ let shellOnly = 0;
336
354
  for (const p of rows) {
337
- const mark = p.configured ? pc.green("\u2713") : pc.dim("\u00b7");
338
- const key = p.configured ? pc.dim(p.hint ? `${p.hint} (${p.source})` : "no key needed") : pc.dim("not configured");
355
+ const mark = p.configured ? pc.green("\u2713") : p.shellOnly ? pc.yellow("!") : pc.dim("\u00b7");
356
+ const key = p.configured ? pc.dim(p.hint ? `${p.hint} (${p.source})` : "no key needed")
357
+ : p.shellOnly ? pc.yellow(`${p.hint} in your shell, not in the daemon`) : pc.dim("not configured");
339
358
  console.log(`${mark} ${pc.bold(p.id.padEnd(13))} ${pc.dim(p.baseUrl.padEnd(34))} ${key}`);
340
359
  if (p.note)
341
360
  console.log(pc.dim(` ${p.note}`));
361
+ if (p.shellOnly)
362
+ shellOnly++;
342
363
  }
364
+ if (shellOnly)
365
+ console.log(pc.yellow(`\n${shellOnly === 1 ? "a key is" : `${shellOnly} keys are`} set in this shell but not in the daemon, so its calls fail with "no key".`) +
366
+ pc.dim(`\n Save it where the daemon reads it: loom providers:set <id> --key … (or restart the daemon from this shell: loom up --restart)`));
343
367
  const fu = freeUsage();
344
368
  console.log(`\n${pc.bold("free models")} ${fu.used} request${fu.used === 1 ? "" : "s"} from this machine today (UTC)` +
345
369
  (fu.cappedUntil ? pc.yellow(` · daily cap hit, resets ${new Date(fu.cappedUntil).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`) : "") +
@@ -405,6 +429,18 @@ program
405
429
  }
406
430
  if (!models.length)
407
431
  fail('which models? e.g. loom ask --models "a,b" "your question"');
432
+ // every provider these models need must have a key in the daemon — check once, before sending any (#258)
433
+ try {
434
+ const keyed = new Map((await client.providers()).providers.map((p) => [p.id, p.configured]));
435
+ const missing = [...new Set(models.map((m) => providerOfModel(m, keyed)))].filter((p) => keyed.has(p) && !keyed.get(p));
436
+ if (missing.length)
437
+ fail(`${missing.join(", ")} ${missing.length === 1 ? "has" : "have"} no key in the daemon — nothing was sent. loom providers:set ${missing[0]} --key … (loom providers shows what the daemon sees)`);
438
+ }
439
+ catch (err) {
440
+ if (err instanceof DaemonError) { /* an older daemon without /api/providers: let the asks say it */ }
441
+ else
442
+ throw err;
443
+ }
408
444
  try {
409
445
  const { asked } = await client.askModels(project.id, words.join(" "), models, {
410
446
  ...(opts.title ? { title: opts.title } : {}),
@@ -1142,6 +1178,182 @@ program
1142
1178
  process.exitCode = 1;
1143
1179
  }
1144
1180
  });
1181
+ program
1182
+ .command("review <agents...>")
1183
+ .description("several agents review the same change at once — read-only, off the baton — and their findings land in one place")
1184
+ .option("--file <path>", "review this file")
1185
+ .option("--diff", "review the uncommitted changes (the default)")
1186
+ .option("--text <ask>", "review something you describe instead")
1187
+ .option("--focus <what>", "what to look hardest at, e.g. \"error handling\"")
1188
+ .option("--no-wait", "start it and return; the summary lands in the thread")
1189
+ .option("--json", "print the finished review as JSON")
1190
+ .option("--timeout <seconds>", "stop waiting after this long", "1800")
1191
+ .action(async (agentArgs, opts) => {
1192
+ const client = await ensureDaemon();
1193
+ const project = await currentProject(client);
1194
+ const agents = agentArgs.flatMap((a) => a.split(",")).map((a) => a.trim()).filter(Boolean);
1195
+ let review;
1196
+ try {
1197
+ ({ review } = await client.startReview(project.id, { agents, ...(opts.file ? { file: opts.file } : {}), ...(opts.text ? { text: opts.text } : {}), ...(opts.focus ? { focus: opts.focus } : {}) }));
1198
+ }
1199
+ catch (err) {
1200
+ if (opts.json) {
1201
+ process.exitCode = 1;
1202
+ return void console.log(JSON.stringify({ status: "error", error: err instanceof Error ? err.message : String(err) }));
1203
+ }
1204
+ return fail(err instanceof Error ? err.message : String(err));
1205
+ }
1206
+ if (!opts.json)
1207
+ 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
+ if (!opts.wait)
1209
+ return void console.log(pc.dim(`review ${review.reviewId} — the summary lands in its chat (loom reviews ${review.reviewId})`));
1210
+ const deadline = Date.now() + Math.max(1, Number(opts.timeout) || 1800) * 1000;
1211
+ while (review.status !== "done" && Date.now() < deadline) {
1212
+ await new Promise((r) => setTimeout(r, 1500));
1213
+ review = (await client.review(project.id, review.reviewId)).review;
1214
+ }
1215
+ if (review.status !== "done") {
1216
+ process.exitCode = 124;
1217
+ // valid JSON on every exit path: who has answered so far, and who hasn't (#291)
1218
+ if (opts.json)
1219
+ return void console.log(JSON.stringify({ ...review, status: "timeout", waitingOn: review.agents }));
1220
+ return void console.error(pc.yellow(`still reviewing after ${opts.timeout}s — loom reviews ${review.reviewId} later`));
1221
+ }
1222
+ if (opts.json)
1223
+ return void console.log(JSON.stringify(review));
1224
+ printReview(review);
1225
+ });
1226
+ program
1227
+ .command("council <goal...>")
1228
+ .description("one planner, several reviewers: plan → backtest → decide → build → prove → forward-test → fix; only the planner writes")
1229
+ .requiredOption("--planner <id>", "the agent that plans, decides and builds")
1230
+ .requiredOption("--reviewers <ids>", "comma-separated agents that test the plan and the diff, read-only")
1231
+ .option("--no-watch", "start it and return; follow it in the app or with loom council:status")
1232
+ .action(async (words, opts) => {
1233
+ const client = await ensureDaemon();
1234
+ const project = await currentProject(client);
1235
+ let council;
1236
+ try {
1237
+ ({ council } = await client.startCouncil(project.id, { goal: words.join(" "), planner: opts.planner, reviewers: opts.reviewers.split(",").map((r) => r.trim()).filter(Boolean) }));
1238
+ }
1239
+ catch (err) {
1240
+ return fail(err instanceof Error ? err.message : String(err));
1241
+ }
1242
+ console.log(pc.cyan(`🏛 council ${council.councilId}: ${council.planner} plans and builds; ${council.reviewers.join(", ")} review — in its own chat`));
1243
+ if (!opts.watch)
1244
+ return void console.log(pc.dim(`loom council:status ${council.councilId} · loom council:stop`));
1245
+ const shown = new Map();
1246
+ while (true) {
1247
+ council = (await client.council(project.id, council.councilId)).council;
1248
+ for (const st of council.stages) {
1249
+ const key = `${st.status}:${st.detail ?? ""}`;
1250
+ if (st.status === "waiting" || shown.get(st.key) === key)
1251
+ continue;
1252
+ shown.set(st.key, key);
1253
+ console.log(`${councilMark(st.status)} ${pc.bold(st.name.padEnd(13))}${st.detail ? pc.dim(st.detail) : st.status === "running" ? pc.dim("…") : ""}`);
1254
+ }
1255
+ if (council.status !== "running")
1256
+ break;
1257
+ await new Promise((r) => setTimeout(r, 1500));
1258
+ }
1259
+ console.log((council.status === "done" ? pc.green : council.status === "stopped" ? pc.yellow : pc.red)(`\n${council.summary ?? council.status}`));
1260
+ if (council.status === "failed")
1261
+ process.exitCode = 1;
1262
+ });
1263
+ program
1264
+ .command("council:status [councilId]")
1265
+ .description("where a council is, stage by stage (default: the latest)")
1266
+ .action(async (councilId) => {
1267
+ const client = await ensureDaemon();
1268
+ const project = await currentProject(client);
1269
+ const council = councilId ? (await client.council(project.id, councilId)).council : (await client.councils(project.id)).councils[0];
1270
+ if (!council)
1271
+ return void console.log(pc.dim('no councils yet — loom council "<goal>" --planner <id> --reviewers <ids>'));
1272
+ console.log(pc.bold(`council ${council.councilId}`) + pc.dim(` · ${council.goal} · ${council.planner} → ${council.reviewers.join(", ")}`));
1273
+ for (const st of council.stages)
1274
+ console.log(`${councilMark(st.status)} ${st.name.padEnd(13)}${st.detail ? pc.dim(st.detail) : ""}`);
1275
+ if (council.summary)
1276
+ console.log(pc.dim(`\n${council.summary}`));
1277
+ });
1278
+ program
1279
+ .command("council:stop")
1280
+ .description("stop the running council after its current stage")
1281
+ .action(async () => {
1282
+ const client = await ensureDaemon();
1283
+ const project = await currentProject(client);
1284
+ try {
1285
+ const { council } = await client.stopCouncil(project.id);
1286
+ console.log(pc.yellow(`stopping council ${council.councilId}`));
1287
+ }
1288
+ catch (err) {
1289
+ fail(err instanceof Error ? err.message : String(err));
1290
+ }
1291
+ });
1292
+ function councilMark(status) {
1293
+ return status === "done" ? pc.green("✓") : status === "running" ? pc.cyan("◐") : status === "failed" ? pc.red("✗") : status === "unverified" ? pc.yellow("?") : status === "skipped" ? pc.dim("–") : pc.dim("·");
1294
+ }
1295
+ program
1296
+ .command("reviews [reviewId]")
1297
+ .description("the latest reviews, or one review's findings")
1298
+ .action(async (reviewId) => {
1299
+ const client = await ensureDaemon();
1300
+ const project = await currentProject(client);
1301
+ if (reviewId) {
1302
+ try {
1303
+ return void printReview((await client.review(project.id, reviewId)).review);
1304
+ }
1305
+ catch (err) {
1306
+ return fail(err instanceof Error ? err.message : String(err));
1307
+ }
1308
+ }
1309
+ const { reviews } = await client.reviews(project.id);
1310
+ if (!reviews.length)
1311
+ return void console.log(pc.dim("no reviews yet — loom review <agents…>"));
1312
+ for (const r of reviews) {
1313
+ const open = (r.findings ?? []).filter((f) => !f.decision).length;
1314
+ console.log(`${pc.bold(r.reviewId)} ${r.status === "done" ? r.summary : pc.yellow("running…")} ${pc.dim(r.agents.join(", "))}${open ? pc.dim(` · ${open} undecided`) : ""}`);
1315
+ }
1316
+ });
1317
+ for (const [verb, decision] of [["accept", "accepted"], ["reject", "rejected"]]) {
1318
+ program
1319
+ .command(`review:${verb} <findings...>`)
1320
+ .description(verb === "accept" ? "accept review findings — each becomes a project decision every agent is briefed with" : "reject review findings")
1321
+ .option("--note <why>", "say why")
1322
+ .action(async (ids, opts) => {
1323
+ const client = await ensureDaemon();
1324
+ const project = await currentProject(client);
1325
+ for (const id of ids) {
1326
+ try {
1327
+ const { finding } = await client.decideFinding(project.id, id, decision, opts.note);
1328
+ console.log(`${verb === "accept" ? pc.green("✓ accepted") : pc.red("✕ rejected")} ${pc.dim(id)} ${finding.text.slice(0, 100)}`);
1329
+ }
1330
+ catch (err) {
1331
+ console.error(pc.red(`${id}: ${err instanceof Error ? err.message : String(err)}`));
1332
+ process.exitCode = 1;
1333
+ }
1334
+ }
1335
+ });
1336
+ }
1337
+ function printReview(review) {
1338
+ if (review.status !== "done")
1339
+ return void console.log(pc.yellow(`review ${review.reviewId} is still running (${review.agents.join(", ")})`));
1340
+ console.log(pc.bold(`review ${review.reviewId}`) + pc.dim(` · ${review.summary}`));
1341
+ const sev = (s) => (s === "high" ? pc.red("high ") : s === "medium" ? pc.yellow("medium") : pc.dim((s ?? "note").padEnd(6)));
1342
+ for (const f of review.findings ?? []) {
1343
+ const mark = f.decision === "accepted" ? pc.green(" ✓") : f.decision === "rejected" ? pc.red(" ✕") : "";
1344
+ console.log(` ${sev(f.severity)} ${pc.dim(f.id.padEnd(10))} ${pc.cyan([f.agent, ...(f.agreed ?? []).map((a) => a.agent)].join("+").padEnd(12))} ${f.file ? pc.bold(`${f.file}${f.line ? `:${f.line}` : ""}`) + " — " : ""}${f.text}${mark}`);
1345
+ }
1346
+ for (const r of review.results ?? []) {
1347
+ if (r.edited?.length)
1348
+ console.log(pc.yellow(` ⚠ ${r.agent} changed files in a read-only review: ${r.edited.join(", ")}`));
1349
+ if (r.status === "failed")
1350
+ console.log(pc.red(` ✗ ${r.agent} didn't finish: ${r.error}`));
1351
+ else if (!(review.findings ?? []).some((f) => f.agent === r.agent || (f.agreed ?? []).some((a) => a.agent === r.agent)))
1352
+ console.log(pc.dim(` ${r.agent}: ${r.reply.split("\n").find((l) => l.trim())?.slice(0, 160) ?? "(no reply)"}`));
1353
+ }
1354
+ if ((review.findings ?? []).some((f) => !f.decision))
1355
+ console.log(pc.dim(`\naccept or reject: loom review:accept <id…> · loom review:reject <id…> [--note why] · or on the card in the app`));
1356
+ }
1145
1357
  program
1146
1358
  .command("spawn <task>")
1147
1359
  .description("fan a subtask out to a child agent — the parent keeps the baton")
@@ -1597,6 +1809,10 @@ program
1597
1809
  .option("--no-approval", "create: the Lead's plan starts without your OK")
1598
1810
  .option("--play <play>", "ship, fanout or race")
1599
1811
  .option("--parallel <n>", "create: maximum concurrent builders (1–4)")
1812
+ .option("--lead <agent>", "create: who leads")
1813
+ .option("--builders <agents>", "create: comma-separated agents that build")
1814
+ .option("--reviewer <agent>", "create: who reviews")
1815
+ .option("--tester <agent>", "create: who tests")
1600
1816
  .option("--amount <usd>", "budget: new goal budget in USD")
1601
1817
  .option("--card <id>", "overlap: held card id")
1602
1818
  .option("--decision <decision>", "overlap: narrow, wait:<goal>, or proceed:<reason>")
@@ -1612,7 +1828,10 @@ program
1612
1828
  const template = first && templates.includes(first.toLowerCase()) ? first.toLowerCase() : "ship";
1613
1829
  const name = (first && templates.includes(first.toLowerCase()) ? rest : args ?? []).join(" ");
1614
1830
  const { crew } = await client.createCrew(project.id, { template, ...(name ? { name } : {}), ...(opts.approval === false ? { planApproval: false } : {}),
1615
- ...(opts.play ? { play: opts.play } : {}), ...(opts.parallel ? { maxParallel: Number(opts.parallel) } : {}) });
1831
+ ...(opts.play ? { play: opts.play } : {}), ...(opts.parallel ? { maxParallel: Number(opts.parallel) } : {}),
1832
+ ...(opts.lead || opts.builders || opts.reviewer || opts.tester ? { seats: { ...(opts.lead ? { lead: opts.lead } : {}),
1833
+ ...(opts.builders ? { builders: opts.builders.split(",").map((x) => x.trim()).filter(Boolean) } : {}),
1834
+ ...(opts.reviewer ? { reviewer: opts.reviewer } : {}), ...(opts.tester ? { tester: opts.tester } : {}) } } : {}) });
1616
1835
  console.log(`${pc.green("✓")} ${pc.bold(crew.name)} (${crew.id}) — ${crew.teammates.map((t) => `${t.id}=${t.agent}`).join(", ")}`);
1617
1836
  console.log(pc.dim(` give it a goal: loom crew goal "…"${crews.length ? ` --crew ${crew.id}` : ""}`));
1618
1837
  return;
@@ -1648,6 +1867,8 @@ program
1648
1867
  console.log(`${pc.green("✓")} ${out.routed === "goal" ? "new goal" : out.routed === "answer" ? "answered" : out.routed === "replan" ? "the Lead replans with that" : `noted for ${out.to}`}`);
1649
1868
  else if (a === "apply")
1650
1869
  console.log(`${pc.green("✓")} merged ${pc.bold(out.merged)} into ${pc.bold(out.into)}`);
1870
+ else if (a === "budget" && !out.goal?.status?.match(/running|planning|awaiting|waiting|paused/))
1871
+ console.log(`${pc.green("✓")} the next goal starts with a $${Number(opts.amount).toFixed(2)} budget`);
1651
1872
  else
1652
1873
  printCrew(out.crew);
1653
1874
  });
@@ -2295,9 +2516,33 @@ program
2295
2516
  // ---------------------------------------------------------------------------
2296
2517
  // send / chat / handoff / interrupt / decision / log
2297
2518
  // ---------------------------------------------------------------------------
2298
- async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true) {
2519
+ /** A session this big, resumed cold, is worth a warning before a turn re-reads it (#280). */
2520
+ const HEAVY_SESSION = 100_000;
2521
+ /**
2522
+ * .loom/ is this machine's runtime state — the event log, attachments, a 0600 config — not something
2523
+ * to commit (a team's shared memory lives in AGENTS.md). In a git repo that doesn't ignore it yet,
2524
+ * offer to (#256).
2525
+ */
2526
+ async function offerGitignore(dir, choice) {
2527
+ if (choice === false || !fs.existsSync(path.join(dir, ".git")))
2528
+ return;
2299
2529
  try {
2300
- return await client.send(projectId, text, agentId);
2530
+ const { execFileSync } = await import("node:child_process");
2531
+ execFileSync("git", ["check-ignore", "-q", ".loom/"], { cwd: dir, stdio: "ignore" });
2532
+ return; // already ignored
2533
+ }
2534
+ catch { /* not ignored (exit 1), or no git: fall through */ }
2535
+ 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"))));
2536
+ if (!yes)
2537
+ return void console.log(pc.dim("tip: .loom/ is local state — add it to .gitignore (loom init --gitignore)"));
2538
+ const file = path.join(dir, ".gitignore");
2539
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
2540
+ fs.writeFileSync(file, `${before}${before && !before.endsWith("\n") ? "\n" : ""}# Loom's local state (event log, attachments, machine config)\n.loom/\n`);
2541
+ console.log(pc.green("✓ added .loom/ to .gitignore"));
2542
+ }
2543
+ async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true, fresh = false) {
2544
+ try {
2545
+ return await client.send(projectId, text, agentId, { fresh });
2301
2546
  }
2302
2547
  catch (err) {
2303
2548
  if (err instanceof DaemonError && err.status === 409 && agentId) {
@@ -2308,7 +2553,7 @@ async function sendWithHandoffConfirm(client, projectId, text, agentId, interact
2308
2553
  if (!yes)
2309
2554
  return null;
2310
2555
  await client.handoff(projectId, agentId);
2311
- return await client.send(projectId, text, agentId);
2556
+ return await client.send(projectId, text, agentId, { fresh });
2312
2557
  }
2313
2558
  throw err;
2314
2559
  }
@@ -2317,12 +2562,61 @@ program
2317
2562
  .command("send <text...>")
2318
2563
  .description("send one message into the shared thread")
2319
2564
  .option("-a, --agent <id>", "address a specific agent (may require a handoff)")
2565
+ .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")
2566
+ .option("--json", "with --wait: print the outcome as one JSON object")
2567
+ .option("--timeout <seconds>", "with --wait: give up after this long", "1800")
2568
+ .option("--fresh", "start the agent's session in this chat over — a short question needn't re-read a long one")
2320
2569
  .action(async (words, opts) => {
2321
2570
  const client = await ensureDaemon();
2322
2571
  const project = await currentProject(client);
2323
- const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent);
2324
- if (result)
2325
- console.log(pc.dim(`→ sent to ${result.agentId} (loom log --follow to watch)`));
2572
+ // a long session whose prompt cache has gone cold is re-read in full: say so before spending it (#280)
2573
+ const target = opts.agent ?? project.holder ?? undefined;
2574
+ if (!opts.fresh && target) {
2575
+ const weight = await client.sessionWeight(project.id, target).then((r) => r.session).catch(() => null);
2576
+ if (weight && weight.cold && weight.tokens >= HEAVY_SESSION) {
2577
+ const ago = weight.ageMs >= 3_600_000 ? `${Math.round(weight.ageMs / 3_600_000)}h` : `${Math.round(weight.ageMs / 60_000)}m`;
2578
+ 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
+ `${weight.estUsd !== undefined ? ` (~${fmtUsd(weight.estUsd)} est.)` : ""}. --fresh starts a new session instead.`));
2580
+ if (process.stdin.isTTY && !opts.wait) {
2581
+ const go = await confirm(pc.yellow("send it to the existing session anyway?"));
2582
+ if (!go)
2583
+ return void console.log(pc.dim(`not sent — loom send --fresh${opts.agent ? ` -a ${opts.agent}` : ""} "…"`));
2584
+ }
2585
+ }
2586
+ }
2587
+ // where the log is now, so the wait reads only what this send causes (#254)
2588
+ const since = opts.wait ? ((await client.events(project.id, undefined, 1)).events.at(-1)?.id ?? 0) : 0;
2589
+ const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent, !opts.wait || process.stdin.isTTY, Boolean(opts.fresh));
2590
+ if (!result)
2591
+ return;
2592
+ if (!opts.wait)
2593
+ return void console.log(pc.dim(`→ sent to ${result.agentId} (loom log --follow to watch)`));
2594
+ const deadline = Date.now() + Math.max(1, Number(opts.timeout) || 1800) * 1000;
2595
+ let seen = [], outcome = null, cursor = since;
2596
+ while (Date.now() < deadline && !outcome) {
2597
+ const { events } = await client.events(project.id, cursor, 500);
2598
+ if (events.length) {
2599
+ seen = seen.concat(events);
2600
+ cursor = events.at(-1).id;
2601
+ }
2602
+ outcome = turnOutcome(seen, result.agentId, since);
2603
+ if (!outcome)
2604
+ await new Promise((r) => setTimeout(r, 400));
2605
+ }
2606
+ process.exitCode = exitCodeFor(outcome);
2607
+ if (opts.json)
2608
+ return void console.log(JSON.stringify(outcome ?? { status: "timeout", agent: result.agentId }));
2609
+ if (!outcome)
2610
+ return void console.error(pc.yellow(`timed out after ${opts.timeout}s — ${result.agentId} is still working (loom log --follow)`));
2611
+ if (outcome.reply)
2612
+ console.log(outcome.reply);
2613
+ const tail = [outcome.durationMs ? `${(outcome.durationMs / 1000).toFixed(1)}s` : "", outcome.costUsd ? `$${outcome.costUsd.toFixed(4)}` : ""].filter(Boolean).join(" · ");
2614
+ if (outcome.status === "error")
2615
+ console.error(pc.red(`✗ ${outcome.agent}: ${outcome.error}`));
2616
+ else if (outcome.status === "needs_input")
2617
+ console.error(pc.yellow(`? ${outcome.agent} asks: ${outcome.question}`));
2618
+ else
2619
+ console.error(pc.dim(`✓ ${outcome.agent}${tail ? ` · ${tail}` : ""}`));
2326
2620
  });
2327
2621
  program
2328
2622
  .command("chat")
@@ -2459,9 +2753,14 @@ program
2459
2753
  cost: pc.dim,
2460
2754
  turn: pc.dim,
2461
2755
  };
2756
+ // a stretch that crosses midnight says which day each moment was; the totals row has no moment (#297)
2757
+ const day = (t) => new Date(t).toDateString();
2758
+ const spans = d.lines.some((l) => l.kind !== "cost" && day(l.at) !== day(Date.now()));
2759
+ const stamp = (l) => l.kind === "cost" ? "in all"
2760
+ : `${spans ? `${new Date(l.at).toLocaleDateString([], { month: "short", day: "numeric" })} ` : ""}${new Date(l.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
2761
+ const width = Math.max(...d.lines.map((l) => stamp(l).length));
2462
2762
  for (const line of d.lines) {
2463
- const when = new Date(line.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2464
- console.log(`${pc.dim(when)} ${(paint[line.kind] ?? ((x) => x))(line.text)}`);
2763
+ console.log(`${pc.dim(stamp(line).padEnd(width))} ${(paint[line.kind] ?? ((x) => x))(line.text)}${line.count && line.count > 1 ? pc.dim(` ×${line.count}`) : ""}`);
2465
2764
  }
2466
2765
  if (d.waiting.length)
2467
2766
  console.log(pc.yellow(`\nwaiting on you: ${d.waiting.join(", ")}`));
@@ -2814,10 +3113,13 @@ program
2814
3113
  }
2815
3114
  const { costs } = await client.costs(project.id);
2816
3115
  console.log(pc.bold(`${project.name}`) +
2817
- pc.dim(` total ${fmtUsd(costs.totalUsd)} · ${costs.turns} turns · ${(costs.totalMs / 1000).toFixed(0)}s agent time`));
3116
+ pc.dim(` total ${fmtUsd(costs.totalUsd)}${costs.estUsd !== undefined ? ` (+ ~${fmtUsd(costs.estUsd)} est.)` : ""} · ${costs.turns} turns · ${(costs.totalMs / 1000).toFixed(0)}s agent time`));
2818
3117
  for (const a of costs.byAgent) {
2819
- console.log(` ${pc.bold(a.agentId.padEnd(14))} ${fmtUsd(a.usd).padStart(9)} ${String(a.turns).padStart(4)} turns ${(a.ms / 1000).toFixed(0).padStart(5)}s`);
3118
+ const est = a.estUsd !== undefined ? pc.dim(` ~${fmtUsd(a.estUsd)} est.`) : !a.usd && a.tokensIn + a.tokensOut ? pc.dim(" no price reported") : "";
3119
+ console.log(` ${pc.bold(a.agentId.padEnd(14))} ${fmtUsd(a.usd).padStart(9)} ${String(a.turns).padStart(4)} turns ${(a.ms / 1000).toFixed(0).padStart(5)}s${est}`);
2820
3120
  }
3121
+ if (costs.estUsd !== undefined)
3122
+ console.log(pc.dim(` est. = tokens at the model's API price, for agents that report tokens but no cost (a subscription sign-in may bill you differently)`));
2821
3123
  if (!costs.byAgent.length)
2822
3124
  console.log(pc.dim(" no turns recorded yet"));
2823
3125
  console.log(pc.dim("\n(costs come from agents that report them — claude-code and opencode do)"));
@@ -2951,7 +3253,7 @@ program
2951
3253
  .option("--json", "print machine-readable diagnostic results")
2952
3254
  .option("--fix", "repair what has exactly one safe repair; report the rest")
2953
3255
  .action(async (opts) => {
2954
- const { doctorReport, envChecks, projectChecks, fixProject } = await import("./doctor.js");
3256
+ const { doctorReport, envChecks, projectChecks, fixProject, compareWithDaemon, envFacts } = await import("./doctor.js");
2955
3257
  const dir = currentProjectDir();
2956
3258
  if (opts.fix) {
2957
3259
  if (!dir) {
@@ -2969,10 +3271,28 @@ program
2969
3271
  // fall through to a fresh diagnosis, so the report reflects the repairs
2970
3272
  }
2971
3273
  const checks = await envChecks();
3274
+ checks.push(...(await (await import("./doctor.js")).diskChecks()));
2972
3275
  if (dir)
2973
3276
  checks.push(...projectChecks(dir));
2974
3277
  else
2975
3278
  checks.push({ name: "project", status: "warn", detail: "not inside a Loom project (loom init)" });
3279
+ // The daemon is often the desktop app's, started without this shell's PATH or keys:
3280
+ // ask it what it sees, and say where the two differ. Its provider and free-model
3281
+ // checks run there because its keys are the ones turns use. Never starts a daemon.
3282
+ const dcfg = readDaemonConfig();
3283
+ if (dcfg && checks.some((c) => c.name === "daemon" && !/not responding|never started/.test(c.detail))) {
3284
+ try {
3285
+ const dclient = new DaemonClient(dcfg);
3286
+ const pid = dir ? findProject(dir)?.id : undefined;
3287
+ const theirs = await dclient.doctor(pid);
3288
+ if (theirs.facts)
3289
+ checks.push(...compareWithDaemon({ checks, facts: envFacts() }, { checks: theirs.checks, facts: theirs.facts }));
3290
+ checks.push(...theirs.checks.filter((c) => /^(provider|model):/.test(c.name)));
3291
+ }
3292
+ catch (err) {
3293
+ checks.push({ name: "daemon:env", status: "warn", detail: `couldn't ask the daemon what it sees: ${err instanceof Error ? err.message : String(err)}` });
3294
+ }
3295
+ }
2976
3296
  const report = doctorReport(checks);
2977
3297
  if (opts.json) {
2978
3298
  console.log(JSON.stringify(report));
@@ -2982,7 +3302,7 @@ program
2982
3302
  }
2983
3303
  for (const c of checks) {
2984
3304
  const icon = c.status === "ok" ? pc.green("✓") : c.status === "warn" ? pc.yellow("⚠") : pc.red("✗");
2985
- console.log(` ${icon} ${pc.bold(c.name.padEnd(11))} ${c.status === "ok" ? pc.dim(c.detail) : c.detail}`);
3305
+ console.log(` ${icon} ${pc.bold(c.name.padEnd(15))} ${c.status === "ok" ? pc.dim(c.detail) : c.detail}`);
2986
3306
  }
2987
3307
  if (report.summary.failures) {
2988
3308
  console.log(pc.red(`\n${report.summary.failures} problem${report.summary.failures > 1 ? "s" : ""} found`));
@@ -2997,6 +3317,23 @@ program
2997
3317
  }
2998
3318
  console.log(pc.green("\nall clear"));
2999
3319
  });
3320
+ program
3321
+ .command("orchestra:clean")
3322
+ .description("remove orchestra worktrees left by projects that are no longer registered")
3323
+ .option("--dry-run", "only say what would go")
3324
+ .action(async (opts) => {
3325
+ const { orchestraOrphans, fmtBytes } = await import("./doctor.js");
3326
+ const orphans = await orchestraOrphans();
3327
+ if (!orphans.length)
3328
+ return void console.log(pc.dim("no orphaned orchestra worktrees"));
3329
+ for (const o of orphans) {
3330
+ if (!opts.dryRun)
3331
+ fs.rmSync(o.dir, { recursive: true, force: true });
3332
+ console.log(`${opts.dryRun ? pc.dim("would remove") : pc.green("✓ removed")} ${o.dir} ${pc.dim(fmtBytes(o.bytes))}`);
3333
+ }
3334
+ if (!opts.dryRun)
3335
+ console.log(pc.dim("their branches stay in each repo: git worktree prune there tidies git's own list"));
3336
+ });
3000
3337
  program
3001
3338
  .command("clients")
3002
3339
  .description("list paired devices, revoke one, or ping them with a test push")