@loompad/cli 1.1.1 → 1.2.0

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 (94) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +20 -5
  3. package/dist/adapters/antigravity-cli.d.ts +2 -0
  4. package/dist/adapters/antigravity-cli.js +20 -1
  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/cli/doctor.d.ts +30 -1
  9. package/dist/cli/doctor.js +117 -1
  10. package/dist/cli/doctor.js.map +1 -1
  11. package/dist/cli/index.js +294 -14
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/ui.js +11 -7
  14. package/dist/cli/ui.js.map +1 -1
  15. package/dist/core/auto-policy.d.ts +46 -0
  16. package/dist/core/auto-policy.js +181 -0
  17. package/dist/core/auto-policy.js.map +1 -0
  18. package/dist/core/council.d.ts +55 -0
  19. package/dist/core/council.js +71 -0
  20. package/dist/core/council.js.map +1 -0
  21. package/dist/core/permissions.js +2 -2
  22. package/dist/core/permissions.js.map +1 -1
  23. package/dist/core/prices.d.ts +55 -0
  24. package/dist/core/prices.js +99 -0
  25. package/dist/core/prices.js.map +1 -0
  26. package/dist/core/proof.d.ts +2 -0
  27. package/dist/core/proof.js +33 -10
  28. package/dist/core/proof.js.map +1 -1
  29. package/dist/core/providers.d.ts +13 -0
  30. package/dist/core/providers.js +17 -0
  31. package/dist/core/providers.js.map +1 -1
  32. package/dist/core/replay.js +14 -0
  33. package/dist/core/replay.js.map +1 -1
  34. package/dist/core/review.d.ts +60 -0
  35. package/dist/core/review.js +121 -0
  36. package/dist/core/review.js.map +1 -0
  37. package/dist/core/setup.d.ts +13 -0
  38. package/dist/core/setup.js +17 -1
  39. package/dist/core/setup.js.map +1 -1
  40. package/dist/core/turn-wait.d.ts +20 -0
  41. package/dist/core/turn-wait.js +39 -0
  42. package/dist/core/turn-wait.js.map +1 -0
  43. package/dist/core/usage.d.ts +7 -1
  44. package/dist/core/usage.js +11 -3
  45. package/dist/core/usage.js.map +1 -1
  46. package/dist/daemon/client.d.ts +46 -0
  47. package/dist/daemon/client.js +34 -0
  48. package/dist/daemon/client.js.map +1 -1
  49. package/dist/daemon/council.d.ts +98 -0
  50. package/dist/daemon/council.js +248 -0
  51. package/dist/daemon/council.js.map +1 -0
  52. package/dist/daemon/proof.d.ts +6 -0
  53. package/dist/daemon/proof.js +22 -3
  54. package/dist/daemon/proof.js.map +1 -1
  55. package/dist/daemon/push.js +7 -0
  56. package/dist/daemon/push.js.map +1 -1
  57. package/dist/daemon/review.d.ts +89 -0
  58. package/dist/daemon/review.js +136 -0
  59. package/dist/daemon/review.js.map +1 -0
  60. package/dist/daemon/routes/council.d.ts +4 -0
  61. package/dist/daemon/routes/council.js +29 -0
  62. package/dist/daemon/routes/council.js.map +1 -0
  63. package/dist/daemon/routes/review.d.ts +4 -0
  64. package/dist/daemon/routes/review.js +40 -0
  65. package/dist/daemon/routes/review.js.map +1 -0
  66. package/dist/daemon/routes/routing.js +2 -1
  67. package/dist/daemon/routes/routing.js.map +1 -1
  68. package/dist/daemon/routes/setup.d.ts +2 -1
  69. package/dist/daemon/routes/setup.js +13 -5
  70. package/dist/daemon/routes/setup.js.map +1 -1
  71. package/dist/daemon/runtime/accounting.d.ts +6 -0
  72. package/dist/daemon/runtime/accounting.js +37 -1
  73. package/dist/daemon/runtime/accounting.js.map +1 -1
  74. package/dist/daemon/runtime/briefings.d.ts +8 -3
  75. package/dist/daemon/runtime/briefings.js +26 -14
  76. package/dist/daemon/runtime/briefings.js.map +1 -1
  77. package/dist/daemon/runtime.d.ts +40 -1
  78. package/dist/daemon/runtime.js +171 -2
  79. package/dist/daemon/runtime.js.map +1 -1
  80. package/dist/daemon/server.js +5 -1
  81. package/dist/daemon/server.js.map +1 -1
  82. package/dist/providers/agent.js +3 -0
  83. package/dist/providers/agent.js.map +1 -1
  84. package/dist/providers/claude/adapter.d.ts +3 -0
  85. package/dist/providers/claude/adapter.js +20 -1
  86. package/dist/providers/claude/adapter.js.map +1 -1
  87. package/dist/types.d.ts +19 -1
  88. package/dist/types.js.map +1 -1
  89. package/dist/web/app.css +48 -1
  90. package/dist/web/app.js +143 -12
  91. package/dist/web/app.js.map +3 -3
  92. package/docs/release-notes/v1.1.2.md +21 -0
  93. package/docs/release-notes/v1.2.0.md +48 -0
  94. 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,175 @@ 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
