@loompad/cli 0.1.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/ARCHITECTURE.md +211 -0
- package/CHANGELOG.md +470 -0
- package/LICENSE +21 -0
- package/README.md +630 -0
- package/dist/adapters/base.d.ts +71 -0
- package/dist/adapters/base.js +187 -0
- package/dist/adapters/base.js.map +1 -0
- package/dist/adapters/bridges/antigravity.d.ts +53 -0
- package/dist/adapters/bridges/antigravity.js +106 -0
- package/dist/adapters/bridges/antigravity.js.map +1 -0
- package/dist/adapters/bridges/cdp.d.ts +82 -0
- package/dist/adapters/bridges/cdp.js +222 -0
- package/dist/adapters/bridges/cdp.js.map +1 -0
- package/dist/adapters/bridges/gui-chat.d.ts +113 -0
- package/dist/adapters/bridges/gui-chat.js +498 -0
- package/dist/adapters/bridges/gui-chat.js.map +1 -0
- package/dist/adapters/bridges/kiro.d.ts +19 -0
- package/dist/adapters/bridges/kiro.js +22 -0
- package/dist/adapters/bridges/kiro.js.map +1 -0
- package/dist/adapters/bridges/profiles.d.ts +82 -0
- package/dist/adapters/bridges/profiles.js +125 -0
- package/dist/adapters/bridges/profiles.js.map +1 -0
- package/dist/adapters/claude-code.d.ts +26 -0
- package/dist/adapters/claude-code.js +209 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/codex.d.ts +51 -0
- package/dist/adapters/codex.js +291 -0
- package/dist/adapters/codex.js.map +1 -0
- package/dist/adapters/echo.d.ts +20 -0
- package/dist/adapters/echo.js +79 -0
- package/dist/adapters/echo.js.map +1 -0
- package/dist/adapters/grok.d.ts +61 -0
- package/dist/adapters/grok.js +217 -0
- package/dist/adapters/grok.js.map +1 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +33 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/opencode.d.ts +64 -0
- package/dist/adapters/opencode.js +426 -0
- package/dist/adapters/opencode.js.map +1 -0
- package/dist/cli/common.d.ts +11 -0
- package/dist/cli/common.js +40 -0
- package/dist/cli/common.js.map +1 -0
- package/dist/cli/doctor.d.ts +12 -0
- package/dist/cli/doctor.js +181 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +641 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/tui-model.d.ts +73 -0
- package/dist/cli/tui-model.js +312 -0
- package/dist/cli/tui-model.js.map +1 -0
- package/dist/cli/tui.d.ts +15 -0
- package/dist/cli/tui.js +451 -0
- package/dist/cli/tui.js.map +1 -0
- package/dist/cli/ui.d.ts +10 -0
- package/dist/cli/ui.js +135 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/core/ades.d.ts +79 -0
- package/dist/core/ades.js +130 -0
- package/dist/core/ades.js.map +1 -0
- package/dist/core/baton.d.ts +37 -0
- package/dist/core/baton.js +92 -0
- package/dist/core/baton.js.map +1 -0
- package/dist/core/brain-extract.d.ts +111 -0
- package/dist/core/brain-extract.js +248 -0
- package/dist/core/brain-extract.js.map +1 -0
- package/dist/core/brain-index.d.ts +128 -0
- package/dist/core/brain-index.js +338 -0
- package/dist/core/brain-index.js.map +1 -0
- package/dist/core/brain.d.ts +214 -0
- package/dist/core/brain.js +425 -0
- package/dist/core/brain.js.map +1 -0
- package/dist/core/claude-cli.d.ts +12 -0
- package/dist/core/claude-cli.js +47 -0
- package/dist/core/claude-cli.js.map +1 -0
- package/dist/core/distill.d.ts +19 -0
- package/dist/core/distill.js +88 -0
- package/dist/core/distill.js.map +1 -0
- package/dist/core/eventlog.d.ts +43 -0
- package/dist/core/eventlog.js +211 -0
- package/dist/core/eventlog.js.map +1 -0
- package/dist/core/git.d.ts +203 -0
- package/dist/core/git.js +460 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/logbook.d.ts +59 -0
- package/dist/core/logbook.js +118 -0
- package/dist/core/logbook.js.map +1 -0
- package/dist/core/memory.d.ts +34 -0
- package/dist/core/memory.js +124 -0
- package/dist/core/memory.js.map +1 -0
- package/dist/core/notify.d.ts +11 -0
- package/dist/core/notify.js +30 -0
- package/dist/core/notify.js.map +1 -0
- package/dist/core/projection.d.ts +20 -0
- package/dist/core/projection.js +96 -0
- package/dist/core/projection.js.map +1 -0
- package/dist/core/registry.d.ts +76 -0
- package/dist/core/registry.js +147 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/router.d.ts +42 -0
- package/dist/core/router.js +99 -0
- package/dist/core/router.js.map +1 -0
- package/dist/core/routes.d.ts +93 -0
- package/dist/core/routes.js +399 -0
- package/dist/core/routes.js.map +1 -0
- package/dist/core/search.d.ts +63 -0
- package/dist/core/search.js +0 -0
- package/dist/core/search.js.map +1 -0
- package/dist/core/setup.d.ts +70 -0
- package/dist/core/setup.js +250 -0
- package/dist/core/setup.js.map +1 -0
- package/dist/core/suggestions.d.ts +15 -0
- package/dist/core/suggestions.js +38 -0
- package/dist/core/suggestions.js.map +1 -0
- package/dist/core/worktree.d.ts +32 -0
- package/dist/core/worktree.js +83 -0
- package/dist/core/worktree.js.map +1 -0
- package/dist/daemon/app-page.d.ts +32 -0
- package/dist/daemon/app-page.js +6363 -0
- package/dist/daemon/app-page.js.map +1 -0
- package/dist/daemon/auth.d.ts +51 -0
- package/dist/daemon/auth.js +135 -0
- package/dist/daemon/auth.js.map +1 -0
- package/dist/daemon/board.d.ts +117 -0
- package/dist/daemon/board.js +202 -0
- package/dist/daemon/board.js.map +1 -0
- package/dist/daemon/brand-icons.d.ts +29 -0
- package/dist/daemon/brand-icons.js +44 -0
- package/dist/daemon/brand-icons.js.map +1 -0
- package/dist/daemon/client.d.ts +127 -0
- package/dist/daemon/client.js +237 -0
- package/dist/daemon/client.js.map +1 -0
- package/dist/daemon/geist-font.d.ts +10 -0
- package/dist/daemon/geist-font.js +783 -0
- package/dist/daemon/geist-font.js.map +1 -0
- package/dist/daemon/guards.d.ts +18 -0
- package/dist/daemon/guards.js +40 -0
- package/dist/daemon/guards.js.map +1 -0
- package/dist/daemon/linear.d.ts +59 -0
- package/dist/daemon/linear.js +117 -0
- package/dist/daemon/linear.js.map +1 -0
- package/dist/daemon/push.d.ts +22 -0
- package/dist/daemon/push.js +58 -0
- package/dist/daemon/push.js.map +1 -0
- package/dist/daemon/runtime.d.ts +299 -0
- package/dist/daemon/runtime.js +1016 -0
- package/dist/daemon/runtime.js.map +1 -0
- package/dist/daemon/server.d.ts +125 -0
- package/dist/daemon/server.js +1728 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/tasks.d.ts +153 -0
- package/dist/daemon/tasks.js +264 -0
- package/dist/daemon/tasks.js.map +1 -0
- package/dist/daemon/terminals.d.ts +82 -0
- package/dist/daemon/terminals.js +372 -0
- package/dist/daemon/terminals.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/sdk.d.ts +17 -0
- package/dist/sdk.js +17 -0
- package/dist/sdk.js.map +1 -0
- package/dist/types.d.ts +236 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/docs/SETUP.md +265 -0
- package/docs/adapters.md +132 -0
- package/docs/design-system.md +245 -0
- package/docs/img/board-linear.png +0 -0
- package/docs/img/board-projects.png +0 -0
- package/docs/img/brain.png +0 -0
- package/docs/img/settings.png +0 -0
- package/docs/img/workspace.png +0 -0
- package/docs/integration-notes.md +76 -0
- package/docs/proposals/brain.md +262 -0
- package/package.json +84 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* loom — one CLI for all your coding agents.
|
|
4
|
+
*
|
|
5
|
+
* Weaves Claude Code, OpenCode (and bridges like Antigravity) into a single
|
|
6
|
+
* shared thread per project: one conversation, one baton, shared memory.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import readline from "node:readline";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { Command } from "commander";
|
|
14
|
+
import pc from "picocolors";
|
|
15
|
+
import qrcode from "qrcode-terminal";
|
|
16
|
+
import { DaemonClient, DaemonError, daemonRunning, ensureDaemon, stopDaemon, } from "../daemon/client.js";
|
|
17
|
+
import { installCrashGuards } from "../daemon/guards.js";
|
|
18
|
+
import { LoomDaemon, DEFAULT_PORT } from "../daemon/server.js";
|
|
19
|
+
import { ensureLoomHome } from "../core/registry.js";
|
|
20
|
+
import { NoProjectError, currentProjectDir, resolveCurrentProject } from "./common.js";
|
|
21
|
+
import { fmtUsd, formatAgentRow, formatEvent, formatProjectRow } from "./ui.js";
|
|
22
|
+
const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
/**
|
|
24
|
+
* Where a background daemon's output goes: ~/.loom/daemon.log, appended.
|
|
25
|
+
*
|
|
26
|
+
* Rolled to .log.1 once it passes 5MB — one generation, because this is a
|
|
27
|
+
* breadcrumb trail for "why did it die", not an archive. Without this the
|
|
28
|
+
* daemon ran with stdio:"ignore" and a crash left nothing at all to read.
|
|
29
|
+
*/
|
|
30
|
+
function openDaemonLog() {
|
|
31
|
+
const home = ensureLoomHome();
|
|
32
|
+
const file = path.join(home, "daemon.log");
|
|
33
|
+
try {
|
|
34
|
+
if (fs.statSync(file).size > LOG_MAX_BYTES)
|
|
35
|
+
fs.renameSync(file, file + ".1");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* no log yet, or it vanished — either way, open a fresh one below */
|
|
39
|
+
}
|
|
40
|
+
return fs.openSync(file, "a");
|
|
41
|
+
}
|
|
42
|
+
const program = new Command();
|
|
43
|
+
program
|
|
44
|
+
.name("loom")
|
|
45
|
+
.description("one CLI for all your coding agents — shared thread, shared memory, one baton")
|
|
46
|
+
.version("0.1.0");
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Helpers
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
function fail(message) {
|
|
51
|
+
console.error(pc.red(`✗ ${message}`));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
async function currentProject(client) {
|
|
55
|
+
try {
|
|
56
|
+
return await resolveCurrentProject(client);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err instanceof NoProjectError)
|
|
60
|
+
fail(err.message);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function confirm(question) {
|
|
65
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
rl.question(`${question} ${pc.dim("[y/N]")} `, (answer) => {
|
|
68
|
+
rl.close();
|
|
69
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function printEvent(e) {
|
|
74
|
+
const line = formatEvent(e);
|
|
75
|
+
if (line)
|
|
76
|
+
console.log(line);
|
|
77
|
+
}
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// tui — the default face of loom (bare `loom` lands here)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
program
|
|
82
|
+
.command("tui", { isDefault: true })
|
|
83
|
+
.description("full-screen TUI: one thread, tab shifts agents (default command)")
|
|
84
|
+
.action(async () => {
|
|
85
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
86
|
+
fail("the loom TUI needs a TTY — use `loom chat`, `loom send`, or `loom log` here");
|
|
87
|
+
}
|
|
88
|
+
const { runTui } = await import("./tui.js");
|
|
89
|
+
await runTui();
|
|
90
|
+
});
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// daemon / up / down / status
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
program
|
|
95
|
+
.command("daemon")
|
|
96
|
+
.description("run the loom daemon in the foreground")
|
|
97
|
+
.option("--port <port>", "port to listen on", String(DEFAULT_PORT))
|
|
98
|
+
.option("--host <host>", "host to bind", "127.0.0.1")
|
|
99
|
+
.option("--tailnet", "bind to this machine's Tailscale IP (phone access)", false)
|
|
100
|
+
.action(async (opts) => {
|
|
101
|
+
const port = Number(opts.port);
|
|
102
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
103
|
+
console.error(pc.red(`✗ invalid --port "${opts.port}"`));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const daemon = new LoomDaemon({ host: opts.host, port });
|
|
107
|
+
let bound;
|
|
108
|
+
try {
|
|
109
|
+
bound = await daemon.listen({ tailnet: opts.tailnet });
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const code = err.code;
|
|
113
|
+
if (code === "EADDRINUSE") {
|
|
114
|
+
console.error(pc.red(`✗ port ${port} is already in use`) +
|
|
115
|
+
pc.dim(` — a daemon may already be running. Try \`loom up\`, or \`loom daemon --port <other>\`.`));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.error(pc.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
119
|
+
}
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
console.log(pc.green(`loom daemon listening on http://${bound.host}:${bound.port}`));
|
|
123
|
+
if (opts.tailnet) {
|
|
124
|
+
console.log(pc.dim("bound to the tailnet — pair your phone with `loom pair`"));
|
|
125
|
+
}
|
|
126
|
+
const shutdown = () => {
|
|
127
|
+
void daemon.close().then(() => process.exit(0));
|
|
128
|
+
};
|
|
129
|
+
process.on("SIGINT", shutdown);
|
|
130
|
+
process.on("SIGTERM", shutdown);
|
|
131
|
+
// Survive the faults that would otherwise take every project down with
|
|
132
|
+
// them; they land in ~/.loom/daemon.log. See daemon/guards.ts.
|
|
133
|
+
installCrashGuards();
|
|
134
|
+
});
|
|
135
|
+
program
|
|
136
|
+
.command("up")
|
|
137
|
+
.description("start the loom daemon in the background")
|
|
138
|
+
.option("--tailnet", "bind to this machine's Tailscale IP", false)
|
|
139
|
+
.option("--restart", "restart even if a daemon is already running", false)
|
|
140
|
+
.action(async (opts) => {
|
|
141
|
+
const running = await daemonRunning();
|
|
142
|
+
if (running) {
|
|
143
|
+
const { BUILD_REV } = await import("../daemon/server.js");
|
|
144
|
+
const health = (await fetch(`http://${running.host}:${running.port}/api/health`)
|
|
145
|
+
.then((r) => r.json())
|
|
146
|
+
.catch(() => ({})));
|
|
147
|
+
const stale = health.rev !== BUILD_REV;
|
|
148
|
+
if (!opts.restart && !stale) {
|
|
149
|
+
console.log(pc.dim("daemon already running (loom up --restart to bounce it)"));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
console.log(pc.dim(stale ? "daemon is running an older build — restarting" : "restarting daemon"));
|
|
153
|
+
await stopDaemon();
|
|
154
|
+
const gone = Date.now() + 6000;
|
|
155
|
+
while (Date.now() < gone && (await daemonRunning())) {
|
|
156
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const self = fileURLToPath(import.meta.url);
|
|
160
|
+
const args = [self, "daemon", ...(opts.tailnet ? ["--tailnet"] : [])];
|
|
161
|
+
// Keep the daemon's output. It used to be stdio:"ignore", which meant a
|
|
162
|
+
// background daemon threw away every byte it ever wrote — a crash left you
|
|
163
|
+
// with nothing to read. Appended, and rolled once it gets big, so it can't
|
|
164
|
+
// quietly eat the disk either.
|
|
165
|
+
const log = openDaemonLog();
|
|
166
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", log, log] });
|
|
167
|
+
child.unref();
|
|
168
|
+
const deadline = Date.now() + 15_000;
|
|
169
|
+
while (Date.now() < deadline) {
|
|
170
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
171
|
+
const cfg = await daemonRunning();
|
|
172
|
+
if (cfg) {
|
|
173
|
+
console.log(pc.green(`✓ daemon up on http://${cfg.host}:${cfg.port}`));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
fail("daemon did not become healthy — try `loom daemon` in the foreground");
|
|
178
|
+
});
|
|
179
|
+
program
|
|
180
|
+
.command("down")
|
|
181
|
+
.description("stop the background daemon")
|
|
182
|
+
.action(async () => {
|
|
183
|
+
if (await stopDaemon())
|
|
184
|
+
console.log(pc.green("✓ daemon stopped"));
|
|
185
|
+
else
|
|
186
|
+
console.log(pc.dim("no running daemon found"));
|
|
187
|
+
});
|
|
188
|
+
program
|
|
189
|
+
.command("status")
|
|
190
|
+
.description("daemon health and project board")
|
|
191
|
+
.action(async () => {
|
|
192
|
+
const cfg = await daemonRunning();
|
|
193
|
+
if (!cfg) {
|
|
194
|
+
console.log(pc.dim("daemon: not running (loom up)"));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
console.log(pc.green(`daemon: http://${cfg.host}:${cfg.port}`));
|
|
198
|
+
const client = new DaemonClient(cfg);
|
|
199
|
+
const { projects } = await client.listProjects();
|
|
200
|
+
if (!projects.length)
|
|
201
|
+
console.log(pc.dim("no projects yet — loom init"));
|
|
202
|
+
for (const p of projects)
|
|
203
|
+
console.log(formatProjectRow(p));
|
|
204
|
+
});
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// init / projects / agents
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
program
|
|
209
|
+
.command("init")
|
|
210
|
+
.description("make the current directory a Loom project")
|
|
211
|
+
.option("--name <name>", "project name (default: directory name)")
|
|
212
|
+
.action(async (opts) => {
|
|
213
|
+
const client = await ensureDaemon();
|
|
214
|
+
const dir = process.cwd();
|
|
215
|
+
const res = await client.addProject(dir, opts.name);
|
|
216
|
+
const { project } = await client.project(res.project.id);
|
|
217
|
+
console.log(pc.green(`✓ project "${project.name}" (${project.id})`));
|
|
218
|
+
for (const a of project.agents)
|
|
219
|
+
console.log(formatAgentRow(a));
|
|
220
|
+
console.log(pc.dim("\nedit .loom/config.json to add/remove agents or change roles"));
|
|
221
|
+
console.log(pc.dim("then: loom chat"));
|
|
222
|
+
});
|
|
223
|
+
program
|
|
224
|
+
.command("projects")
|
|
225
|
+
.description("board of all projects")
|
|
226
|
+
.action(async () => {
|
|
227
|
+
const client = await ensureDaemon();
|
|
228
|
+
const { projects } = await client.listProjects();
|
|
229
|
+
if (!projects.length)
|
|
230
|
+
console.log(pc.dim("no projects yet — loom init"));
|
|
231
|
+
for (const p of projects)
|
|
232
|
+
console.log(formatProjectRow(p));
|
|
233
|
+
});
|
|
234
|
+
program
|
|
235
|
+
.command("agents")
|
|
236
|
+
.description("agents in the current project")
|
|
237
|
+
.action(async () => {
|
|
238
|
+
const client = await ensureDaemon();
|
|
239
|
+
const project = await currentProject(client);
|
|
240
|
+
for (const a of project.agents)
|
|
241
|
+
console.log(formatAgentRow(a));
|
|
242
|
+
});
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// send / chat / handoff / interrupt / decision / log
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
async function sendWithHandoffConfirm(client, projectId, text, agentId, interactive = true) {
|
|
247
|
+
try {
|
|
248
|
+
return await client.send(projectId, text, agentId);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
if (err instanceof DaemonError && err.status === 409 && agentId) {
|
|
252
|
+
const holder = String(err.body?.holder ?? "another agent");
|
|
253
|
+
if (!interactive)
|
|
254
|
+
fail(`${agentId} doesn't hold the baton (holder: ${holder}) — loom handoff ${agentId}`);
|
|
255
|
+
const yes = await confirm(pc.yellow(`⟶ ${holder} holds the baton. Hand off to ${agentId} (interrupts current work)?`));
|
|
256
|
+
if (!yes)
|
|
257
|
+
return null;
|
|
258
|
+
await client.handoff(projectId, agentId);
|
|
259
|
+
return await client.send(projectId, text, agentId);
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
program
|
|
265
|
+
.command("send <text...>")
|
|
266
|
+
.description("send one message into the shared thread")
|
|
267
|
+
.option("-a, --agent <id>", "address a specific agent (may require a handoff)")
|
|
268
|
+
.action(async (words, opts) => {
|
|
269
|
+
const client = await ensureDaemon();
|
|
270
|
+
const project = await currentProject(client);
|
|
271
|
+
const result = await sendWithHandoffConfirm(client, project.id, words.join(" "), opts.agent);
|
|
272
|
+
if (result)
|
|
273
|
+
console.log(pc.dim(`→ sent to ${result.agentId} (loom log --follow to watch)`));
|
|
274
|
+
});
|
|
275
|
+
program
|
|
276
|
+
.command("chat")
|
|
277
|
+
.description("interactive shared thread (all agents, one conversation)")
|
|
278
|
+
.action(async () => {
|
|
279
|
+
const client = await ensureDaemon();
|
|
280
|
+
const project = await currentProject(client);
|
|
281
|
+
console.log(pc.bold(`\n${project.name}`) + pc.dim(` — shared thread. /help for commands.`));
|
|
282
|
+
for (const a of project.agents)
|
|
283
|
+
console.log(formatAgentRow(a));
|
|
284
|
+
console.log();
|
|
285
|
+
// Replay a little recent history, then go live.
|
|
286
|
+
const { events } = await client.events(project.id, undefined, 15);
|
|
287
|
+
for (const e of events)
|
|
288
|
+
printEvent(e);
|
|
289
|
+
const rl = readline.createInterface({
|
|
290
|
+
input: process.stdin,
|
|
291
|
+
output: process.stdout,
|
|
292
|
+
prompt: pc.bold("you> "),
|
|
293
|
+
});
|
|
294
|
+
const lastSeen = { id: events[events.length - 1]?.id ?? 0 };
|
|
295
|
+
const unsubscribe = client.subscribe((pid, e) => {
|
|
296
|
+
if (pid !== project.id || e.id <= lastSeen.id)
|
|
297
|
+
return;
|
|
298
|
+
lastSeen.id = e.id;
|
|
299
|
+
// Don't re-echo what the user just typed.
|
|
300
|
+
if (e.kind === "message" && !e.agentId)
|
|
301
|
+
return;
|
|
302
|
+
readline.clearLine(process.stdout, 0);
|
|
303
|
+
readline.cursorTo(process.stdout, 0);
|
|
304
|
+
printEvent(e);
|
|
305
|
+
rl.prompt(true);
|
|
306
|
+
}, project.id);
|
|
307
|
+
const close = () => {
|
|
308
|
+
unsubscribe();
|
|
309
|
+
rl.close();
|
|
310
|
+
process.exit(0);
|
|
311
|
+
};
|
|
312
|
+
rl.on("line", (line) => {
|
|
313
|
+
void (async () => {
|
|
314
|
+
const text = line.trim();
|
|
315
|
+
if (!text)
|
|
316
|
+
return rl.prompt();
|
|
317
|
+
try {
|
|
318
|
+
if (text === "/quit" || text === "/exit")
|
|
319
|
+
return close();
|
|
320
|
+
if (text === "/help") {
|
|
321
|
+
console.log(pc.dim("/agents — list agents · /handoff <id> — pass the baton · /interrupt — stop current turn\n" +
|
|
322
|
+
"/decision <text> — record a shared decision · @<agent> <msg> — address an agent · /quit"));
|
|
323
|
+
}
|
|
324
|
+
else if (text === "/agents") {
|
|
325
|
+
const { project: fresh } = await client.project(project.id);
|
|
326
|
+
for (const a of fresh.agents)
|
|
327
|
+
console.log(formatAgentRow(a));
|
|
328
|
+
}
|
|
329
|
+
else if (text.startsWith("/handoff ")) {
|
|
330
|
+
const to = text.slice(9).trim();
|
|
331
|
+
const yes = await confirm(pc.yellow(`Hand the baton to ${to}?`));
|
|
332
|
+
if (yes)
|
|
333
|
+
await client.handoff(project.id, to);
|
|
334
|
+
}
|
|
335
|
+
else if (text === "/interrupt") {
|
|
336
|
+
const { interrupted } = await client.interrupt(project.id);
|
|
337
|
+
console.log(pc.dim(interrupted ? `interrupted ${interrupted}` : "nothing running"));
|
|
338
|
+
}
|
|
339
|
+
else if (text.startsWith("/decision ")) {
|
|
340
|
+
await client.decision(project.id, text.slice(10).trim());
|
|
341
|
+
}
|
|
342
|
+
else if (text.startsWith("@")) {
|
|
343
|
+
const m = text.match(/^@(\S+)\s+([\s\S]+)$/);
|
|
344
|
+
if (!m)
|
|
345
|
+
console.log(pc.dim("usage: @<agent> <message>"));
|
|
346
|
+
else
|
|
347
|
+
await sendWithHandoffConfirm(client, project.id, m[2], m[1]);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
await sendWithHandoffConfirm(client, project.id, text);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
console.error(pc.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
355
|
+
}
|
|
356
|
+
rl.prompt();
|
|
357
|
+
})();
|
|
358
|
+
});
|
|
359
|
+
rl.on("close", close);
|
|
360
|
+
rl.prompt();
|
|
361
|
+
});
|
|
362
|
+
program
|
|
363
|
+
.command("handoff <agent>")
|
|
364
|
+
.description("pass the baton (write lock) to another agent")
|
|
365
|
+
.option("-y, --yes", "skip confirmation", false)
|
|
366
|
+
.action(async (agent, opts) => {
|
|
367
|
+
const client = await ensureDaemon();
|
|
368
|
+
const project = await currentProject(client);
|
|
369
|
+
if (!opts.yes) {
|
|
370
|
+
const holder = project.holder ?? "nobody";
|
|
371
|
+
const ok = await confirm(pc.yellow(`Baton: ${holder} → ${agent}. Interrupts in-flight work and projects shared memory. Continue?`));
|
|
372
|
+
if (!ok)
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const { from } = await client.handoff(project.id, agent);
|
|
376
|
+
console.log(pc.magenta(`⟶ baton: ${from ?? "—"} → ${agent}`));
|
|
377
|
+
console.log(pc.dim(`shared context written to .loom/memory/${agent}.md`));
|
|
378
|
+
});
|
|
379
|
+
program
|
|
380
|
+
.command("interrupt")
|
|
381
|
+
.description("interrupt the agent holding the baton")
|
|
382
|
+
.action(async () => {
|
|
383
|
+
const client = await ensureDaemon();
|
|
384
|
+
const project = await currentProject(client);
|
|
385
|
+
const { interrupted } = await client.interrupt(project.id);
|
|
386
|
+
console.log(pc.dim(interrupted ? `interrupted ${interrupted}` : "nothing running"));
|
|
387
|
+
});
|
|
388
|
+
const memoryCmd = program
|
|
389
|
+
.command("memory")
|
|
390
|
+
.description("the unified brain — one memory across every connected ADE")
|
|
391
|
+
.action(async () => {
|
|
392
|
+
const client = await ensureDaemon();
|
|
393
|
+
const project = await currentProject(client);
|
|
394
|
+
const { memory } = await client.memory(project.id);
|
|
395
|
+
console.log(memory.document);
|
|
396
|
+
console.log(pc.dim(`\n${memory.sources.length} ADE memory source(s), ${memory.decisions.length} decision(s)` +
|
|
397
|
+
(memory.sources.length ? " · " + memory.sources.map((s) => s.file).join(", ") : "")));
|
|
398
|
+
});
|
|
399
|
+
memoryCmd
|
|
400
|
+
.command("import")
|
|
401
|
+
.description("pull each connected ADE's native memory into the shared brain")
|
|
402
|
+
.action(async () => {
|
|
403
|
+
const client = await ensureDaemon();
|
|
404
|
+
const project = await currentProject(client);
|
|
405
|
+
const { imported, sources } = await client.importMemory(project.id);
|
|
406
|
+
console.log(imported
|
|
407
|
+
? pc.green(`✓ imported ${imported} memory source(s): ${sources.join(", ")}`)
|
|
408
|
+
: pc.dim("shared brain already current — nothing new to import"));
|
|
409
|
+
});
|
|
410
|
+
program
|
|
411
|
+
.command("costs")
|
|
412
|
+
.description("what this project has spent, per agent")
|
|
413
|
+
.action(async () => {
|
|
414
|
+
const client = await ensureDaemon();
|
|
415
|
+
const project = await currentProject(client);
|
|
416
|
+
const { costs } = await client.costs(project.id);
|
|
417
|
+
console.log(pc.bold(`${project.name}`) +
|
|
418
|
+
pc.dim(` total ${fmtUsd(costs.totalUsd)} · ${costs.turns} turns · ${(costs.totalMs / 1000).toFixed(0)}s agent time`));
|
|
419
|
+
for (const a of costs.byAgent) {
|
|
420
|
+
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`);
|
|
421
|
+
}
|
|
422
|
+
if (!costs.byAgent.length)
|
|
423
|
+
console.log(pc.dim(" no turns recorded yet"));
|
|
424
|
+
console.log(pc.dim("\n(costs come from agents that report them — claude-code and opencode do)"));
|
|
425
|
+
});
|
|
426
|
+
program
|
|
427
|
+
.command("decision <text...>")
|
|
428
|
+
.description("record a decision into shared memory (projected on every handoff)")
|
|
429
|
+
.action(async (words) => {
|
|
430
|
+
const client = await ensureDaemon();
|
|
431
|
+
const project = await currentProject(client);
|
|
432
|
+
await client.decision(project.id, words.join(" "));
|
|
433
|
+
console.log(pc.blue("★ recorded"));
|
|
434
|
+
});
|
|
435
|
+
program
|
|
436
|
+
.command("log")
|
|
437
|
+
.description("show the project event log")
|
|
438
|
+
.option("-f, --follow", "stream live events", false)
|
|
439
|
+
.option("-n, --limit <n>", "how many recent events", "40")
|
|
440
|
+
.action(async (opts) => {
|
|
441
|
+
const client = await ensureDaemon();
|
|
442
|
+
const project = await currentProject(client);
|
|
443
|
+
const { events } = await client.events(project.id, undefined, Number(opts.limit));
|
|
444
|
+
for (const e of events)
|
|
445
|
+
printEvent(e);
|
|
446
|
+
if (opts.follow) {
|
|
447
|
+
const lastSeen = { id: events[events.length - 1]?.id ?? 0 };
|
|
448
|
+
client.subscribe((pid, e) => {
|
|
449
|
+
if (pid !== project.id || e.id <= lastSeen.id)
|
|
450
|
+
return;
|
|
451
|
+
lastSeen.id = e.id;
|
|
452
|
+
printEvent(e);
|
|
453
|
+
}, project.id);
|
|
454
|
+
await new Promise(() => { }); // stream until Ctrl-C
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// route — automated multi-hop pipelines
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
program
|
|
461
|
+
.command("route [spec] [task...]")
|
|
462
|
+
.description("run a task through agents — spec: \"auto\" (LLM picks each hop), a route name, or ids/roles like planner,executor")
|
|
463
|
+
.option("--status", "show the current/last route", false)
|
|
464
|
+
.option("--abort", "abort the active route", false)
|
|
465
|
+
.option("-d, --detach", "don't follow — notifications will tell you when it's done", false)
|
|
466
|
+
.option("--router <kind>", "for auto: llm (default) or rules")
|
|
467
|
+
.option("--max-hops <n>", "for auto: hop budget (default 8)")
|
|
468
|
+
.action(async (spec, words, opts) => {
|
|
469
|
+
const client = await ensureDaemon();
|
|
470
|
+
const project = await currentProject(client);
|
|
471
|
+
if (opts.status) {
|
|
472
|
+
const { route } = await client.routeState(project.id);
|
|
473
|
+
if (!route)
|
|
474
|
+
return void console.log(pc.dim("no route has run in this project"));
|
|
475
|
+
const flow = route.steps
|
|
476
|
+
.map((s, i) => (i === route.current ? pc.bold(s) : s))
|
|
477
|
+
.join(" → ");
|
|
478
|
+
console.log(`${pc.cyan(route.name ?? "route")} [${route.status}] ${flow}` +
|
|
479
|
+
pc.dim(` task: ${route.task.slice(0, 80)}`));
|
|
480
|
+
if (route.pendingQuestion)
|
|
481
|
+
console.log(pc.yellow(` ⏸ asks: ${route.pendingQuestion}`));
|
|
482
|
+
if (route.reason)
|
|
483
|
+
console.log(pc.dim(` reason: ${route.reason}`));
|
|
484
|
+
if (route.costUsd && route.costUsd > 0)
|
|
485
|
+
console.log(pc.dim(` cost: ${fmtUsd(route.costUsd)}`));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (opts.abort) {
|
|
489
|
+
const { route } = await client.abortRoute(project.id);
|
|
490
|
+
console.log(pc.yellow(`⊘ route stopped: ${route.reason ?? "aborted"}`));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (!spec || !words.length) {
|
|
494
|
+
fail('usage: loom route <name|steps> "<task>" e.g. loom route planner,executor "add dark mode"\n (or: loom route --status / --abort)');
|
|
495
|
+
}
|
|
496
|
+
const { route } = await client.startRoute(project.id, words.join(" "), spec, {
|
|
497
|
+
...(opts.router ? { router: opts.router } : {}),
|
|
498
|
+
...(opts.maxHops ? { maxHops: Number(opts.maxHops) } : {}),
|
|
499
|
+
});
|
|
500
|
+
console.log(pc.cyan(route.mode === "dynamic"
|
|
501
|
+
? `➤ route "auto" (${route.router} picks each hop, budget ${route.maxHops}): started with ${route.steps.join(" → ")}`
|
|
502
|
+
: `➤ route${route.name ? ` "${route.name}"` : ""}: ${route.steps.join(" → ")}`));
|
|
503
|
+
if (opts.detach) {
|
|
504
|
+
console.log(pc.dim("running in the background — you'll be notified at each pause/finish"));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
console.log(pc.dim("following — Ctrl-C detaches, the route keeps running\n"));
|
|
508
|
+
const lastSeen = { id: 0 };
|
|
509
|
+
await new Promise((resolve) => {
|
|
510
|
+
client.subscribe((pid, e) => {
|
|
511
|
+
if (pid !== project.id || e.id <= lastSeen.id)
|
|
512
|
+
return;
|
|
513
|
+
lastSeen.id = e.id;
|
|
514
|
+
printEvent(e);
|
|
515
|
+
if (e.kind === "route_completed" || e.kind === "route_failed")
|
|
516
|
+
resolve();
|
|
517
|
+
}, project.id);
|
|
518
|
+
});
|
|
519
|
+
const { route: finalState } = await client.routeState(project.id);
|
|
520
|
+
process.exit(finalState?.status === "completed" ? 0 : 1);
|
|
521
|
+
});
|
|
522
|
+
program
|
|
523
|
+
.command("routes")
|
|
524
|
+
.description("named routes defined for the current project")
|
|
525
|
+
.action(async () => {
|
|
526
|
+
const client = await ensureDaemon();
|
|
527
|
+
await currentProject(client); // validates we're in a project
|
|
528
|
+
const dir = currentProjectDir();
|
|
529
|
+
const { readProjectConfig } = await import("../core/registry.js");
|
|
530
|
+
const cfg = readProjectConfig(dir);
|
|
531
|
+
const routes = cfg?.routes ?? {};
|
|
532
|
+
if (!Object.keys(routes).length) {
|
|
533
|
+
console.log(pc.dim('no named routes — add {"routes":{"ship":["planner","executor"]}} to .loom/config.json'));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
for (const [name, steps] of Object.entries(routes)) {
|
|
537
|
+
const rendered = steps
|
|
538
|
+
.map((s) => typeof s === "string" ? s : `${s.step}${s.instruction ? pc.dim(` ("${s.instruction}")`) : ""}`)
|
|
539
|
+
.join(" → ");
|
|
540
|
+
console.log(`${pc.cyan(pc.bold(name))} ${rendered}`);
|
|
541
|
+
}
|
|
542
|
+
console.log(pc.dim('\nrun one: loom route <name> "<task>" · ad-hoc: loom route a,b,c "<task>"'));
|
|
543
|
+
});
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// pair
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
program
|
|
548
|
+
.command("doctor")
|
|
549
|
+
.description("diagnose the environment, daemon, and current project")
|
|
550
|
+
.action(async () => {
|
|
551
|
+
const { envChecks, projectChecks } = await import("./doctor.js");
|
|
552
|
+
const checks = await envChecks();
|
|
553
|
+
const dir = currentProjectDir();
|
|
554
|
+
if (dir)
|
|
555
|
+
checks.push(...projectChecks(dir));
|
|
556
|
+
else
|
|
557
|
+
checks.push({ name: "project", status: "warn", detail: "not inside a Loom project (loom init)" });
|
|
558
|
+
let failures = 0;
|
|
559
|
+
let warnings = 0;
|
|
560
|
+
for (const c of checks) {
|
|
561
|
+
const icon = c.status === "ok" ? pc.green("✓") : c.status === "warn" ? pc.yellow("⚠") : pc.red("✗");
|
|
562
|
+
if (c.status === "fail")
|
|
563
|
+
failures++;
|
|
564
|
+
if (c.status === "warn")
|
|
565
|
+
warnings++;
|
|
566
|
+
console.log(` ${icon} ${pc.bold(c.name.padEnd(11))} ${c.status === "ok" ? pc.dim(c.detail) : c.detail}`);
|
|
567
|
+
}
|
|
568
|
+
if (failures) {
|
|
569
|
+
console.log(pc.red(`\n${failures} problem${failures > 1 ? "s" : ""} found`));
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
// "all clear" over a screen of warnings is a lie, and it's the lie that
|
|
573
|
+
// teaches people to stop reading this command. Nothing is broken; several
|
|
574
|
+
// things aren't set up. Those are different sentences.
|
|
575
|
+
if (warnings) {
|
|
576
|
+
console.log(pc.yellow(`\nnothing broken · ${warnings} thing${warnings > 1 ? "s" : ""} not set up (see above)`));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
console.log(pc.green("\nall clear"));
|
|
580
|
+
});
|
|
581
|
+
program
|
|
582
|
+
.command("clients")
|
|
583
|
+
.description("list paired devices, revoke one, or ping them with a test push")
|
|
584
|
+
.option("--revoke <id>", "revoke a paired device's access")
|
|
585
|
+
.option("--ping", "send a test push notification to all registered devices", false)
|
|
586
|
+
.action(async (opts) => {
|
|
587
|
+
const client = await ensureDaemon();
|
|
588
|
+
if (opts.revoke) {
|
|
589
|
+
await client.revokeClient(opts.revoke);
|
|
590
|
+
console.log(pc.green(`✓ revoked ${opts.revoke}`));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (opts.ping) {
|
|
594
|
+
const { sent } = await client.pushTest();
|
|
595
|
+
console.log(sent
|
|
596
|
+
? pc.green(`✓ test push sent to ${sent} device${sent === 1 ? "" : "s"}`)
|
|
597
|
+
: pc.dim("no devices registered for push — open the Loom app once after pairing"));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const { clients } = await client.pairedClients();
|
|
601
|
+
if (!clients.length)
|
|
602
|
+
return void console.log(pc.dim("no paired devices — loom pair"));
|
|
603
|
+
for (const c of clients) {
|
|
604
|
+
console.log(` ${pc.bold(c.name)} ${pc.dim(`(${c.id})`)} paired ${new Date(c.createdAt).toLocaleString()}` +
|
|
605
|
+
(c.push ? pc.green(" ·push✓") : pc.dim(" ·no push")));
|
|
606
|
+
}
|
|
607
|
+
console.log(pc.dim("\nrevoke: loom clients --revoke <id> · test push: loom clients --ping"));
|
|
608
|
+
});
|
|
609
|
+
program
|
|
610
|
+
.command("pair")
|
|
611
|
+
.description("pair a phone/device: QR with a short-lived, single-use token")
|
|
612
|
+
.option("--allow-local", "mint a localhost QR anyway (same-machine testing)", false)
|
|
613
|
+
.action(async (opts) => {
|
|
614
|
+
const client = await ensureDaemon();
|
|
615
|
+
const cfg = await daemonRunning();
|
|
616
|
+
const loopback = cfg && ["127.0.0.1", "localhost", "::1"].includes(cfg.host);
|
|
617
|
+
if (loopback && !opts.allowLocal) {
|
|
618
|
+
// A localhost QR is unreachable from a phone — the #1 "failed to
|
|
619
|
+
// fetch" cause. Refuse and say exactly what to run instead.
|
|
620
|
+
console.error(pc.red("✗ daemon is bound to localhost — your phone cannot reach 127.0.0.1"));
|
|
621
|
+
console.error(pc.bold("\n fix:"));
|
|
622
|
+
console.error(pc.bold(" loom up --restart --tailnet"));
|
|
623
|
+
console.error(pc.bold(" loom pair"));
|
|
624
|
+
console.error(pc.dim("\n needs Tailscale on this machine (`tailscale up`) and on your phone (same tailnet).\n" +
|
|
625
|
+
" testing on this machine only? loom pair --allow-local"));
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
const { token, expiresAt, url } = await client.newPairingToken();
|
|
629
|
+
// Deep link: scanning with any camera opens the phone app, which claims
|
|
630
|
+
// the (single-use, 10-min) token from the URL fragment and pairs itself.
|
|
631
|
+
const link = `${url}/app#pair=${token}`;
|
|
632
|
+
qrcode.generate(link, { small: true }, (qr) => console.log(qr));
|
|
633
|
+
console.log(pc.bold(` ${link}`));
|
|
634
|
+
console.log(pc.dim(` scan with your phone camera · single use · expires ${new Date(expiresAt).toLocaleTimeString()}`));
|
|
635
|
+
console.log(pc.dim(` (manual claim: POST ${url}/api/pair/claim {"token":"${token.slice(0, 6)}…"})`));
|
|
636
|
+
});
|
|
637
|
+
program.parseAsync().catch((err) => {
|
|
638
|
+
console.error(pc.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
639
|
+
process.exit(1);
|
|
640
|
+
});
|
|
641
|
+
//# sourceMappingURL=index.js.map
|