@loompad/cli 1.1.2 → 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.
- package/CHANGELOG.md +25 -0
- package/README.md +20 -5
- package/dist/adapters/antigravity-cli.d.ts +2 -0
- package/dist/adapters/antigravity-cli.js +20 -1
- package/dist/adapters/antigravity-cli.js.map +1 -1
- package/dist/adapters/model.js +1 -1
- package/dist/adapters/model.js.map +1 -1
- package/dist/cli/doctor.d.ts +30 -1
- package/dist/cli/doctor.js +117 -1
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +294 -14
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/ui.js +11 -7
- package/dist/cli/ui.js.map +1 -1
- package/dist/core/auto-policy.d.ts +46 -0
- package/dist/core/auto-policy.js +181 -0
- package/dist/core/auto-policy.js.map +1 -0
- package/dist/core/council.d.ts +55 -0
- package/dist/core/council.js +71 -0
- package/dist/core/council.js.map +1 -0
- package/dist/core/permissions.js +2 -2
- package/dist/core/permissions.js.map +1 -1
- package/dist/core/prices.d.ts +55 -0
- package/dist/core/prices.js +99 -0
- package/dist/core/prices.js.map +1 -0
- package/dist/core/proof.d.ts +2 -0
- package/dist/core/proof.js +33 -10
- package/dist/core/proof.js.map +1 -1
- package/dist/core/providers.d.ts +13 -0
- package/dist/core/providers.js +17 -0
- package/dist/core/providers.js.map +1 -1
- package/dist/core/replay.js +14 -0
- package/dist/core/replay.js.map +1 -1
- package/dist/core/review.d.ts +60 -0
- package/dist/core/review.js +121 -0
- package/dist/core/review.js.map +1 -0
- package/dist/core/setup.d.ts +13 -0
- package/dist/core/setup.js +17 -1
- package/dist/core/setup.js.map +1 -1
- package/dist/core/turn-wait.d.ts +20 -0
- package/dist/core/turn-wait.js +39 -0
- package/dist/core/turn-wait.js.map +1 -0
- package/dist/core/usage.d.ts +7 -1
- package/dist/core/usage.js +11 -3
- package/dist/core/usage.js.map +1 -1
- package/dist/daemon/client.d.ts +46 -0
- package/dist/daemon/client.js +34 -0
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/council.d.ts +98 -0
- package/dist/daemon/council.js +248 -0
- package/dist/daemon/council.js.map +1 -0
- package/dist/daemon/proof.d.ts +6 -0
- package/dist/daemon/proof.js +22 -3
- package/dist/daemon/proof.js.map +1 -1
- package/dist/daemon/push.js +7 -0
- package/dist/daemon/push.js.map +1 -1
- package/dist/daemon/review.d.ts +89 -0
- package/dist/daemon/review.js +136 -0
- package/dist/daemon/review.js.map +1 -0
- package/dist/daemon/routes/council.d.ts +4 -0
- package/dist/daemon/routes/council.js +29 -0
- package/dist/daemon/routes/council.js.map +1 -0
- package/dist/daemon/routes/review.d.ts +4 -0
- package/dist/daemon/routes/review.js +40 -0
- package/dist/daemon/routes/review.js.map +1 -0
- package/dist/daemon/routes/routing.js +2 -1
- package/dist/daemon/routes/routing.js.map +1 -1
- package/dist/daemon/routes/setup.d.ts +2 -1
- package/dist/daemon/routes/setup.js +13 -5
- package/dist/daemon/routes/setup.js.map +1 -1
- package/dist/daemon/runtime/accounting.d.ts +6 -0
- package/dist/daemon/runtime/accounting.js +37 -1
- package/dist/daemon/runtime/accounting.js.map +1 -1
- package/dist/daemon/runtime/briefings.d.ts +8 -3
- package/dist/daemon/runtime/briefings.js +26 -14
- package/dist/daemon/runtime/briefings.js.map +1 -1
- package/dist/daemon/runtime.d.ts +40 -1
- package/dist/daemon/runtime.js +171 -2
- package/dist/daemon/runtime.js.map +1 -1
- package/dist/daemon/server.js +5 -1
- package/dist/daemon/server.js.map +1 -1
- package/dist/providers/agent.js +3 -0
- package/dist/providers/agent.js.map +1 -1
- package/dist/providers/claude/adapter.d.ts +3 -0
- package/dist/providers/claude/adapter.js +20 -1
- package/dist/providers/claude/adapter.js.map +1 -1
- package/dist/types.d.ts +19 -1
- package/dist/types.js.map +1 -1
- package/dist/web/app.css +44 -0
- package/dist/web/app.js +143 -12
- package/dist/web/app.js.map +3 -3
- package/docs/release-notes/v1.2.0.md +48 -0
- 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
|
-
|
|
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")
|
|
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
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
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
|
-
|
|
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(
|
|
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`));
|