+ return fail(err instanceof Error ? err.message : String(err));
1201
+ }
1202
+ if (!opts.json)
1203
+ 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`));
1204
+ if (!opts.wait)
1205
+ return void console.log(pc.dim(`review ${review.reviewId} — the summary lands in its chat (loom reviews ${review.reviewId})`));
1206
+ const deadline = Date.now() + Math.max(1, Number(opts.timeout) || 1800) * 1000;
1207
+ while (review.status !== "done" && Date.now() < deadline) {
1208
+ await new Promise((r) => setTimeout(r, 1500));
1209
+ review = (await client.review(project.id, review.reviewId)).review;
1210
+ }
1211
+ if (review.status !== "done") {
1212
+ process.exitCode = 124;
1213
+ return void console.error(pc.yellow(`still reviewing after ${opts.timeout}s — loom reviews ${review.reviewId} later`));
1214
+ }
1215
+ if (opts.json)
1216
+ return void console.log(JSON.stringify(review));
1217
+ printReview(review);
1218
+ });
1219
+ program
1220
+ .command("council <goal...>")
1221
+ .description("one planner, several reviewers: plan → backtest → decide → build → prove → forward-test → fix; only the planner writes")
1222
+ .requiredOption("--planner <id>", "the agent that plans, decides and builds")
1223
+ .requiredOption("--reviewers <ids>", "comma-separated agents that test the plan and the diff, read-only")
1224
+ .option("--no-watch", "start it and return; follow it in the app or with loom council:status")
1225
+ .action(async (words, opts) => {
1226
+ const client = await ensureDaemon();
1227
+ const project = await currentProject(client);
1228
+ let council;
1229
+ try {
1230
+ ({ council } = await client.startCouncil(project.id, { goal: words.join(" "), planner: opts.planner, reviewers: opts.reviewers.split(",").map((r) => r.trim()).filter(Boolean) }));
1231
+ }
1232
+ catch (err) {
1233
+ return fail(err instanceof Error ? err.message : String(err));
1234
+ }
1235
+ console.log(pc.cyan(`🏛 council ${council.councilId}: ${council.planner} plans and builds; ${council.reviewers.join(", ")} review — in its own chat`));
1236
+ if (!opts.watch)
1237
+ return void console.log(pc.dim(`loom council:status ${council.councilId} · loom council:stop`));
1238
+ const shown = new Map();
1239
+ while (true) {
1240
+ council = (await client.council(project.id, council.councilId)).council;
1241
+ for (const st of council.stages) {
1242
+ const key = `${st.status}:${st.detail ?? ""}`;
1243
+ if (st.status === "waiting" || shown.get(st.key) === key)
1244
+ continue;
1245
+ shown.set(st.key, key);
1246
+ console.log(`${councilMark(st.status)} ${pc.bold(st.name.padEnd(13))}${st.detail ? pc.dim(st.detail) : st.status === "running" ? pc.dim("…") : ""}`);
1247
+ }
1248
+ if (council.status !== "running")
1249
+ break;
1250
+ await new Promise((r) => setTimeout(r, 1500));
1251
+ }
1252
+ console.log((council.status === "done" ? pc.green : council.status === "stopped" ? pc.yellow : pc.red)(`\n${council.summary ?? council.status}`));
1253
+ if (council.status === "failed")
1254
+ process.exitCode = 1;
1255
+ });
1256
+ program
1257
+ .command("council:status [councilId]")
1258
+ .description("where a council is, stage by stage (default: the latest)")
1259
+ .action(async (councilId) => {
1260
+ const client = await ensureDaemon();
1261
+ const project = await currentProject(client);
1262
+ const council = councilId ? (await client.council(project.id, councilId)).council : (await client.councils(project.id)).councils[0];
1263
+ if (!council)
1264
+ return void console.log(pc.dim('no councils yet — loom council "<goal>" --planner <id> --reviewers <ids>'));
1265
+ console.log(pc.bold(`council ${council.councilId}`) + pc.dim(` · ${council.goal} · ${council.planner} → ${council.reviewers.join(", ")}`));
1266
+ for (const st of council.stages)
1267
+ console.log(`${councilMark(st.status)} ${st.name.padEnd(13)}${st.detail ? pc.dim(st.detail) : ""}`);
1268
+ if (council.summary)
1269
+ console.log(pc.dim(`\n${council.summary}`));
1270
+ });
1271
+ program
1272
+ .command("council:stop")
1273
+ .description("stop the running council after its current stage")
1274
+ .action(async () => {
1275
+ const client = await ensureDaemon();
1276
+ const project = await currentProject(client);
1277
+ try {
1278
+ const { council } = await client.stopCouncil(project.id);
1279
+ console.log(pc.yellow(`stopping council ${council.councilId}`));
1280
+ }
1281
+ catch (err) {
1282
+ fail(err instanceof Error ? err.message : String(err));
1283
+ }
1284
+ });
1285
+ function councilMark(status) {
1286
+ return status === "done" ? pc.green("✓") : status === "running" ? pc.cyan("◐") : status === "failed" ? pc.red("✗") : status === "skipped" ? pc.dim("–") : pc.dim("·");
1287
+ }
1288
+ program
1289
+ .command("reviews [reviewId]")
1290
+ .description("the latest reviews, or one review's findings")
1291
+ .action(async (reviewId) => {
1292
+ const client = await ensureDaemon();
1293
+ const project = await currentProject(client);
1294
+ if (reviewId) {
1295
+ try {
1296
+ return void printReview((await client.review(project.id, reviewId)).review);
1297
+ }
1298
+ catch (err) {
1299
+ return fail(err instanceof Error ? err.message : String(err));
1300
+ }
1301
+ }
1302
+ const { reviews } = await client.reviews(project.id);
1303
+ if (!reviews.length)
1304
+ return void console.log(pc.dim("no reviews yet — loom review <agents…>"));
1305
+ for (const r of reviews) {
1306
+ const open = (r.findings ?? []).filter((f) => !f.decision).length;
1307
+ console.log(`${pc.bold(r.reviewId)} ${r.status === "done" ? r.summary : pc.yellow("running…")} ${pc.dim(r.agents.join(", "))}${open ? pc.dim(` · ${open} undecided`) : ""}`);
1308
+ }
1309
+ });
1310
+ for (const [verb, decision] of [["accept", "accepted"], ["reject", "rejected"]]) {
1311
+ program
1312
+ .command(`review:${verb} <findings...>`)
1313
+ .description(verb === "accept" ? "accept review findings — each becomes a project decision every agent is briefed with" : "reject review findings")
1314
+ .option("--note <why>", "say why")
1315
+ .action(async (ids, opts) => {
1316
+ const client = await ensureDaemon();
1317
+ const project = await currentProject(client);
1318
+ for (const id of ids) {
1319
+ try {
1320
+ const { finding } = await client.decideFinding(project.id, id, decision, opts.note);
1321
+ console.log(`${verb === "accept" ? pc.green("✓ accepted") : pc.red("✕ rejected")} ${pc.dim(id)} ${finding.text.slice(0, 100)}`);
1322
+ }
1323
+ catch (err) {
1324
+ console.error(pc.red(`${id}: ${err instanceof Error ? err.message : String(err)}`));
1325
+ process.exitCode = 1;
1326
+ }
1327
+ }
1328
+ });
1329
+ }
1330
+ function printReview(review) {
1331
+ if (review.status !== "done")
1332
+ return void console.log(pc.yellow(`review ${review.reviewId} is still running (${review.agents.join(", ")})`));
1333
+ console.log(pc.bold(`review ${review.reviewId}`) + pc.dim(` · ${review.summary}`));
1334
+ const sev = (s) => (s === "high" ? pc.red("high ") : s === "medium" ? pc.yellow("medium") : pc.dim((s ?? "note").padEnd(6)));
1335
+ for (const f of review.findings ?? []) {
1336
+ const mark = f.decision === "accepted" ? pc.green(" ✓") : f.decision === "rejected" ? pc.red(" ✕") : "";
1337
+ 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}`);
1338
+ }
1339
+ for (const r of review.results ?? []) {
1340
+ if (r.edited?.length)
1341
+ console.log(pc.yellow(` ⚠ ${r.agent} changed files in a read-only review: ${r.edited.join(", ")}`));
1342
+ if (r.status === "failed")
1343
+ console.log(pc.red(` ✗ ${r.agent} didn't finish: ${r.error}`));
1344
+ else if (!(review.findings ?? []).some((f) => f.agent === r.agent || (f.agreed ?? []).some((a) => a.agent === r.agent)))
1345
+ console.log(pc.dim(` ${r.agent}: ${r.reply.split("\n").find((l) => l.trim())?.slice(0, 160) ?? "(no reply)"}`));
1346
+ }
1347
+ if ((review.findings ?? []).some((f) => !f.decision))
1348
+ console.log(pc.dim(`\naccept or reject: loom review:accept <id…> · loom review:reject <id…> [--note why] · or on the card in the app`));
1349
+ }
1145
1350
  program
