@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.
- package/CHANGELOG.md +61 -0
- package/README.md +31 -10
- package/dist/adapters/antigravity-cli.d.ts +2 -0
- package/dist/adapters/antigravity-cli.js +27 -4
- 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/adapters/opencode.d.ts +4 -0
- package/dist/adapters/opencode.js +18 -0
- package/dist/adapters/opencode.js.map +1 -1
- package/dist/cli/doctor.d.ts +38 -1
- package/dist/cli/doctor.js +157 -3
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +357 -20
- 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 +73 -0
- package/dist/core/council.js.map +1 -0
- package/dist/core/crew-protocol.js +4 -1
- package/dist/core/crew-protocol.js.map +1 -1
- package/dist/core/crew.d.ts +13 -1
- package/dist/core/crew.js +40 -6
- package/dist/core/crew.js.map +1 -1
- package/dist/core/digest.d.ts +2 -0
- package/dist/core/digest.js +9 -0
- package/dist/core/digest.js.map +1 -1
- package/dist/core/loops.js +22 -2
- package/dist/core/loops.js.map +1 -1
- package/dist/core/orchestra.d.ts +16 -0
- package/dist/core/orchestra.js +45 -2
- package/dist/core/orchestra.js.map +1 -1
- package/dist/core/permissions.js +2 -2
- package/dist/core/permissions.js.map +1 -1
- package/dist/core/prices.d.ts +66 -0
- package/dist/core/prices.js +109 -0
- package/dist/core/prices.js.map +1 -0
- package/dist/core/proof.d.ts +13 -1
- package/dist/core/proof.js +89 -17
- 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/routes.js +14 -0
- package/dist/core/routes.js.map +1 -1
- package/dist/core/setup.d.ts +27 -0
- package/dist/core/setup.js +39 -7
- package/dist/core/setup.js.map +1 -1
- package/dist/core/shield.js +7 -3
- package/dist/core/shield.js.map +1 -1
- package/dist/core/shift.d.ts +2 -0
- package/dist/core/shift.js +14 -2
- package/dist/core/shift.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 +60 -1
- package/dist/daemon/client.js +40 -1
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/council.d.ts +98 -0
- package/dist/daemon/council.js +254 -0
- package/dist/daemon/council.js.map +1 -0
- package/dist/daemon/proof.d.ts +6 -0
- package/dist/daemon/proof.js +32 -9
- 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 +95 -0
- package/dist/daemon/review.js +154 -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/crews.js +2 -0
- package/dist/daemon/routes/crews.js.map +1 -1
- package/dist/daemon/routes/messages.js +8 -2
- package/dist/daemon/routes/messages.js.map +1 -1
- 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 +22 -6
- 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/queue.d.ts +7 -0
- package/dist/daemon/runtime/queue.js +13 -1
- package/dist/daemon/runtime/queue.js.map +1 -1
- package/dist/daemon/runtime/relay.d.ts +2 -0
- package/dist/daemon/runtime/relay.js +7 -0
- package/dist/daemon/runtime/relay.js.map +1 -1
- package/dist/daemon/runtime/turns.d.ts +2 -0
- package/dist/daemon/runtime/turns.js +1 -0
- package/dist/daemon/runtime/turns.js.map +1 -1
- package/dist/daemon/runtime-support.js +1 -1
- package/dist/daemon/runtime-support.js.map +1 -1
- package/dist/daemon/runtime.d.ts +65 -3
- package/dist/daemon/runtime.js +238 -5
- 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/daemon/shift.js +1 -1
- package/dist/daemon/shift.js.map +1 -1
- package/dist/providers/agent.js +6 -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 +31 -1
- package/dist/types.js.map +1 -1
- package/dist/web/app.css +51 -0
- package/dist/web/app.js +172 -18
- package/dist/web/app.js.map +3 -3
- package/docs/release-notes/v1.2.0.md +48 -0
- package/docs/release-notes/v1.2.1.md +40 -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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")
|