@loompad/cli 1.2.3 → 1.2.5
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 +40 -0
- package/README.md +10 -0
- package/dist/adapters/antigravity-cli.d.ts +7 -0
- package/dist/adapters/antigravity-cli.js +9 -1
- package/dist/adapters/antigravity-cli.js.map +1 -1
- package/dist/adapters/base.d.ts +5 -3
- package/dist/adapters/base.js +7 -2
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/opencode.d.ts +18 -21
- package/dist/adapters/opencode.js +103 -22
- package/dist/adapters/opencode.js.map +1 -1
- package/dist/cli/index.js +137 -23
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/ui.js +11 -1
- package/dist/cli/ui.js.map +1 -1
- package/dist/core/orchestra.d.ts +1 -1
- package/dist/core/orchestra.js +7 -2
- package/dist/core/orchestra.js.map +1 -1
- package/dist/core/turn-wait.d.ts +5 -1
- package/dist/core/turn-wait.js +3 -3
- package/dist/core/turn-wait.js.map +1 -1
- package/dist/daemon/client.d.ts +10 -0
- package/dist/daemon/client.js +28 -1
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/routes/project-team.js +17 -0
- package/dist/daemon/routes/project-team.js.map +1 -1
- package/dist/daemon/runner.js +22 -9
- package/dist/daemon/runner.js.map +1 -1
- package/dist/daemon/team-coordinator.d.ts +1 -1
- package/dist/daemon/team-coordinator.js +2 -2
- package/dist/daemon/team-coordinator.js.map +1 -1
- package/dist/daemon/team.d.ts +33 -0
- package/dist/daemon/team.js +48 -0
- package/dist/daemon/team.js.map +1 -1
- package/dist/web/app.css +8 -0
- package/dist/web/app.js +44 -1
- package/dist/web/app.js.map +2 -2
- package/docs/release-notes/v1.2.4.md +13 -0
- package/docs/release-notes/v1.2.5.md +19 -0
- package/docs/teams.md +3 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import qrcode from "qrcode-terminal";
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
import { exitCodeFor, turnOutcome } from "../core/turn-wait.js";
|
|
30
|
-
import { DaemonClient, DaemonError, daemonRunning, desktopOwnsDaemon, ensureDaemon, stopDaemon, } from "../daemon/client.js";
|
|
30
|
+
import { DaemonClient, DaemonError, daemonRunning, desktopOwnsDaemon, ensureDaemon, stopDaemon, turnsInFlight, } from "../daemon/client.js";
|
|
31
31
|
import { installCrashGuards } from "../daemon/guards.js";
|
|
32
32
|
import { LoomDaemon, DEFAULT_PORT } from "../daemon/server.js";
|
|
33
33
|
import { ensureLoomHome, findProject, loomHome, readDaemonConfig } from "../core/registry.js";
|
|
@@ -200,7 +200,7 @@ program
|
|
|
200
200
|
.description("start the loom daemon in the background")
|
|
201
201
|
.option("--tailnet", "bind to this machine's Tailscale IP", false)
|
|
202
202
|
.option("--restart", "restart even if a daemon is already running", false)
|
|
203
|
-
.option("--force", "with --restart: replace even Loom Desktop's daemon with this CLI's")
|
|
203
|
+
.option("--force", "with --restart: replace even Loom Desktop's daemon with this CLI's, and restart even with turns running")
|
|
204
204
|
.action(async (opts) => {
|
|
205
205
|
const running = await daemonRunning();
|
|
206
206
|
if (running && !opts.force && (await desktopOwnsDaemon(running))) {
|
|
@@ -218,6 +218,14 @@ program
|
|
|
218
218
|
console.log(pc.dim("daemon already running (loom up --restart to bounce it)"));
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
221
|
+
// a restart kills turns in flight: say whose, and leave them be unless told otherwise
|
|
222
|
+
const busy = opts.force ? [] : await turnsInFlight(new DaemonClient(running));
|
|
223
|
+
if (busy.length) {
|
|
224
|
+
console.log(pc.yellow(`${busy.length} turn${busy.length === 1 ? " is" : "s are"} running (${busy.slice(0, 4).join(", ")}${busy.length > 4 ? ", …" : ""}) — ` +
|
|
225
|
+
`a restart would kill ${busy.length === 1 ? "it" : "them"}. Try again when they finish, or loom up --restart --force.`));
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
221
229
|
console.log(pc.dim(stale ? "daemon is running an older build — restarting" : "restarting daemon"));
|
|
222
230
|
await stopDaemon();
|
|
223
231
|
const gone = Date.now() + 6000;
|
|
@@ -274,9 +282,27 @@ program
|
|
|
274
282
|
return void console.log(JSON.stringify(projects)); // #315
|
|
275
283
|
if (!projects.length)
|
|
276
284
|
console.log(pc.dim("no projects yet — loom init"));
|
|
277
|
-
|
|
278
|
-
|
|
285
|
+
// inside a project, it comes first and says so: "is my turn running here?" is the usual question
|
|
286
|
+
const here = await resolveCurrentProject(client).catch(() => null);
|
|
287
|
+
const ordered = here ? [...projects.filter((p) => p.id === here.id), ...projects.filter((p) => p.id !== here.id)] : projects;
|
|
288
|
+
for (const p of ordered)
|
|
289
|
+
console.log(formatProjectRow(p) + (here && p.id === here.id ? pc.bold(pc.cyan(" ← here")) : ""));
|
|
279
290
|
});
|
|
291
|
+
/**
|
|
292
|
+
* Outside a project there's no roster, but the question "what can I run?" still has an
|
|
293
|
+
* answer: the agents installed on this machine (#feedback: agents outside a project).
|
|
294
|
+
*/
|
|
295
|
+
async function printMachineAgents(json) {
|
|
296
|
+
const { ADES, detectAdes } = await import("../core/ades.js");
|
|
297
|
+
const found = await detectAdes();
|
|
298
|
+
const rows = ADES.map((a) => ({ kind: a.kind, label: a.label, tier: a.tier, installed: a.tier === "adapter" ? Boolean(found[a.kind]) : null }));
|
|
299
|
+
if (json)
|
|
300
|
+
return void console.log(JSON.stringify({ project: null, machine: rows }));
|
|
301
|
+
console.log(pc.dim("not in a Loom project — this machine can drive:"));
|
|
302
|
+
for (const r of rows)
|
|
303
|
+
console.log(` ${r.kind.padEnd(18)} ${r.label.padEnd(20)} ${r.installed === false ? pc.dim("not installed") : r.installed ? pc.green("installed") : pc.dim("app (launch it)")}`);
|
|
304
|
+
console.log(pc.dim("\nloom init here to make this folder a project with them"));
|
|
305
|
+
}
|
|
280
306
|
// ---------------------------------------------------------------------------
|
|
281
307
|
// init / projects / agents
|
|
282
308
|
// ---------------------------------------------------------------------------
|
|
@@ -335,7 +361,10 @@ program
|
|
|
335
361
|
.option("--json", "print the agent roster as JSON")
|
|
336
362
|
.action(async (opts) => {
|
|
337
363
|
const client = await ensureDaemon();
|
|
338
|
-
const project = await
|
|
364
|
+
const project = await resolveCurrentProject(client).catch((err) => { if (err instanceof NoProjectError)
|
|
365
|
+
return null; throw err; });
|
|
366
|
+
if (!project)
|
|
367
|
+
return void (await printMachineAgents(Boolean(opts.json)));
|
|
339
368
|
if (opts.json) {
|
|
340
369
|
console.log(JSON.stringify(project.agents));
|
|
341
370
|
return;
|
|
@@ -1894,12 +1923,17 @@ program
|
|
|
1894
1923
|
console.log(pc.dim(`no crews here yet — loom crew create [${templates.join("|")}] [name]`));
|
|
1895
1924
|
return;
|
|
1896
1925
|
}
|
|
1926
|
+
// status shows every crew unless one is named; everything else needs to know which
|
|
1927
|
+
if (a === "status" && !opts.crew) {
|
|
1928
|
+
for (const c of crews)
|
|
1929
|
+
printCrew(c);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1897
1932
|
const crew = opts.crew ? crews.find((c) => c.id === opts.crew || c.name === opts.crew) : crews.length === 1 ? crews[0] : undefined;
|
|
1898
1933
|
if (!crew)
|
|
1899
1934
|
throw new Error(`which crew? --crew ${crews.map((c) => c.id).join(" | ")}`);
|
|
1900
1935
|
if (a === "status") {
|
|
1901
|
-
|
|
1902
|
-
printCrew(c);
|
|
1936
|
+
printCrew(crew);
|
|
1903
1937
|
return;
|
|
1904
1938
|
}
|
|
1905
1939
|
if (a === "diff") {
|
|
@@ -1955,7 +1989,7 @@ function printCrew(c) {
|
|
|
1955
1989
|
}
|
|
1956
1990
|
program
|
|
1957
1991
|
.command("team [action] [args...]")
|
|
1958
|
-
.description("Loom Teams: status | signin [hub] | create <name> | invite | join <link> | share | unshare | brain [inbox|promote|resolve|correct|trust|private] | landing | doctor [fix] | adopt <pr> | deploys | release-notes <since> | webhook [--repo r] [--install] | remove <github> | leave")
|
|
1992
|
+
.description("Loom Teams: status | auto [on] | signin [hub] | create <name> | invite | join <link> | share | unshare | brain [inbox|promote|resolve|correct|trust|private] | landing | doctor [fix] | adopt <pr> | deploys | release-notes <since> | webhook [--repo r] [--install] | remove <github> | leave")
|
|
1959
1993
|
.option("--github <login>", "your GitHub login (defaults to the gh CLI's)")
|
|
1960
1994
|
.option("--repo <owner/name>", "webhook: which repo (defaults to this project's)")
|
|
1961
1995
|
.option("--install", "webhook: create it on the repo with gh (needs repo admin)")
|
|
@@ -1971,6 +2005,24 @@ program
|
|
|
1971
2005
|
try {
|
|
1972
2006
|
if (a === "status")
|
|
1973
2007
|
return void printTeam(await client.team());
|
|
2008
|
+
if (a === "auto") {
|
|
2009
|
+
// team auto-orchestration: your goals split across teammates' machines, theirs across yours
|
|
2010
|
+
const project = await currentProject(client);
|
|
2011
|
+
const st = await client.teamAuto(project.id, arg === "on");
|
|
2012
|
+
if (st.wrotePolicy)
|
|
2013
|
+
console.log(`${pc.green("✓")} wrote loom.team.json (runners.shared) — commit and merge it; policy is reviewed like code`);
|
|
2014
|
+
const mark = (ok) => (ok ? pc.green("✓") : pc.dim("·"));
|
|
2015
|
+
console.log(`${mark(st.shared)} project shared with your team`);
|
|
2016
|
+
console.log(`${mark(st.policyShared)} repo policy lets teammates' runners take its work`);
|
|
2017
|
+
console.log(`${mark(st.myRunner.running && st.myRunner.shared)} your runner is on and shared (teammates' goals can use your agents)`);
|
|
2018
|
+
const on = st.teammates.filter((t) => t.online);
|
|
2019
|
+
console.log(`${mark(on.length > 0)} teammates' runners online: ${on.length ? on.map((t) => `${t.github} (${t.kinds.join(", ")})`).join(", ") : "none yet"}`);
|
|
2020
|
+
if (st.ready)
|
|
2021
|
+
console.log(`\n${pc.green("ready")} — goals you start get ${st.workers.join(", ")} as workers. ${pc.dim("loom orchestrate \"…\" (--solo to keep one local)")}`);
|
|
2022
|
+
else
|
|
2023
|
+
console.log(`\n${pc.yellow("not yet")} — ${st.missing.join("; ")}`);
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
1974
2026
|
if (a === "signin") {
|
|
1975
2027
|
const { hostedHubUrl, hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
|
|
1976
2028
|
const supabaseUrl = hostedSupabaseUrl(arg);
|
|
@@ -2505,13 +2557,43 @@ program
|
|
|
2505
2557
|
process.exitCode = 1;
|
|
2506
2558
|
}
|
|
2507
2559
|
});
|
|
2560
|
+
program
|
|
2561
|
+
.command("agents:mode <agentId> [mode]")
|
|
2562
|
+
.description("an agent's access: bypass (no sandbox — can commit, push, use the network), auto (the default), or ask. No mode: show what each means for it")
|
|
2563
|
+
.action(async (agentId, mode) => {
|
|
2564
|
+
const client = await ensureDaemon();
|
|
2565
|
+
const project = await currentProject(client);
|
|
2566
|
+
const agent = project.agents.find((a) => a.id === agentId);
|
|
2567
|
+
if (!agent)
|
|
2568
|
+
fail(`no agent "${agentId}" here — loom agents`);
|
|
2569
|
+
const { PERMISSION_PROFILES } = await import("../core/permissions.js");
|
|
2570
|
+
const profile = PERMISSION_PROFILES[agent.kind];
|
|
2571
|
+
if (!profile)
|
|
2572
|
+
fail(`${agent.kind} has no access modes to choose from`);
|
|
2573
|
+
const now = (agent.permissions ?? profile.default);
|
|
2574
|
+
if (!mode) {
|
|
2575
|
+
for (const [m, cell] of Object.entries(profile.modes)) {
|
|
2576
|
+
const off = cell.unsupported;
|
|
2577
|
+
console.log(`${m === now ? pc.green("●") : pc.dim("○")} ${pc.bold(m.padEnd(6))} ${off ? pc.dim(`${cell.label} — unavailable: ${off}`) : cell.label}`);
|
|
2578
|
+
}
|
|
2579
|
+
return void console.log(pc.dim(`\nloom agents:mode ${agentId} <bypass|auto|ask>`));
|
|
2580
|
+
}
|
|
2581
|
+
const res = await client.setAgentPermissions(project.id, agentId, mode);
|
|
2582
|
+
const cell = profile.modes[(res.agent.permissions ?? mode)];
|
|
2583
|
+
console.log(`${pc.green("✓")} ${agentId}: ${cell?.label ?? mode}`);
|
|
2584
|
+
if (mode === "bypass")
|
|
2585
|
+
console.log(pc.yellow(" no sandbox: it can commit, push, reach the network and run anything — on this machine, as you"));
|
|
2586
|
+
});
|
|
2508
2587
|
program
|
|
2509
2588
|
.command("agents:available")
|
|
2510
2589
|
.description("which agents this machine can drive, and how many sessions are here")
|
|
2511
2590
|
.option("--json", "print as JSON")
|
|
2512
2591
|
.action(async (opts) => {
|
|
2513
2592
|
const client = await ensureDaemon();
|
|
2514
|
-
const project = await
|
|
2593
|
+
const project = await resolveCurrentProject(client).catch((err) => { if (err instanceof NoProjectError)
|
|
2594
|
+
return null; throw err; });
|
|
2595
|
+
if (!project)
|
|
2596
|
+
return void (await printMachineAgents(Boolean(opts.json)));
|
|
2515
2597
|
const { ades } = await client.availableAgents(project.id);
|
|
2516
2598
|
if (opts.json)
|
|
2517
2599
|
return void console.log(JSON.stringify(ades, null, 2));
|
|
@@ -2592,26 +2674,31 @@ async function offerGitignore(dir, choice) {
|
|
|
2592
2674
|
return; // already ignored
|
|
2593
2675
|
}
|
|
2594
2676
|
catch { /* not ignored (exit 1), or no git: fall through */ }
|
|
2595
|
-
|
|
2677
|
+
// in a terminal, ask; in a script (no one to ask), do it — .loom/ is never meant for the repo
|
|
2678
|
+
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")));
|
|
2596
2679
|
if (!yes)
|
|
2597
2680
|
return void console.log(pc.dim("tip: .loom/ is local state — add it to .gitignore (loom init --gitignore)"));
|
|
2598
2681
|
const file = path.join(dir, ".gitignore");
|
|
2599
2682
|
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
2600
2683
|
fs.writeFileSync(file, `${before}${before && !before.endsWith("\n") ? "\n" : ""}# Loom's local state (event log, attachments, machine config)\n.loom/\n`);
|
|
2601
|
-
console.log(pc.green("✓ added .loom/ to .gitignore"));
|
|
2684
|
+
console.log(pc.green("✓ added .loom/ to .gitignore") + (process.stdin.isTTY ? "" : pc.dim(" (loom init --no-gitignore to leave it alone)")));
|
|
2602
2685
|
}
|
|
2603
|
-
async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true, fresh = false) {
|
|
2686
|
+
async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true, fresh = false, handoff = false) {
|
|
2604
2687
|
try {
|
|
2605
2688
|
return await client.send(projectId, text, agentId, { fresh });
|
|
2606
2689
|
}
|
|
2607
2690
|
catch (err) {
|
|
2608
2691
|
if (err instanceof DaemonError && err.status === 409 && agentId) {
|
|
2609
2692
|
const holder = String(err.body?.holder ?? "another agent");
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2693
|
+
// no one to ask (a script, --json) and no --handoff: fail loudly, never a quiet exit 0
|
|
2694
|
+
if (!handoff && !interactive)
|
|
2695
|
+
fail(`${agentId} doesn't hold the baton (holder: ${holder}) — add --handoff, or loom handoff ${agentId}`);
|
|
2696
|
+
const yes = handoff || (await confirm(pc.yellow(`⟶ ${holder} holds the baton. Hand off to ${agentId} (interrupts current work)?`)));
|
|
2697
|
+
if (!yes) {
|
|
2698
|
+
process.exitCode = 1;
|
|
2699
|
+
console.error(pc.dim(`not sent — ${holder} keeps the baton (--handoff hands it over without asking)`));
|
|
2614
2700
|
return null;
|
|
2701
|
+
}
|
|
2615
2702
|
await client.handoff(projectId, agentId);
|
|
2616
2703
|
return await client.send(projectId, text, agentId, { fresh });
|
|
2617
2704
|
}
|
|
@@ -2622,10 +2709,11 @@ program
|
|
|
2622
2709
|
.command("send <text...>")
|
|
2623
2710
|
.description("send one message into the shared thread")
|
|
2624
2711
|
.option("-a, --agent <id>", "address a specific agent (may require a handoff)")
|
|
2625
|
-
.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")
|
|
2712
|
+
.option("--wait", "wait for the turn to end; print the reply and cost; exit 0 done, 1 failed, 2 asked a question, 75 interrupted (the daemon restarted), 124 timed out")
|
|
2626
2713
|
.option("--json", "with --wait: print the outcome as one JSON object")
|
|
2627
2714
|
.option("--timeout <seconds>", "with --wait: give up after this long", "1800")
|
|
2628
2715
|
.option("--fresh", "start the agent's session in this chat over — a short question needn't re-read a long one")
|
|
2716
|
+
.option("--handoff", "with --agent: take the baton from whoever holds it, without asking")
|
|
2629
2717
|
.action(async (words, opts) => {
|
|
2630
2718
|
const client = await ensureDaemon();
|
|
2631
2719
|
const project = await currentProject(client);
|
|
@@ -2647,23 +2735,49 @@ program
|
|
|
2647
2735
|
}
|
|
2648
2736
|
// where the log is now, so the wait reads only what this send causes (#254)
|
|
2649
2737
|
const since = opts.wait ? ((await client.events(project.id, undefined, 1)).events.at(-1)?.id ?? 0) : 0;
|
|
2650
|
-
|
|
2738
|
+
// a question needs a person at a terminal — not a script, and never --json output
|
|
2739
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !opts.json;
|
|
2740
|
+
const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent, interactive, Boolean(opts.fresh), Boolean(opts.handoff));
|
|
2651
2741
|
if (!result)
|
|
2652
2742
|
return;
|
|
2653
2743
|
if (!opts.wait)
|
|
2654
2744
|
return void console.log(pc.dim(`→ sent to ${result.agentId} (loom log --follow to watch)`));
|
|
2655
2745
|
const deadline = Date.now() + Math.max(1, Number(opts.timeout) || 1800) * 1000;
|
|
2656
2746
|
let seen = [], outcome = null, cursor = since;
|
|
2747
|
+
// the daemon this turn runs in: if it goes away or comes back as another process, the turn is gone
|
|
2748
|
+
const daemonPid = await client.version().then((v) => v.pid).catch(() => null);
|
|
2749
|
+
let lastPidCheck = Date.now(), interrupted = false;
|
|
2657
2750
|
while (Date.now() < deadline && !outcome) {
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2751
|
+
try {
|
|
2752
|
+
const { events } = await client.events(project.id, cursor, 500);
|
|
2753
|
+
if (events.length) {
|
|
2754
|
+
seen = seen.concat(events);
|
|
2755
|
+
cursor = events.at(-1).id;
|
|
2756
|
+
}
|
|
2757
|
+
outcome = turnOutcome(seen, result.agentId, since);
|
|
2758
|
+
if (!outcome && daemonPid && Date.now() - lastPidCheck > 5000) {
|
|
2759
|
+
lastPidCheck = Date.now();
|
|
2760
|
+
if ((await client.version()).pid !== daemonPid)
|
|
2761
|
+
interrupted = true;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
catch {
|
|
2765
|
+
interrupted = true; // the daemon stopped answering (or answered "event log is closed") mid-turn
|
|
2662
2766
|
}
|
|
2663
|
-
|
|
2767
|
+
if (interrupted)
|
|
2768
|
+
break;
|
|
2664
2769
|
if (!outcome)
|
|
2665
2770
|
await new Promise((r) => setTimeout(r, 400));
|
|
2666
2771
|
}
|
|
2772
|
+
if (interrupted && !outcome) {
|
|
2773
|
+
// EX_TEMPFAIL: not the agent's failure, and worth trying again
|
|
2774
|
+
process.exitCode = 75;
|
|
2775
|
+
const again = `loom send -a ${result.agentId} --wait "continue where you left off"`;
|
|
2776
|
+
if (opts.json)
|
|
2777
|
+
return void console.log(JSON.stringify({ status: "interrupted", agent: result.agentId, reason: "the Loom daemon restarted or stopped during the turn", resume: again }));
|
|
2778
|
+
return void console.error(pc.yellow(`⚠ the Loom daemon restarted or stopped while ${result.agentId} was working — the turn was interrupted. ` +
|
|
2779
|
+
`What it did so far is still in the working tree. To carry on: ${again}`));
|
|
2780
|
+
}
|
|
2667
2781
|
process.exitCode = exitCodeFor(outcome);
|
|
2668
2782
|
if (opts.json)
|
|
2669
2783
|
return void console.log(JSON.stringify(outcome ?? { status: "timeout", agent: result.agentId }));
|