1146
1351
  .command("spawn <task>")
1147
1352
  .description("fan a subtask out to a child agent — the parent keeps the baton")
@@ -2295,6 +2500,28 @@ program
2295
2500
  // ---------------------------------------------------------------------------
2296
2501
  // send / chat / handoff / interrupt / decision / log
2297
2502
  // ---------------------------------------------------------------------------
2503
+ /**
2504
+ * .loom/ is this machine's runtime state — the event log, attachments, a 0600 config — not something
2505
+ * to commit (a team's shared memory lives in AGENTS.md). In a git repo that doesn't ignore it yet,
2506
+ * offer to (#256).
2507
+ */
2508
+ async function offerGitignore(dir, choice) {
2509
+ if (choice === false || !fs.existsSync(path.join(dir, ".git")))
2510
+ return;
2511
+ try {
2512
+ const { execFileSync } = await import("node:child_process");
2513
+ execFileSync("git", ["check-ignore", "-q", ".loom/"], { cwd: dir, stdio: "ignore" });
2514
+ return; // already ignored
2515
+ }
2516
+ catch { /* not ignored (exit 1), or no git: fall through */ }
2517
+ 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"))));
2518
+ if (!yes)
2519
+ return void console.log(pc.dim("tip: .loom/ is local state — add it to .gitignore (loom init --gitignore)"));
2520
+ const file = path.join(dir, ".gitignore");
2521
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
2522
+ fs.writeFileSync(file, `${before}${before && !before.endsWith("\n") ? "\n" : ""}# Loom's local state (event log, attachments, machine config)\n.loom/\n`);
2523
+ console.log(pc.green("✓ added .loom/ to .gitignore"));
2524
+ }
2298
2525
  async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true) {
2299
2526
  try {
2300
2527
  return await client.send(projectId, text, agentId);
@@ -2317,12 +2544,45 @@ program
2317
2544
  .command("send <text...>")
2318
2545
  .description("send one message into the shared thread")
2319
2546
  .option("-a, --agent <id>", "address a specific agent (may require a handoff)")
2547
+ .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")
2548
+ .option("--json", "with --wait: print the outcome as one JSON object")
2549
+ .option("--timeout <seconds>", "with --wait: give up after this long", "1800")
2320
2550
  .action(async (words, opts) => {
2321
2551
  const client = await ensureDaemon();
2322
2552
  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)`));
2553
+ // where the log is now, so the wait reads only what this send causes (#254)
2554
+ const since = opts.wait ? ((await client.events(project.id, undefined, 1)).events.at(-1)?.id ?? 0) : 0;
2555
+ const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent, !opts.wait || process.stdin.isTTY);
2556
+ if (!result)
2557
+ return;
2558
+ if (!opts.wait)
2559
+ return void console.log(pc.dim(`→ sent to ${result.agentId} (loom log --follow to watch)`));
2560
+ const deadline = Date.now() + Math.max(1, Number(opts.timeout) || 1800) * 1000;
2561
+ let seen = [], outcome = null, cursor = since;
2562
+ while (Date.now() < deadline && !outcome) {
2563
+ const { events } = await client.events(project.id, cursor, 500);
2564
+ if (events.length) {
2565
+ seen = seen.concat(events);
2566
+ cursor = events.at(-1).id;
2567
+ }
2568
+ outcome = turnOutcome(seen, result.agentId, since);
2569
+ if (!outcome)
2570
+ await new Promise((r) => setTimeout(r, 400));
2571
+ }
2572
+ process.exitCode = exitCodeFor(outcome);
2573
+ if (opts.json)
2574
+ return void console.log(JSON.stringify(outcome ?? { status: "timeout", agent: result.agentId }));
2575
+ if (!outcome)
2576
+ return void console.error(pc.yellow(`timed out after ${opts.timeout}s — ${result.agentId} is still working (loom log --follow)`));
2577
+ if (outcome.reply)
2578
+ console.log(outcome.reply);
2579
+ const tail = [outcome.durationMs ? `${(outcome.durationMs / 1000).toFixed(1)}s` : "", outcome.costUsd ? `$${outcome.costUsd.toFixed(4)}` : ""].filter(Boolean).join(" · ");
2580
+ if (outcome.status === "error")
2581
+ console.error(pc.red(`✗ ${outcome.agent}: ${outcome.error}`));
2582
+ else if (outcome.status === "needs_input")
2583
+ console.error(pc.yellow(`? ${outcome.agent} asks: ${outcome.question}`));
2584
+ else
2585
+ console.error(pc.dim(`✓ ${outcome.agent}${tail ? ` · ${tail}` : ""}`));
2326
2586
  });
2327
2587
  program
2328
2588
  .command("chat")
@@ -2814,10 +3074,13 @@ program
2814
3074
  }
2815
3075
  const { costs } = await client.costs(project.id);
2816
3076
  console.log(pc.bold(`${project.name}`) +
2817
- pc.dim(` total ${fmtUsd(costs.totalUsd)} · ${costs.turns} turns · ${(costs.totalMs / 1000).toFixed(0)}s agent time`));
3077
+ 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
3078
  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`);
3079
+ const est = a.estUsd !== undefined ? pc.dim(` ~${fmtUsd(a.estUsd)} est.`) : !a.usd && a.tokensIn + a.tokensOut ? pc.dim(" no price reported") : "";
3080
+ 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
3081
  }
3082
+ if (costs.estUsd !== undefined)
3083
+ 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
3084
  if (!costs.byAgent.length)
2822
3085
  console.log(pc.dim(" no turns recorded yet"));
2823
3086
  console.log(pc.dim("\n(costs come from agents that report them — claude-code and opencode do)"));
@@ -2951,7 +3214,7 @@ program
2951
3214
  .option("--json", "print machine-readable diagnostic results")
2952
3215
  .option("--fix", "repair what has exactly one safe repair; report the rest")
2953
3216
  .action(async (opts) => {
2954
- const { doctorReport, envChecks, projectChecks, fixProject } = await import("./doctor.js");
3217
+ const { doctorReport, envChecks, projectChecks, fixProject, compareWithDaemon, envFacts } = await import("./doctor.js");
2955
3218
  const dir = currentProjectDir();
2956
3219
  if (opts.fix) {
2957
3220
  if (!dir) {
@@ -2973,6 +3236,23 @@ program
2973
3236
  checks.push(...projectChecks(dir));
2974
3237
  else
2975
3238
  checks.push({ name: "project", status: "warn", detail: "not inside a Loom project (loom init)" });
3239
+ // The daemon is often the desktop app's, started without this shell's PATH or keys:
3240
+ // ask it what it sees, and say where the two differ. Its provider and free-model
3241
+ // checks run there because its keys are the ones turns use. Never starts a daemon.
3242
+ const dcfg = readDaemonConfig();
3243
+ if (dcfg && checks.some((c) => c.name === "daemon" && !/not responding|never started/.test(c.detail))) {
3244
+ try {
3245
+ const dclient = new DaemonClient(dcfg);
3246
+ const pid = dir ? findProject(dir)?.id : undefined;
3247
+ const theirs = await dclient.doctor(pid);
3248
+ if (theirs.facts)
3249
+ checks.push(...compareWithDaemon({ checks, facts: envFacts() }, { checks: theirs.checks, facts: theirs.facts }));
3250
+ checks.push(...theirs.checks.filter((c) => /^(provider|model):/.test(c.name)));
3251
+ }
3252
+ catch (err) {
3253
+ checks.push({ name: "daemon:env", status: "warn", detail: `couldn't ask the daemon what it sees: ${err instanceof Error ? err.message : String(err)}` });
3254
+ }
3255
+ }
2976
3256
  const report = doctorReport(checks);
2977
3257
  if (opts.json) {
2978
3258
  console.log(JSON.stringify(report));
@@ -2982,7 +3262,7 @@ program
2982
3262
  }
2983
3263
  for (const c of checks) {
2984
3264
  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}`);
3265
+ console.log(` ${icon} ${pc.bold(c.name.padEnd(15))} ${c.status === "ok" ? pc.dim(c.detail) : c.detail}`);
2986
3266
  }
2987
3267
  if (report.summary.failures) {
2988
3268
  console.log(pc.red(`\n${report.summary.failures} problem${report.summary.failures > 1 ? "s" : ""} found`));