@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,1728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Loom daemon — one process, many projects, one API for every surface
|
|
3
|
+
* (CLI today, iOS app next). REST for commands, WebSocket for the live
|
|
4
|
+
* event stream.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import express from "express";
|
|
15
|
+
import QRCode from "qrcode";
|
|
16
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
17
|
+
import { NotHolderError } from "../core/baton.js";
|
|
18
|
+
import { retrieve } from "../core/brain-index.js";
|
|
19
|
+
import { RouteActiveError } from "../core/routes.js";
|
|
20
|
+
import { ADES, buildDefaultRoutes, defaultAgentConfigs, detectAdes } from "../core/ades.js";
|
|
21
|
+
import { logbook } from "../core/logbook.js";
|
|
22
|
+
import { searchChats, searchCode } from "../core/search.js";
|
|
23
|
+
import { addWorktree as gitAddWorktree, branches as gitBranches, checkout as gitCheckout, commit as gitCommit, discard as gitDiscard, fileDiff as gitFileDiff, GitError, init as gitInit, listWorktrees as gitListWorktrees, log as gitLog, push as gitPush, removeWorktree as gitRemoveWorktree, stage as gitStage, stagedDiff as gitStagedDiff, status as gitStatus, unstage as gitUnstage, } from "../core/git.js";
|
|
24
|
+
import { claudeText } from "../core/claude-cli.js";
|
|
25
|
+
import { setupReport } from "../core/setup.js";
|
|
26
|
+
import { ensureDaemonConfig, findProject, listProjects, readDaemonConfig, readProjectConfig, registerProject, writeDaemonConfig, writeProjectConfig, } from "../core/registry.js";
|
|
27
|
+
import { APP_HTML, APP_MANIFEST } from "./app-page.js";
|
|
28
|
+
import { GEIST_WOFF2 } from "./geist-font.js";
|
|
29
|
+
import { AuthManager, bearerToken } from "./auth.js";
|
|
30
|
+
import { PUSH_KINDS, pushContent, sendExpoPush } from "./push.js";
|
|
31
|
+
import { ProjectRuntime } from "./runtime.js";
|
|
32
|
+
import { buildBoard } from "./board.js";
|
|
33
|
+
import { ghAuthStatus, ghProjectItems, ghProjects, listTasks, prReview, prView, runGh, } from "./tasks.js";
|
|
34
|
+
import { linearCreateIssue, linearTeams, listLinearIssues } from "./linear.js";
|
|
35
|
+
import { TerminalManager, TooManySessionsError } from "./terminals.js";
|
|
36
|
+
export const DEFAULT_PORT = 7420;
|
|
37
|
+
/**
|
|
38
|
+
* Fingerprint every built file the daemon can load, as one hash.
|
|
39
|
+
*
|
|
40
|
+
* The walk is what makes it honest. This used to hash exactly two files —
|
|
41
|
+
* server.js and app-page.js — which meant a change anywhere else (an adapter,
|
|
42
|
+
* the router, core/registry.ts) left the rev identical. `loom up` said "daemon
|
|
43
|
+
* already running", the shell agreed it was current, and a daemon kept serving
|
|
44
|
+
* the old code from memory. A correct fix looked like it did nothing, which
|
|
45
|
+
* sends you debugging code that is already right.
|
|
46
|
+
*
|
|
47
|
+
* Content-based on purpose: mtimes are unreliable across runtimes on some
|
|
48
|
+
* filesystems (exFAT drives skew them by the local timezone offset). Names are
|
|
49
|
+
* hashed alongside contents so a rename or a deletion moves the rev too.
|
|
50
|
+
*
|
|
51
|
+
* Reading ~39 files (about half a megabyte) costs a couple of milliseconds at
|
|
52
|
+
* import, once. A stale daemon costs an afternoon.
|
|
53
|
+
*
|
|
54
|
+
* The desktop shell has a twin of this in desktop/loom-app.js — it can't import
|
|
55
|
+
* this module without pulling express into Electron's main process. They must
|
|
56
|
+
* agree byte for byte; test/desktop-app.test.ts compares them against the real
|
|
57
|
+
* built output so a drift fails there rather than in the field.
|
|
58
|
+
*/
|
|
59
|
+
export function fingerprintBuild(root) {
|
|
60
|
+
const rels = [];
|
|
61
|
+
const walk = (dir) => {
|
|
62
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
63
|
+
const full = path.join(dir, entry.name);
|
|
64
|
+
if (entry.isDirectory())
|
|
65
|
+
walk(full);
|
|
66
|
+
else if (entry.name.endsWith(".js"))
|
|
67
|
+
rels.push(path.relative(root, full));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(root);
|
|
71
|
+
if (rels.length === 0)
|
|
72
|
+
return null;
|
|
73
|
+
rels.sort(); // readdir order is filesystem-dependent; the hash must not be
|
|
74
|
+
const hash = crypto.createHash("sha256");
|
|
75
|
+
for (const rel of rels) {
|
|
76
|
+
hash.update(rel);
|
|
77
|
+
hash.update(fs.readFileSync(path.join(root, rel)));
|
|
78
|
+
}
|
|
79
|
+
return hash.digest("hex").slice(0, 16);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* This build's rev. "dev" when there's nothing compiled to hash — running from
|
|
83
|
+
* source under tsx, where the tree is .ts and the walk finds no .js at all.
|
|
84
|
+
*/
|
|
85
|
+
export const BUILD_REV = (() => {
|
|
86
|
+
try {
|
|
87
|
+
// dist/daemon/server.js → dist: everything this process can import.
|
|
88
|
+
const me = fileURLToPath(import.meta.url);
|
|
89
|
+
return fingerprintBuild(path.dirname(path.dirname(me))) ?? "dev";
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return "dev";
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
/**
|
|
96
|
+
* Loom's own install root — the directory to ask "am I behind my remote?".
|
|
97
|
+
*
|
|
98
|
+
* Walks up from this module looking for a .git directory (a source checkout or
|
|
99
|
+
* a cloned install). null when Loom was installed some other way (a package),
|
|
100
|
+
* in which case "check for updates" honestly says there's no git tree to check.
|
|
101
|
+
*/
|
|
102
|
+
function loomRoot() {
|
|
103
|
+
try {
|
|
104
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
105
|
+
for (let i = 0; i < 6; i++) {
|
|
106
|
+
if (fs.existsSync(path.join(dir, ".git")))
|
|
107
|
+
return dir;
|
|
108
|
+
const parent = path.dirname(dir);
|
|
109
|
+
if (parent === dir)
|
|
110
|
+
break;
|
|
111
|
+
dir = parent;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* fall through */
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const TERM_MARK = "__LOOM_END__";
|
|
120
|
+
/**
|
|
121
|
+
* Just enough to give a pasted attachment a sensible extension.
|
|
122
|
+
*/
|
|
123
|
+
const MIME_EXT = {
|
|
124
|
+
"image/png": "png",
|
|
125
|
+
"image/jpeg": "jpg",
|
|
126
|
+
"image/gif": "gif",
|
|
127
|
+
"image/webp": "webp",
|
|
128
|
+
"image/svg+xml": "svg",
|
|
129
|
+
"text/markdown": "md",
|
|
130
|
+
"text/plain": "txt",
|
|
131
|
+
"application/pdf": "pdf",
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Paths from an HTTP body, as strings and nothing else.
|
|
135
|
+
*
|
|
136
|
+
* These reach `git checkout --` and `git clean -fd`, which delete things. A
|
|
137
|
+
* body is whatever the caller felt like sending, so anything that isn't a
|
|
138
|
+
* string is dropped here rather than stringified into a path somewhere deeper.
|
|
139
|
+
* This is the shape check; core/git.ts does the safety check, resolving every
|
|
140
|
+
* one of them against the project root.
|
|
141
|
+
*/
|
|
142
|
+
function asPaths(v) {
|
|
143
|
+
if (!Array.isArray(v))
|
|
144
|
+
return [];
|
|
145
|
+
return v.filter((x) => typeof x === "string" && x.length > 0).slice(0, 500);
|
|
146
|
+
}
|
|
147
|
+
export class LoomDaemon {
|
|
148
|
+
app = express();
|
|
149
|
+
server = null;
|
|
150
|
+
wss = null;
|
|
151
|
+
auth;
|
|
152
|
+
runtimes = new Map();
|
|
153
|
+
sockets = new Map();
|
|
154
|
+
/** Terminal shells — a real pty when node-pty loaded, else plain pipes. */
|
|
155
|
+
terminals = new TerminalManager({
|
|
156
|
+
onData: (projectId, term, chunk) => this.broadcastTerm(projectId, { type: "term", term, chunk }),
|
|
157
|
+
onCommandEnd: (projectId, term, exit, cwd) => this.broadcastTerm(projectId, { type: "term", term, exit, cwd }),
|
|
158
|
+
onExit: (projectId, term) => {
|
|
159
|
+
this.terminals.forget(projectId, term);
|
|
160
|
+
this.broadcastTerm(projectId, { type: "term", term, closed: true });
|
|
161
|
+
},
|
|
162
|
+
onTitle: (projectId, term, title) => this.broadcastTerm(projectId, { type: "term", term, title }),
|
|
163
|
+
});
|
|
164
|
+
unstreamLogs = null;
|
|
165
|
+
host;
|
|
166
|
+
port;
|
|
167
|
+
/**
|
|
168
|
+
* Extra listeners, keyed by IP, added when a phone is connected. `host` stays
|
|
169
|
+
* what we advertise and write to the daemon config (so local CLIs keep
|
|
170
|
+
* reaching us over loopback); each entry here is a second socket on a specific
|
|
171
|
+
* LAN or tailnet IP and the same port, so the phone can reach us without the
|
|
172
|
+
* localhost listener ever being disturbed.
|
|
173
|
+
*/
|
|
174
|
+
extra = new Map();
|
|
175
|
+
constructor(opts = {}) {
|
|
176
|
+
this.host = opts.host ?? "127.0.0.1";
|
|
177
|
+
this.port = opts.port ?? DEFAULT_PORT;
|
|
178
|
+
const cfg = ensureDaemonConfig({ host: this.host, port: this.port });
|
|
179
|
+
this.auth = new AuthManager(cfg);
|
|
180
|
+
this.routes();
|
|
181
|
+
}
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
// HTTP routes
|
|
184
|
+
// -------------------------------------------------------------------------
|
|
185
|
+
routes() {
|
|
186
|
+
const app = this.app;
|
|
187
|
+
app.use(express.json({ limit: "2mb" }));
|
|
188
|
+
// CORS for same-machine browser origins only (the Expo web dev server running
|
|
189
|
+
// on another localhost port, etc.). Scoped to loopback so it can't be abused
|
|
190
|
+
// cross-site; the bearer wall below still guards every data route. Preflight
|
|
191
|
+
// is answered here, ahead of auth.
|
|
192
|
+
app.use((req, res, next) => {
|
|
193
|
+
const origin = req.headers.origin;
|
|
194
|
+
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
|
195
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
196
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
197
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
198
|
+
res.setHeader("Vary", "Origin");
|
|
199
|
+
if (req.method === "OPTIONS")
|
|
200
|
+
return void res.sendStatus(204);
|
|
201
|
+
}
|
|
202
|
+
next();
|
|
203
|
+
});
|
|
204
|
+
// Public: the phone app shell (its API calls are bearer-authed),
|
|
205
|
+
// health, and the pairing claim (the pairing token IS the auth).
|
|
206
|
+
app.get("/", (_req, res) => res.redirect("/app"));
|
|
207
|
+
app.get("/app", (_req, res) => {
|
|
208
|
+
// Never cache the shell: a redeployed daemon must serve its own UI.
|
|
209
|
+
res.type("html").setHeader("Cache-Control", "no-store").send(APP_HTML);
|
|
210
|
+
});
|
|
211
|
+
app.get("/app/manifest.webmanifest", (_req, res) => {
|
|
212
|
+
res
|
|
213
|
+
.type("application/manifest+json")
|
|
214
|
+
.setHeader("Cache-Control", "no-store")
|
|
215
|
+
.send(JSON.stringify(APP_MANIFEST));
|
|
216
|
+
});
|
|
217
|
+
// The UI sans (Geist, SIL OFL 1.1) — embedded so the app works offline
|
|
218
|
+
// on the tailnet with no CDN. Immutable: cache hard.
|
|
219
|
+
app.get("/app/fonts/geist.woff2", (_req, res) => {
|
|
220
|
+
res
|
|
221
|
+
.type("font/woff2")
|
|
222
|
+
.setHeader("Cache-Control", "public, max-age=31536000, immutable")
|
|
223
|
+
.send(GEIST_WOFF2);
|
|
224
|
+
});
|
|
225
|
+
// xterm.js and its addons, served straight from node_modules — the app has
|
|
226
|
+
// no build step and must work offline on a tailnet, so no bundler, no CDN.
|
|
227
|
+
// These are plain UMD files the browser loads with <script>.
|
|
228
|
+
const vendor = {
|
|
229
|
+
"xterm.js": ["@xterm/xterm/lib/xterm.js", "application/javascript"],
|
|
230
|
+
"xterm.css": ["@xterm/xterm/css/xterm.css", "text/css"],
|
|
231
|
+
"addon-fit.js": ["@xterm/addon-fit/lib/addon-fit.js", "application/javascript"],
|
|
232
|
+
"addon-web-links.js": [
|
|
233
|
+
"@xterm/addon-web-links/lib/addon-web-links.js",
|
|
234
|
+
"application/javascript",
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
app.get("/app/vendor/:file", (req, res) => {
|
|
238
|
+
const entry = vendor[String(req.params.file)];
|
|
239
|
+
if (!entry)
|
|
240
|
+
return void res.status(404).end();
|
|
241
|
+
try {
|
|
242
|
+
res
|
|
243
|
+
.type(entry[1])
|
|
244
|
+
.setHeader("Cache-Control", "public, max-age=31536000, immutable")
|
|
245
|
+
.send(fs.readFileSync(createRequire(import.meta.url).resolve(entry[0])));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
res.status(404).end();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
app.get("/api/health", (_req, res) => {
|
|
252
|
+
res.json({
|
|
253
|
+
ok: true,
|
|
254
|
+
name: "loom",
|
|
255
|
+
version: "0.1.0",
|
|
256
|
+
rev: BUILD_REV,
|
|
257
|
+
terminal: this.terminals.mode,
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
app.post("/api/pair/claim", (req, res) => {
|
|
261
|
+
const { token, name } = (req.body ?? {});
|
|
262
|
+
if (!token)
|
|
263
|
+
return void res.status(400).json({ error: "missing token" });
|
|
264
|
+
const claimed = this.auth.claim(token, name ?? "device");
|
|
265
|
+
if (!claimed)
|
|
266
|
+
return void res.status(403).json({ error: "invalid or expired pairing token" });
|
|
267
|
+
res.json(claimed);
|
|
268
|
+
});
|
|
269
|
+
/**
|
|
270
|
+
* The local admin console bootstraps here — before the bearer wall, gated by
|
|
271
|
+
* the socket being loopback. A same-machine caller gets the admin token (it
|
|
272
|
+
* lives in a config file they can already read), which is what lets the web
|
|
273
|
+
* app served on localhost mint pairing codes and open phone access. Everyone
|
|
274
|
+
* else — a phone on the tailnet, anything past localhost — is turned away and
|
|
275
|
+
* pairs like any other device. Admin-ness stays a property of the *token*,
|
|
276
|
+
* so a paired client is never an admin no matter where it connects from.
|
|
277
|
+
*/
|
|
278
|
+
app.get("/api/bootstrap", (req, res) => {
|
|
279
|
+
// Both must hold: the TCP peer is loopback (can't be spoofed by a header),
|
|
280
|
+
// AND the Host is a loopback literal (defeats DNS rebinding, where the
|
|
281
|
+
// socket is loopback but the browser sends the attacker's hostname).
|
|
282
|
+
if (!isLoopback(req.socket.remoteAddress) || !isLoopbackHost(req.headers.host)) {
|
|
283
|
+
return void res.status(403).json({ error: "not a local request" });
|
|
284
|
+
}
|
|
285
|
+
res.json({ token: this.auth.adminToken(), admin: true });
|
|
286
|
+
});
|
|
287
|
+
// Everything else requires a bearer token.
|
|
288
|
+
app.use((req, res, next) => {
|
|
289
|
+
const token = bearerToken(req.headers.authorization);
|
|
290
|
+
if (!this.auth.isAuthorized(token)) {
|
|
291
|
+
res.status(401).json({ error: "unauthorized" });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
req.isAdmin = this.auth.isAdmin(token);
|
|
295
|
+
next();
|
|
296
|
+
});
|
|
297
|
+
/**
|
|
298
|
+
* What this machine still needs — the same answer `loom doctor` gives.
|
|
299
|
+
*
|
|
300
|
+
* Behind the auth wall on purpose: it enumerates which agents you have
|
|
301
|
+
* installed and which GUI apps are open, a small inventory of your machine
|
|
302
|
+
* and none of a stranger's business — which matters the moment the daemon
|
|
303
|
+
* binds past localhost (--host, Tailscale).
|
|
304
|
+
*
|
|
305
|
+
* Probing GUI bridges means a couple of HTTP round trips to their debug
|
|
306
|
+
* ports, so this is a request you make when you open Settings, not something
|
|
307
|
+
* the app polls.
|
|
308
|
+
*/
|
|
309
|
+
app.get("/api/setup", (_req, res) => {
|
|
310
|
+
void setupReport()
|
|
311
|
+
.then((report) => res.json(report))
|
|
312
|
+
.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
|
|
313
|
+
});
|
|
314
|
+
/**
|
|
315
|
+
* `loom doctor`, over HTTP — the env checks always, plus one project's
|
|
316
|
+
* checks when a ?project is given. Dynamically imported so doctor.js (which
|
|
317
|
+
* pulls BUILD_REV back out of this file) doesn't create an import cycle at
|
|
318
|
+
* module-init time.
|
|
319
|
+
*/
|
|
320
|
+
app.get("/api/doctor", (req, res) => {
|
|
321
|
+
void (async () => {
|
|
322
|
+
try {
|
|
323
|
+
const { envChecks, projectChecks } = await import("../cli/doctor.js");
|
|
324
|
+
const checks = await envChecks();
|
|
325
|
+
const projId = req.query.project;
|
|
326
|
+
if (projId) {
|
|
327
|
+
const info = findProject(projId);
|
|
328
|
+
if (info)
|
|
329
|
+
checks.push(...projectChecks(info.dir));
|
|
330
|
+
}
|
|
331
|
+
res.json({ checks });
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
335
|
+
}
|
|
336
|
+
})();
|
|
337
|
+
});
|
|
338
|
+
/**
|
|
339
|
+
* Is this Loom current? Version + build rev, and — when Loom itself is a git
|
|
340
|
+
* checkout — how many commits its own tree is behind its remote. Honest about
|
|
341
|
+
* the two different "updates" that matter: a newer daemon build waiting to be
|
|
342
|
+
* restarted (rev), and newer code waiting to be pulled (behind).
|
|
343
|
+
*/
|
|
344
|
+
app.get("/api/updates", (_req, res) => {
|
|
345
|
+
void (async () => {
|
|
346
|
+
const root = loomRoot();
|
|
347
|
+
let git = null;
|
|
348
|
+
if (root) {
|
|
349
|
+
const { remoteBehind } = await import("../core/git.js");
|
|
350
|
+
git = await remoteBehind(root).catch(() => null);
|
|
351
|
+
}
|
|
352
|
+
res.json({ version: "0.1.0", rev: BUILD_REV, root, git });
|
|
353
|
+
})();
|
|
354
|
+
});
|
|
355
|
+
/**
|
|
356
|
+
* Is `gh` logged in, and as whom — machine-wide, so no project needed. The
|
|
357
|
+
* whole GitHub half of Loom (board PRs, Projects, review) rides on this; the
|
|
358
|
+
* status bar shows it and offers Connect when it's false.
|
|
359
|
+
*/
|
|
360
|
+
app.get("/api/github/status", (_req, res) => {
|
|
361
|
+
void ghAuthStatus()
|
|
362
|
+
.then((s) => res.json(s))
|
|
363
|
+
.catch((err) => res.status(500).json({ error: err instanceof Error ? err.message : String(err) }));
|
|
364
|
+
});
|
|
365
|
+
/**
|
|
366
|
+
* LoomPad connectivity — proxies the voice backend's /health so the web app
|
|
367
|
+
* can show a live "LoomPad connected" pill without a cross-origin fetch. The
|
|
368
|
+
* backend (orchestrator-pad) does STT -> agent -> TTS for the physical pad;
|
|
369
|
+
* when it's up, the pad gets its spoken replies. Best-effort: an unreachable
|
|
370
|
+
* backend just returns { up:false } (the pill goes grey), never an error.
|
|
371
|
+
*/
|
|
372
|
+
app.get("/api/loompad/health", (_req, res) => {
|
|
373
|
+
const base = (process.env.LOOMPAD_BACKEND_URL || "http://127.0.0.1:8080").replace(/\/+$/, "");
|
|
374
|
+
const ctl = new AbortController();
|
|
375
|
+
const timer = setTimeout(() => ctl.abort(), 2500);
|
|
376
|
+
void fetch(base + "/health", { signal: ctl.signal })
|
|
377
|
+
.then(async (r) => {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
if (!r.ok)
|
|
380
|
+
return void res.json({ up: false, backend: base, status: r.status });
|
|
381
|
+
const body = (await r.json().catch(() => ({})));
|
|
382
|
+
res.json({ up: true, backend: base, ...body });
|
|
383
|
+
})
|
|
384
|
+
.catch(() => {
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
res.json({ up: false, backend: base });
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
/**
|
|
390
|
+
* The two networks a phone could use to reach this daemon — the LAN and the
|
|
391
|
+
* tailnet — with, for each, the address and whether the phone can actually
|
|
392
|
+
* get here on it *right now*. It can't when we're bound to localhost, which
|
|
393
|
+
* is the default; `reachable:false` is the modal's cue to offer "enable
|
|
394
|
+
* phone access" (expose) before showing a QR that wouldn't resolve.
|
|
395
|
+
*/
|
|
396
|
+
app.get("/api/pair/networks", (req, res) => {
|
|
397
|
+
if (!req.isAdmin) {
|
|
398
|
+
return void res.status(403).json({ error: "admin only" });
|
|
399
|
+
}
|
|
400
|
+
void (async () => {
|
|
401
|
+
const exposed = this.exposedIps();
|
|
402
|
+
const reach = (ip) => Boolean(ip) && (this.host === "0.0.0.0" || this.host === ip || exposed.includes(ip));
|
|
403
|
+
const lan = lanIp();
|
|
404
|
+
let ts = null;
|
|
405
|
+
try {
|
|
406
|
+
ts = await tailscaleIp();
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
ts = null;
|
|
410
|
+
}
|
|
411
|
+
res.json({
|
|
412
|
+
port: this.port,
|
|
413
|
+
boundHost: this.host,
|
|
414
|
+
exposed,
|
|
415
|
+
localnet: { ip: lan, reachable: reach(lan) },
|
|
416
|
+
tailnet: ts
|
|
417
|
+
? { ip: ts, available: true, reachable: reach(ts) }
|
|
418
|
+
: {
|
|
419
|
+
ip: null,
|
|
420
|
+
available: false,
|
|
421
|
+
reachable: false,
|
|
422
|
+
reason: "Tailscale isn't logged in on this machine (run `tailscale up`)",
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
})();
|
|
426
|
+
});
|
|
427
|
+
/**
|
|
428
|
+
* Make a phone-reachable address go live — a phone can't reach a
|
|
429
|
+
* localhost-only daemon. We add a second listener on the requested LAN or
|
|
430
|
+
* tailnet IP (never touching localhost), so this is safe to await and report
|
|
431
|
+
* on directly. Explicit and user-driven (you clicked "connect a phone"), and
|
|
432
|
+
* behind the token wall the whole time.
|
|
433
|
+
*/
|
|
434
|
+
app.post("/api/pair/expose", (req, res) => {
|
|
435
|
+
if (!req.isAdmin) {
|
|
436
|
+
return void res.status(403).json({ error: "admin only" });
|
|
437
|
+
}
|
|
438
|
+
void (async () => {
|
|
439
|
+
const wanted = typeof req.body?.host === "string" ? req.body.host.trim() : "";
|
|
440
|
+
let ts = null;
|
|
441
|
+
try {
|
|
442
|
+
ts = await tailscaleIp();
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
ts = null;
|
|
446
|
+
}
|
|
447
|
+
// Only ever bind an address that is genuinely ours (LAN or tailnet).
|
|
448
|
+
const allowed = new Set([lanIp(), ts].filter(Boolean));
|
|
449
|
+
if (!wanted || !allowed.has(wanted)) {
|
|
450
|
+
return void res.status(400).json({ error: "not a local or tailnet address of this machine" });
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
await this.expose(wanted);
|
|
454
|
+
res.json({ ok: true, ip: wanted, port: this.port, exposed: this.exposedIps() });
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
logbook.error("daemon", `could not open phone access on ${wanted}`, err);
|
|
458
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
459
|
+
}
|
|
460
|
+
})();
|
|
461
|
+
});
|
|
462
|
+
app.post("/api/pair/new", (req, res) => {
|
|
463
|
+
if (!req.isAdmin) {
|
|
464
|
+
return void res.status(403).json({ error: "admin only" });
|
|
465
|
+
}
|
|
466
|
+
void (async () => {
|
|
467
|
+
// The QR must point at the address the phone will actually use, so the
|
|
468
|
+
// caller may ask for the LAN or tailnet host — but only those. An
|
|
469
|
+
// arbitrary host from the client never reaches the link.
|
|
470
|
+
const wanted = typeof req.body?.host === "string" ? req.body.host.trim() : "";
|
|
471
|
+
let ts = null;
|
|
472
|
+
try {
|
|
473
|
+
ts = await tailscaleIp();
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
ts = null;
|
|
477
|
+
}
|
|
478
|
+
const allowed = new Set([this.host, lanIp(), ts].filter(Boolean));
|
|
479
|
+
const host = wanted && allowed.has(wanted) ? wanted : this.host;
|
|
480
|
+
const { token, expiresAt } = this.auth.newPairingToken();
|
|
481
|
+
const url = `http://${host}:${this.port}`;
|
|
482
|
+
// Deep link: scanning it with any camera opens the app, which claims the
|
|
483
|
+
// single-use token from the URL fragment and pairs itself.
|
|
484
|
+
const link = `${url}/app#pair=${token}`;
|
|
485
|
+
let qrSvg;
|
|
486
|
+
try {
|
|
487
|
+
qrSvg = await QRCode.toString(link, {
|
|
488
|
+
type: "svg",
|
|
489
|
+
margin: 1,
|
|
490
|
+
errorCorrectionLevel: "M",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
// The link still works even if the QR doesn't render — degrade, don't fail.
|
|
495
|
+
logbook.warn("pair", "QR render failed — the copy link still works", err);
|
|
496
|
+
}
|
|
497
|
+
res.json({ token, expiresAt, url, link, ...(qrSvg ? { qrSvg } : {}) });
|
|
498
|
+
})();
|
|
499
|
+
});
|
|
500
|
+
app.get("/api/pair/clients", (_req, res) => {
|
|
501
|
+
res.json({ clients: this.auth.clients() });
|
|
502
|
+
});
|
|
503
|
+
app.delete("/api/pair/clients/:clientId", (req, res) => {
|
|
504
|
+
if (!req.isAdmin) {
|
|
505
|
+
return void res.status(403).json({ error: "admin only" });
|
|
506
|
+
}
|
|
507
|
+
const revoked = this.auth.revoke(String(req.params.clientId));
|
|
508
|
+
if (!revoked)
|
|
509
|
+
return void res.status(404).json({ error: "unknown client" });
|
|
510
|
+
res.json({ revoked: true });
|
|
511
|
+
});
|
|
512
|
+
// A paired device registers (or clears) its Expo push token.
|
|
513
|
+
app.post("/api/push/register", (req, res) => {
|
|
514
|
+
const me = this.auth.clientFor(bearerToken(req.headers.authorization));
|
|
515
|
+
if (!me)
|
|
516
|
+
return void res.status(403).json({ error: "device tokens only — pair first" });
|
|
517
|
+
const { token, platform } = (req.body ?? {});
|
|
518
|
+
if (!token?.trim())
|
|
519
|
+
return void res.status(400).json({ error: "missing token" });
|
|
520
|
+
this.auth.setPushToken(me.id, token.trim(), platform);
|
|
521
|
+
res.json({ registered: true });
|
|
522
|
+
});
|
|
523
|
+
app.delete("/api/push/register", (req, res) => {
|
|
524
|
+
const me = this.auth.clientFor(bearerToken(req.headers.authorization));
|
|
525
|
+
if (!me)
|
|
526
|
+
return void res.status(403).json({ error: "device tokens only" });
|
|
527
|
+
this.auth.setPushToken(me.id, null);
|
|
528
|
+
res.json({ registered: false });
|
|
529
|
+
});
|
|
530
|
+
// Admin: fire a test push at every registered device.
|
|
531
|
+
app.post("/api/push/test", (req, res) => {
|
|
532
|
+
if (!req.isAdmin) {
|
|
533
|
+
return void res.status(403).json({ error: "admin only" });
|
|
534
|
+
}
|
|
535
|
+
const tokens = this.pushTokens();
|
|
536
|
+
void sendExpoPush(tokens, {
|
|
537
|
+
title: "Loom",
|
|
538
|
+
body: "test notification — pairing works ✓",
|
|
539
|
+
});
|
|
540
|
+
res.json({ sent: tokens.length });
|
|
541
|
+
});
|
|
542
|
+
app.get("/api/projects", (_req, res) => {
|
|
543
|
+
void (async () => {
|
|
544
|
+
const projects = [];
|
|
545
|
+
for (const info of listProjects()) {
|
|
546
|
+
try {
|
|
547
|
+
const rt = await this.runtime(info.id);
|
|
548
|
+
projects.push(await rt.status());
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
projects.push({
|
|
552
|
+
id: info.id,
|
|
553
|
+
name: info.name,
|
|
554
|
+
dir: info.dir,
|
|
555
|
+
holder: null,
|
|
556
|
+
agents: [],
|
|
557
|
+
lastEvent: null,
|
|
558
|
+
needsInput: false,
|
|
559
|
+
error: String(err instanceof Error ? err.message : err),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
res.json({ projects });
|
|
564
|
+
})();
|
|
565
|
+
});
|
|
566
|
+
app.post("/api/projects", (req, res) => {
|
|
567
|
+
void (async () => {
|
|
568
|
+
const { dir, name } = (req.body ?? {});
|
|
569
|
+
if (!dir)
|
|
570
|
+
return void res.status(400).json({ error: "missing dir" });
|
|
571
|
+
const resolved = path.resolve(dir);
|
|
572
|
+
if (!fs.existsSync(resolved)) {
|
|
573
|
+
return void res.status(400).json({ error: `no such directory: ${resolved}` });
|
|
574
|
+
}
|
|
575
|
+
let config = readProjectConfig(resolved);
|
|
576
|
+
if (!config) {
|
|
577
|
+
// Every ADE Loom can drive, probed in parallel — see core/ades.ts.
|
|
578
|
+
// This used to name claude and opencode by hand, which is how the list
|
|
579
|
+
// of what Loom actually drives drifted from the list of logos it ships.
|
|
580
|
+
const availability = await detectAdes();
|
|
581
|
+
const agents = defaultAgentConfigs(availability);
|
|
582
|
+
const routes = buildDefaultRoutes(agents);
|
|
583
|
+
config = {
|
|
584
|
+
name: name ?? path.basename(resolved),
|
|
585
|
+
agents,
|
|
586
|
+
...(routes ? { routes } : {}),
|
|
587
|
+
};
|
|
588
|
+
writeProjectConfig(resolved, config);
|
|
589
|
+
}
|
|
590
|
+
const info = registerProject(resolved, config.name);
|
|
591
|
+
res.json({ project: info, config });
|
|
592
|
+
})();
|
|
593
|
+
});
|
|
594
|
+
const withRuntime = (handler) => {
|
|
595
|
+
return (req, res) => {
|
|
596
|
+
void (async () => {
|
|
597
|
+
try {
|
|
598
|
+
const rt = await this.runtime(String(req.params.id));
|
|
599
|
+
await handler(rt, req, res);
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
if (err instanceof NotHolderError) {
|
|
603
|
+
res.status(409).json({
|
|
604
|
+
error: "not_holder",
|
|
605
|
+
holder: err.holder,
|
|
606
|
+
agentId: err.agentId,
|
|
607
|
+
message: err.message,
|
|
608
|
+
});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (err instanceof RouteActiveError) {
|
|
612
|
+
res.status(409).json({ error: "route_active", message: err.message });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// A 500 used to be a sentence for one caller and nothing else: no
|
|
616
|
+
// stack, no record, gone the moment the fetch resolved. Now the
|
|
617
|
+
// Console gets it with the stack and the route that produced it.
|
|
618
|
+
logbook.error("api", `${req.method} ${req.path} failed: ${err instanceof Error ? err.message : String(err)}`, err, String(req.params.id ?? ""));
|
|
619
|
+
res.status(500).json({ error: String(err instanceof Error ? err.message : err) });
|
|
620
|
+
}
|
|
621
|
+
})();
|
|
622
|
+
};
|
|
623
|
+
};
|
|
624
|
+
app.get("/api/projects/:id", withRuntime(async (rt, _req, res) => {
|
|
625
|
+
res.json({ project: await rt.status() });
|
|
626
|
+
}));
|
|
627
|
+
app.get("/api/projects/:id/events", withRuntime(async (rt, req, res) => {
|
|
628
|
+
const since = req.query.since ? Number(req.query.since) : undefined;
|
|
629
|
+
const limit = req.query.limit ? Number(req.query.limit) : 200;
|
|
630
|
+
// no ?chat= means the whole project — old clients keep seeing the
|
|
631
|
+
// whole thread, which is what they've always shown
|
|
632
|
+
const chat = req.query.chat ? String(req.query.chat) : undefined;
|
|
633
|
+
res.json({ events: rt.log.list({ since, limit, ...(chat ? { chat } : {}) }) });
|
|
634
|
+
}));
|
|
635
|
+
app.post("/api/projects/:id/messages", withRuntime(async (rt, req, res) => {
|
|
636
|
+
const { text, agentId, chat } = (req.body ?? {});
|
|
637
|
+
if (!text?.trim())
|
|
638
|
+
return void res.status(400).json({ error: "missing text" });
|
|
639
|
+
const result = await rt.sendMessage(text, agentId, chat ? { chat } : {});
|
|
640
|
+
res.json(result);
|
|
641
|
+
}));
|
|
642
|
+
/**
|
|
643
|
+
* Drive a GUI agent: type into Antigravity's or Kiro's own chat and read
|
|
644
|
+
* back what appeared.
|
|
645
|
+
*
|
|
646
|
+
* Separate from /messages because it is a different act. /messages hands a
|
|
647
|
+
* turn to something that can hold the baton; this types into an app you're
|
|
648
|
+
* signed into and waits for its panel to settle. The bridge never takes the
|
|
649
|
+
* lock, so an adapter mid-turn is untouched.
|
|
650
|
+
*
|
|
651
|
+
* It blocks for as long as the app takes to answer — minutes, for a real
|
|
652
|
+
* task. That's why it's its own route: nothing else here is allowed to be
|
|
653
|
+
* this slow, and the client needs to know to wait.
|
|
654
|
+
*/
|
|
655
|
+
app.post("/api/projects/:id/bridge/:agentId/ask", withRuntime(async (rt, req, res) => {
|
|
656
|
+
const { text, chat } = (req.body ?? {});
|
|
657
|
+
const agentId = String(req.params.agentId);
|
|
658
|
+
if (!text?.trim())
|
|
659
|
+
return void res.status(400).json({ error: "missing text" });
|
|
660
|
+
try {
|
|
661
|
+
const result = await rt.askBridge(agentId, text, chat ? { chat } : {});
|
|
662
|
+
res.json(result);
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
// 409, not 500: "log into Antigravity" is a state you can fix, not a
|
|
666
|
+
// bug in the daemon, and the message is the whole value of the reply.
|
|
667
|
+
res.status(409).json({ error: err instanceof Error ? err.message : String(err) });
|
|
668
|
+
}
|
|
669
|
+
}));
|
|
670
|
+
app.post("/api/projects/:id/handoff", withRuntime(async (rt, req, res) => {
|
|
671
|
+
const { to } = (req.body ?? {});
|
|
672
|
+
if (!to)
|
|
673
|
+
return void res.status(400).json({ error: "missing to" });
|
|
674
|
+
const result = await rt.handoff(to);
|
|
675
|
+
res.json({ ...result, to });
|
|
676
|
+
}));
|
|
677
|
+
// Chats — several conversations inside one project. They share the brain,
|
|
678
|
+
// the baton and the working tree; only the talking is separate.
|
|
679
|
+
app.get("/api/projects/:id/chats", withRuntime(async (rt, _req, res) => {
|
|
680
|
+
res.json({ chats: rt.chats() });
|
|
681
|
+
}));
|
|
682
|
+
app.post("/api/projects/:id/chats", withRuntime(async (rt, req, res) => {
|
|
683
|
+
const { title } = (req.body ?? {});
|
|
684
|
+
res.json({ chat: rt.createChat(String(title ?? "")) });
|
|
685
|
+
}));
|
|
686
|
+
app.post("/api/projects/:id/chats/:chatId/rename", withRuntime(async (rt, req, res) => {
|
|
687
|
+
const { title } = (req.body ?? {});
|
|
688
|
+
if (!title?.trim())
|
|
689
|
+
return void res.status(400).json({ error: "missing title" });
|
|
690
|
+
const chat = rt.renameChat(String(req.params.chatId), title);
|
|
691
|
+
if (!chat)
|
|
692
|
+
return void res.status(400).json({ error: "cannot rename that chat" });
|
|
693
|
+
res.json({ chat });
|
|
694
|
+
}));
|
|
695
|
+
app.delete("/api/projects/:id/chats/:chatId", withRuntime(async (rt, req, res) => {
|
|
696
|
+
if (!rt.deleteChat(String(req.params.chatId))) {
|
|
697
|
+
return void res.status(400).json({ error: "cannot delete that chat" });
|
|
698
|
+
}
|
|
699
|
+
res.json({ deleted: true });
|
|
700
|
+
}));
|
|
701
|
+
// Rename an agent's role. It's free text — your project decides what jobs
|
|
702
|
+
// exist, not us. Writes .loom/config.json, which is the source of truth.
|
|
703
|
+
app.post("/api/projects/:id/agents/:agentId/role", withRuntime(async (rt, req, res) => {
|
|
704
|
+
const { role } = (req.body ?? {});
|
|
705
|
+
if (typeof role !== "string")
|
|
706
|
+
return void res.status(400).json({ error: "missing role" });
|
|
707
|
+
const clean = role.trim().slice(0, 40);
|
|
708
|
+
if (!clean)
|
|
709
|
+
return void res.status(400).json({ error: "role cannot be empty" });
|
|
710
|
+
const updated = rt.setAgentRole(String(req.params.agentId), clean);
|
|
711
|
+
if (!updated)
|
|
712
|
+
return void res.status(404).json({ error: "unknown agent" });
|
|
713
|
+
res.json(updated);
|
|
714
|
+
}));
|
|
715
|
+
// The Settings screen reads its editable knobs here — brain extractor,
|
|
716
|
+
// projection mode, default agent — with the roster the picker chooses from.
|
|
717
|
+
app.get("/api/projects/:id/config", withRuntime(async (rt, _req, res) => {
|
|
718
|
+
res.json(rt.settings());
|
|
719
|
+
}));
|
|
720
|
+
// The Settings screen's editable knobs: the brain extractor, the projection
|
|
721
|
+
// mode, the default agent. Everything is read live from config, so a merge
|
|
722
|
+
// here lands on the next turn/handoff with no restart. Partial — send only
|
|
723
|
+
// what changed. Returns the full config so the screen can re-render.
|
|
724
|
+
app.patch("/api/projects/:id/config", withRuntime(async (rt, req, res) => {
|
|
725
|
+
const body = (req.body ?? {});
|
|
726
|
+
try {
|
|
727
|
+
const cfg = rt.patchConfig({
|
|
728
|
+
brain: body.brain,
|
|
729
|
+
projection: body.projection,
|
|
730
|
+
defaultAgent: body.defaultAgent,
|
|
731
|
+
});
|
|
732
|
+
res.json({
|
|
733
|
+
brain: cfg.brain ?? {},
|
|
734
|
+
projection: cfg.projection ?? {},
|
|
735
|
+
defaultAgent: cfg.defaultAgent ?? "",
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
740
|
+
}
|
|
741
|
+
}));
|
|
742
|
+
// ---- search -----------------------------------------------------------
|
|
743
|
+
// Finding a file by name was the whole of search, which is its least useful
|
|
744
|
+
// half: you remember a line, not a filename. And the thread — where a
|
|
745
|
+
// project's actual reasoning lives — wasn't searchable at all.
|
|
746
|
+
app.get("/api/projects/:id/grep", withRuntime(async (rt, req, res) => {
|
|
747
|
+
res.json(await searchCode(rt.info.dir, String(req.query.q ?? "")));
|
|
748
|
+
}));
|
|
749
|
+
app.get("/api/projects/:id/chats/search", withRuntime(async (rt, req, res) => {
|
|
750
|
+
res.json(searchChats(rt.log, String(req.query.q ?? ""), {
|
|
751
|
+
...(req.query.chat ? { chat: String(req.query.chat) } : {}),
|
|
752
|
+
}));
|
|
753
|
+
}));
|
|
754
|
+
// ---- source control ---------------------------------------------------
|
|
755
|
+
// Reading the working tree has been possible since the Explorer landed;
|
|
756
|
+
// doing anything about it has not. These are the writes, and they're the
|
|
757
|
+
// only endpoints in Loom that can destroy work — hence the path checks in
|
|
758
|
+
// core/git.ts and the noise in the log when you discard.
|
|
759
|
+
app.get("/api/projects/:id/git/status", withRuntime(async (rt, _req, res) => {
|
|
760
|
+
res.json(await gitStatus(rt.info.dir));
|
|
761
|
+
}));
|
|
762
|
+
const gitWrite = (fn) => withRuntime(async (rt, req, res) => {
|
|
763
|
+
try {
|
|
764
|
+
res.json(await fn(rt.info.dir, (req.body ?? {})));
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
// git's own words, not ours: "nothing to commit, working tree clean"
|
|
768
|
+
// beats anything we'd invent about an exit code.
|
|
769
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
770
|
+
logbook.warn("git", message, err instanceof GitError ? err.stderr : err, rt.info.id);
|
|
771
|
+
res.status(400).json({ error: message });
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
app.post("/api/projects/:id/git/stage", gitWrite((dir, b) => gitStage(dir, asPaths(b.paths))));
|
|
775
|
+
app.post("/api/projects/:id/git/unstage", gitWrite((dir, b) => gitUnstage(dir, asPaths(b.paths))));
|
|
776
|
+
app.post("/api/projects/:id/git/discard", gitWrite((dir, b) => gitDiscard(dir, asPaths(b.paths), asPaths(b.untracked))));
|
|
777
|
+
app.post("/api/projects/:id/git/commit", gitWrite((dir, b) => gitCommit(dir, String(b.message ?? ""))));
|
|
778
|
+
// init / push / checkout — all write, all through the same error surface.
|
|
779
|
+
app.post("/api/projects/:id/git/init", gitWrite((dir) => gitInit(dir)));
|
|
780
|
+
app.post("/api/projects/:id/git/push", gitWrite((dir) => gitPush(dir)));
|
|
781
|
+
app.post("/api/projects/:id/git/checkout", gitWrite((dir, b) => gitCheckout(dir, String(b.ref ?? ""))));
|
|
782
|
+
// read-only: the commit log, one file's diff, and the branch list
|
|
783
|
+
app.get("/api/projects/:id/git/log", withRuntime(async (rt, req, res) => {
|
|
784
|
+
const limit = Number(req.query.limit) || 30;
|
|
785
|
+
res.json({ commits: await gitLog(rt.info.dir, limit) });
|
|
786
|
+
}));
|
|
787
|
+
app.get("/api/projects/:id/git/diff", withRuntime(async (rt, req, res) => {
|
|
788
|
+
const p = String(req.query.path ?? "");
|
|
789
|
+
if (!p)
|
|
790
|
+
return void res.status(400).json({ error: "missing path" });
|
|
791
|
+
try {
|
|
792
|
+
res.json({ path: p, patch: await gitFileDiff(rt.info.dir, p) });
|
|
793
|
+
}
|
|
794
|
+
catch (err) {
|
|
795
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
796
|
+
}
|
|
797
|
+
}));
|
|
798
|
+
app.get("/api/projects/:id/git/branches", withRuntime(async (rt, _req, res) => {
|
|
799
|
+
res.json(await gitBranches(rt.info.dir));
|
|
800
|
+
}));
|
|
801
|
+
// Draft a commit message from the staged diff, via the logged-in Claude CLI
|
|
802
|
+
// — the "Generate" affordance. No key; a no-op-ish 400 when Claude isn't
|
|
803
|
+
// there, so the field just stays empty and the user types their own.
|
|
804
|
+
app.post("/api/projects/:id/git/suggest-message", withRuntime(async (rt, _req, res) => {
|
|
805
|
+
const diff = await gitStagedDiff(rt.info.dir).catch(() => "");
|
|
806
|
+
if (!diff.trim())
|
|
807
|
+
return void res.status(400).json({ error: "nothing to describe — stage or edit some files first" });
|
|
808
|
+
try {
|
|
809
|
+
const prompt = "Write a single-line Conventional Commit subject (type(scope): summary, imperative mood, <72 chars) " +
|
|
810
|
+
"for this diff. Reply with ONLY the subject line, no quotes, no body.\n\n" +
|
|
811
|
+
diff;
|
|
812
|
+
const out = (await claudeText(prompt, { model: "haiku", timeoutMs: 30_000 })).trim();
|
|
813
|
+
const message = out.split("\n")[0]?.replace(/^["'`]|["'`]$/g, "").trim().slice(0, 120) ?? "";
|
|
814
|
+
if (!message)
|
|
815
|
+
return void res.status(502).json({ error: "Claude returned nothing — type a message instead" });
|
|
816
|
+
res.json({ message });
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
// claudeText's raw "claude exited N" helps no one at the commit box.
|
|
820
|
+
res.status(502).json({ error: "couldn't reach Claude to draft a message — type one instead" });
|
|
821
|
+
}
|
|
822
|
+
}));
|
|
823
|
+
// ---- the Console ------------------------------------------------------
|
|
824
|
+
// Everything that went wrong, for the tab next to the terminal. Until this
|
|
825
|
+
// existed an error's only home was ~/.loom/daemon.log, which you have to
|
|
826
|
+
// know about, find, and tail — so in practice errors reached nobody.
|
|
827
|
+
app.get("/api/logs", (req, res) => {
|
|
828
|
+
const since = req.query.since === undefined ? undefined : Number(req.query.since);
|
|
829
|
+
const level = req.query.level;
|
|
830
|
+
res.json({
|
|
831
|
+
logs: logbook.list({
|
|
832
|
+
...(Number.isFinite(since) ? { since } : {}),
|
|
833
|
+
...(level ? { level } : {}),
|
|
834
|
+
...(req.query.project ? { project: String(req.query.project) } : {}),
|
|
835
|
+
}),
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
app.delete("/api/logs", (_req, res) => {
|
|
839
|
+
logbook.clear();
|
|
840
|
+
res.json({ ok: true });
|
|
841
|
+
});
|
|
842
|
+
/**
|
|
843
|
+
* The window reporting its own errors — a failed fetch, a thrown render, an
|
|
844
|
+
* unhandled rejection. Client-side failures used to die in the browser
|
|
845
|
+
* console where no one was looking; now they land in the same Console tab as
|
|
846
|
+
* the daemon's, streamed to every window and kept in the ring buffer.
|
|
847
|
+
*/
|
|
848
|
+
app.post("/api/logs", (req, res) => {
|
|
849
|
+
const b = (req.body ?? {});
|
|
850
|
+
const level = b.level === "error" || b.level === "warn" ? b.level : "info";
|
|
851
|
+
const message = String(b.message ?? "").slice(0, 500);
|
|
852
|
+
if (!message)
|
|
853
|
+
return void res.status(400).json({ error: "missing message" });
|
|
854
|
+
const scope = (b.scope ? String(b.scope) : "app").slice(0, 40);
|
|
855
|
+
const rec = logbook.add(level, scope, message, b.detail, b.project ? String(b.project) : undefined);
|
|
856
|
+
res.json({ ok: true, id: rec.id });
|
|
857
|
+
});
|
|
858
|
+
// Which agents Loom can drive on this machine, and which are already in
|
|
859
|
+
// this project. The UI needs both to offer you the difference.
|
|
860
|
+
app.get("/api/projects/:id/agents/available", withRuntime(async (rt, _req, res) => {
|
|
861
|
+
const availability = await detectAdes();
|
|
862
|
+
const inProject = new Set(rt.config.agents.map((a) => a.kind));
|
|
863
|
+
res.json({
|
|
864
|
+
ades: ADES.map((a) => ({
|
|
865
|
+
kind: a.kind,
|
|
866
|
+
label: a.label,
|
|
867
|
+
tier: a.tier,
|
|
868
|
+
// Bridges are never "installed" — they're an app you launch with a
|
|
869
|
+
// debug port, so presence is a live question, not a lookup.
|
|
870
|
+
installed: a.tier === "adapter" ? Boolean(availability[a.kind]) : null,
|
|
871
|
+
inProject: inProject.has(a.kind),
|
|
872
|
+
models: a.models ?? [],
|
|
873
|
+
})),
|
|
874
|
+
});
|
|
875
|
+
}));
|
|
876
|
+
// Add an agent to a project. A roster used to be frozen at creation: install
|
|
877
|
+
// a new ADE and your existing projects never heard of it.
|
|
878
|
+
app.post("/api/projects/:id/agents", withRuntime(async (rt, req, res) => {
|
|
879
|
+
const { kind, id, role } = (req.body ?? {});
|
|
880
|
+
if (!kind?.trim())
|
|
881
|
+
return void res.status(400).json({ error: "missing kind" });
|
|
882
|
+
try {
|
|
883
|
+
res.json(rt.addAgent(kind.trim(), { id, role }));
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
887
|
+
}
|
|
888
|
+
}));
|
|
889
|
+
app.delete("/api/projects/:id/agents/:agentId", withRuntime(async (rt, req, res) => {
|
|
890
|
+
try {
|
|
891
|
+
res.json(rt.removeAgent(String(req.params.agentId)));
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
895
|
+
}
|
|
896
|
+
}));
|
|
897
|
+
// Point an agent at a different model. Empty string clears the override.
|
|
898
|
+
app.post("/api/projects/:id/agents/:agentId/model", withRuntime(async (rt, req, res) => {
|
|
899
|
+
const { model } = (req.body ?? {});
|
|
900
|
+
try {
|
|
901
|
+
const cfg = rt.setAgentModel(String(req.params.agentId), model ?? "");
|
|
902
|
+
res.json({ agent: cfg });
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
906
|
+
}
|
|
907
|
+
}));
|
|
908
|
+
app.post("/api/projects/:id/interrupt", withRuntime(async (rt, _req, res) => {
|
|
909
|
+
res.json(await rt.interrupt());
|
|
910
|
+
}));
|
|
911
|
+
app.post("/api/projects/:id/decisions", withRuntime(async (rt, req, res) => {
|
|
912
|
+
const { text } = (req.body ?? {});
|
|
913
|
+
if (!text?.trim())
|
|
914
|
+
return void res.status(400).json({ error: "missing text" });
|
|
915
|
+
const event = rt.log.append({ kind: "decision", payload: { text } });
|
|
916
|
+
// Also a memory. The decision event stays because the projection and
|
|
917
|
+
// forty other things read it; the memory is the addressable copy — the
|
|
918
|
+
// one that can be retrieved by what it's about, corrected, and
|
|
919
|
+
// forgotten. Seeding the brain from the surface people already use
|
|
920
|
+
// beats asking them to fill a second box.
|
|
921
|
+
rt.brain.add({
|
|
922
|
+
kind: "decision",
|
|
923
|
+
text,
|
|
924
|
+
provenance: { agentId: "user", eventId: event.id, ts: event.ts },
|
|
925
|
+
});
|
|
926
|
+
res.json({ event });
|
|
927
|
+
}));
|
|
928
|
+
// --- the brain ---------------------------------------------------------
|
|
929
|
+
app.get("/api/projects/:id/brain", withRuntime(async (rt, req, res) => {
|
|
930
|
+
const q = req.query;
|
|
931
|
+
const memories = rt.brain.list({
|
|
932
|
+
...(q.kind ? { kind: q.kind } : {}),
|
|
933
|
+
...(q.chat ? { chat: q.chat } : {}),
|
|
934
|
+
...(q.includeExpired === "1" ? { includeExpired: true } : {}),
|
|
935
|
+
...(q.limit ? { limit: Math.min(500, Number(q.limit) || 100) } : {}),
|
|
936
|
+
});
|
|
937
|
+
res.json({ memories, stats: rt.brain.stats() });
|
|
938
|
+
}));
|
|
939
|
+
app.get("/api/projects/:id/brain/search", withRuntime(async (rt, req, res) => {
|
|
940
|
+
const q = req.query;
|
|
941
|
+
const files = q.files ? q.files.split(",").filter(Boolean) : [];
|
|
942
|
+
if (!q.q?.trim() && !files.length) {
|
|
943
|
+
return void res.status(400).json({ error: "missing q or files" });
|
|
944
|
+
}
|
|
945
|
+
const hits = retrieve(rt.brain, {
|
|
946
|
+
...(q.q ? { query: q.q } : {}),
|
|
947
|
+
...(files.length ? { files } : {}),
|
|
948
|
+
...(q.chat ? { chat: q.chat } : {}),
|
|
949
|
+
...(q.agent ? { agent: q.agent } : {}),
|
|
950
|
+
limit: Math.min(50, Number(q.limit) || 12),
|
|
951
|
+
explain: q.explain === "1",
|
|
952
|
+
});
|
|
953
|
+
res.json({ hits });
|
|
954
|
+
}));
|
|
955
|
+
app.post("/api/projects/:id/brain", withRuntime(async (rt, req, res) => {
|
|
956
|
+
const body = (req.body ?? {});
|
|
957
|
+
if (!body.text?.trim())
|
|
958
|
+
return void res.status(400).json({ error: "missing text" });
|
|
959
|
+
try {
|
|
960
|
+
const { memory, created } = rt.brain.add({
|
|
961
|
+
kind: body.kind ?? "fact",
|
|
962
|
+
text: body.text,
|
|
963
|
+
...(body.entities ? { entities: body.entities } : {}),
|
|
964
|
+
...(body.chat ? { scope: { chat: body.chat } } : {}),
|
|
965
|
+
...(body.confidence !== undefined ? { confidence: body.confidence } : {}),
|
|
966
|
+
provenance: { agentId: "user", eventId: rt.log.lastId(), ts: Date.now() },
|
|
967
|
+
});
|
|
968
|
+
res.json({ memory, created });
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
972
|
+
}
|
|
973
|
+
}));
|
|
974
|
+
app.patch("/api/projects/:id/brain/:mid", withRuntime(async (rt, req, res) => {
|
|
975
|
+
try {
|
|
976
|
+
res.json({ memory: rt.brain.update(String(req.params.mid), (req.body ?? {}), "user") });
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
980
|
+
res.status(/no such memory/.test(msg) ? 404 : 400).json({ error: msg });
|
|
981
|
+
}
|
|
982
|
+
}));
|
|
983
|
+
app.delete("/api/projects/:id/brain/:mid", withRuntime(async (rt, req, res) => {
|
|
984
|
+
const reason = String(req.query.reason ?? "").trim();
|
|
985
|
+
if (!reason)
|
|
986
|
+
return void res.status(400).json({ error: "forgetting needs a reason" });
|
|
987
|
+
const forgot = rt.brain.forget(String(req.params.mid), reason, "user");
|
|
988
|
+
if (!forgot)
|
|
989
|
+
return void res.status(404).json({ error: "no such memory" });
|
|
990
|
+
res.json({ forgot: true });
|
|
991
|
+
}));
|
|
992
|
+
app.get("/api/projects/:id/brain/:mid/history", withRuntime(async (rt, req, res) => {
|
|
993
|
+
res.json({ history: rt.brain.history(String(req.params.mid)) });
|
|
994
|
+
}));
|
|
995
|
+
app.post("/api/projects/:id/route", withRuntime(async (rt, req, res) => {
|
|
996
|
+
const { task, spec, router, maxHops } = (req.body ?? {});
|
|
997
|
+
if (!task?.trim())
|
|
998
|
+
return void res.status(400).json({ error: "missing task" });
|
|
999
|
+
const route = await rt.startRoute({
|
|
1000
|
+
task,
|
|
1001
|
+
...(spec !== undefined ? { spec } : {}),
|
|
1002
|
+
...(router ? { router } : {}),
|
|
1003
|
+
...(maxHops ? { maxHops: Number(maxHops) } : {}),
|
|
1004
|
+
});
|
|
1005
|
+
res.json({ route });
|
|
1006
|
+
}));
|
|
1007
|
+
app.get("/api/projects/:id/route", withRuntime(async (rt, _req, res) => {
|
|
1008
|
+
res.json({ route: rt.routeState() });
|
|
1009
|
+
}));
|
|
1010
|
+
app.get("/api/projects/:id/costs", withRuntime(async (rt, _req, res) => {
|
|
1011
|
+
res.json({ costs: rt.costSummary() });
|
|
1012
|
+
}));
|
|
1013
|
+
app.get("/api/projects/:id/tree", withRuntime(async (rt, _req, res) => {
|
|
1014
|
+
res.json({ tree: await rt.workingTree() });
|
|
1015
|
+
}));
|
|
1016
|
+
app.get("/api/projects/:id/memory", withRuntime(async (rt, _req, res) => {
|
|
1017
|
+
res.json({ memory: rt.unifiedMemory() });
|
|
1018
|
+
}));
|
|
1019
|
+
app.post("/api/projects/:id/memory/import", withRuntime(async (rt, _req, res) => {
|
|
1020
|
+
res.json(rt.importMemories());
|
|
1021
|
+
}));
|
|
1022
|
+
app.delete("/api/projects/:id/route", withRuntime(async (rt, _req, res) => {
|
|
1023
|
+
res.json({ route: await rt.abortRoute() });
|
|
1024
|
+
}));
|
|
1025
|
+
// Terminal: one long-lived shell per tab. A real pty when node-pty is
|
|
1026
|
+
// available (echo, job control, vim), otherwise a pipe-backed shell — see
|
|
1027
|
+
// terminals.ts. Output streams over the project WebSocket; input arrives
|
|
1028
|
+
// there too, because a tty needs a round-trip per keystroke. Bearer auth +
|
|
1029
|
+
// the tailnet are the trust boundary, same as the agents the daemon runs.
|
|
1030
|
+
app.post("/api/projects/:id/term/open", (req, res) => {
|
|
1031
|
+
const info = findProject(String(req.params.id));
|
|
1032
|
+
if (!info)
|
|
1033
|
+
return void res.status(404).json({ error: "unknown project" });
|
|
1034
|
+
const { term, cols, rows } = (req.body ?? {});
|
|
1035
|
+
const termId = String(term ?? "t1");
|
|
1036
|
+
const existing = this.terminals.get(info.id, termId);
|
|
1037
|
+
if (existing) {
|
|
1038
|
+
// A reload rejoins the session it left, and gets replayed what it missed.
|
|
1039
|
+
return void res.json({
|
|
1040
|
+
term: termId,
|
|
1041
|
+
cwd: existing.cwd,
|
|
1042
|
+
mode: existing.mode,
|
|
1043
|
+
reused: true,
|
|
1044
|
+
scrollback: existing.scrollback(),
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
const sess = this.terminals.open(info.id, termId, info.dir, cols ?? 80, rows ?? 24);
|
|
1049
|
+
res.json({ term: termId, cwd: sess.cwd, mode: sess.mode, reused: false, scrollback: "" });
|
|
1050
|
+
}
|
|
1051
|
+
catch (err) {
|
|
1052
|
+
// only the cap is a 429 — a shell that won't spawn is our problem, not
|
|
1053
|
+
// the client's rate
|
|
1054
|
+
res.status(err instanceof TooManySessionsError ? 429 : 500).json({
|
|
1055
|
+
error: err.message,
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
app.post("/api/projects/:id/term/input", (req, res) => {
|
|
1060
|
+
const info = findProject(String(req.params.id));
|
|
1061
|
+
if (!info)
|
|
1062
|
+
return void res.status(404).json({ error: "unknown project" });
|
|
1063
|
+
const { term, data } = (req.body ?? {});
|
|
1064
|
+
const termId = String(term ?? "t1");
|
|
1065
|
+
try {
|
|
1066
|
+
// this opens a session when none exists, so it can fail the same ways
|
|
1067
|
+
// /term/open can — uncaught, Express answers a JSON client with HTML
|
|
1068
|
+
const sess = this.terminals.get(info.id, termId) ?? this.terminals.open(info.id, termId, info.dir);
|
|
1069
|
+
sess.write(String(data ?? ""));
|
|
1070
|
+
res.json({ ok: true });
|
|
1071
|
+
}
|
|
1072
|
+
catch (err) {
|
|
1073
|
+
res.status(err instanceof TooManySessionsError ? 429 : 500).json({
|
|
1074
|
+
error: err.message,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
app.post("/api/projects/:id/term/signal", (req, res) => {
|
|
1079
|
+
const info = findProject(String(req.params.id));
|
|
1080
|
+
if (!info)
|
|
1081
|
+
return void res.status(404).json({ error: "unknown project" });
|
|
1082
|
+
const sess = this.terminals.get(info.id, String((req.body ?? {}).term ?? "t1"));
|
|
1083
|
+
if (!sess)
|
|
1084
|
+
return void res.json({ signalled: false });
|
|
1085
|
+
sess.interrupt();
|
|
1086
|
+
res.json({ signalled: true });
|
|
1087
|
+
});
|
|
1088
|
+
app.post("/api/projects/:id/term/resize", (req, res) => {
|
|
1089
|
+
const info = findProject(String(req.params.id));
|
|
1090
|
+
if (!info)
|
|
1091
|
+
return void res.status(404).json({ error: "unknown project" });
|
|
1092
|
+
const { term, cols, rows } = (req.body ?? {});
|
|
1093
|
+
const sess = this.terminals.get(info.id, String(term ?? "t1"));
|
|
1094
|
+
if (!sess)
|
|
1095
|
+
return void res.json({ resized: false });
|
|
1096
|
+
sess.resize(Number(cols) || 80, Number(rows) || 24);
|
|
1097
|
+
res.json({ resized: true });
|
|
1098
|
+
});
|
|
1099
|
+
app.post("/api/projects/:id/term/close", (req, res) => {
|
|
1100
|
+
const info = findProject(String(req.params.id));
|
|
1101
|
+
if (!info)
|
|
1102
|
+
return void res.status(404).json({ error: "unknown project" });
|
|
1103
|
+
this.terminals.close(info.id, String((req.body ?? {}).term ?? "t1"));
|
|
1104
|
+
res.json({ closed: true });
|
|
1105
|
+
});
|
|
1106
|
+
// The board: live agents (from us) + pull requests (from gh), sorted into
|
|
1107
|
+
// working → needs you → in review → ready. See board.ts.
|
|
1108
|
+
app.get("/api/projects/:id/board", async (req, res) => {
|
|
1109
|
+
const info = findProject(String(req.params.id));
|
|
1110
|
+
if (!info)
|
|
1111
|
+
return void res.status(404).json({ error: "unknown project" });
|
|
1112
|
+
try {
|
|
1113
|
+
const rt = await this.runtime(info.id);
|
|
1114
|
+
const status = await rt.status();
|
|
1115
|
+
const blocked = status.blockedAgent ? [status.blockedAgent] : [];
|
|
1116
|
+
const search = req.query.search ? String(req.query.search) : undefined;
|
|
1117
|
+
res.json(await buildBoard(info.dir, status.agents, blocked, {
|
|
1118
|
+
tasks: rt.boardTasks(),
|
|
1119
|
+
...(search ? { search } : {}),
|
|
1120
|
+
}));
|
|
1121
|
+
}
|
|
1122
|
+
catch (err) {
|
|
1123
|
+
res.status(500).json({ error: err.message });
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
// Cards you write yourself. Unlike an agent or a PR, these are ours, so a
|
|
1127
|
+
// drag really moves them — the column IS the state.
|
|
1128
|
+
app.post("/api/projects/:id/board/tasks", withRuntime(async (rt, req, res) => {
|
|
1129
|
+
const { title, column, agent } = (req.body ?? {});
|
|
1130
|
+
if (!title?.trim())
|
|
1131
|
+
return void res.status(400).json({ error: "missing title" });
|
|
1132
|
+
res.json({ task: rt.createTask({ title, ...(column ? { column } : {}), ...(agent ? { agent } : {}) }) });
|
|
1133
|
+
}));
|
|
1134
|
+
app.post("/api/projects/:id/board/tasks/:taskId", withRuntime(async (rt, req, res) => {
|
|
1135
|
+
const { title, column, agent } = (req.body ?? {});
|
|
1136
|
+
const task = rt.updateTask(String(req.params.taskId), {
|
|
1137
|
+
...(title !== undefined ? { title } : {}),
|
|
1138
|
+
...(column !== undefined ? { column } : {}),
|
|
1139
|
+
...(agent !== undefined ? { agent } : {}),
|
|
1140
|
+
});
|
|
1141
|
+
if (!task)
|
|
1142
|
+
return void res.status(404).json({ error: "unknown task" });
|
|
1143
|
+
res.json({ task });
|
|
1144
|
+
}));
|
|
1145
|
+
app.delete("/api/projects/:id/board/tasks/:taskId", withRuntime(async (rt, req, res) => {
|
|
1146
|
+
if (!rt.deleteTask(String(req.params.taskId))) {
|
|
1147
|
+
return void res.status(404).json({ error: "unknown task" });
|
|
1148
|
+
}
|
|
1149
|
+
res.json({ deleted: true });
|
|
1150
|
+
}));
|
|
1151
|
+
// Issues / PRs for the project's GitHub remote, read through the user's
|
|
1152
|
+
// own gh CLI (see tasks.ts) — Loom holds no token of its own.
|
|
1153
|
+
app.get("/api/projects/:id/tasks", async (req, res) => {
|
|
1154
|
+
const info = findProject(String(req.params.id));
|
|
1155
|
+
if (!info)
|
|
1156
|
+
return void res.status(404).json({ error: "unknown project" });
|
|
1157
|
+
const kind = String(req.query.kind ?? "issue") === "pr" ? "pr" : "issue";
|
|
1158
|
+
res.json(await listTasks(info.dir, {
|
|
1159
|
+
kind,
|
|
1160
|
+
...(req.query.search ? { search: String(req.query.search) } : {}),
|
|
1161
|
+
}));
|
|
1162
|
+
});
|
|
1163
|
+
// Small async wrapper: resolve the project or 404, run the handler, and turn
|
|
1164
|
+
// any throw into a 500 with its message. The GitHub/Linear/worktree reads
|
|
1165
|
+
// below all share this shape.
|
|
1166
|
+
const projectRoute = (fn) => (req, res) => {
|
|
1167
|
+
const info = findProject(String(req.params.id));
|
|
1168
|
+
if (!info)
|
|
1169
|
+
return void res.status(404).json({ error: "unknown project" });
|
|
1170
|
+
void fn(info.dir, req, res).catch((err) => res.status(500).json({ error: err instanceof Error ? err.message : String(err) }));
|
|
1171
|
+
};
|
|
1172
|
+
// ---- GitHub Projects (v2) — the owner's boards, browsed in-app ----------
|
|
1173
|
+
app.get("/api/projects/:id/gh/projects", projectRoute(async (dir, _req, res) => {
|
|
1174
|
+
res.json(await ghProjects(dir));
|
|
1175
|
+
}));
|
|
1176
|
+
app.get("/api/projects/:id/gh/projects/:num/items", projectRoute(async (dir, req, res) => {
|
|
1177
|
+
res.json(await ghProjectItems(dir, Number(req.params.num)));
|
|
1178
|
+
}));
|
|
1179
|
+
// ---- Pull-request review — diff + approve / request-changes / comment ----
|
|
1180
|
+
app.get("/api/projects/:id/prs/:num", projectRoute(async (dir, req, res) => {
|
|
1181
|
+
res.json(await prView(dir, Number(req.params.num)));
|
|
1182
|
+
}));
|
|
1183
|
+
app.post("/api/projects/:id/prs/:num/review", projectRoute(async (dir, req, res) => {
|
|
1184
|
+
const { action, body } = (req.body ?? {});
|
|
1185
|
+
const allowed = ["approve", "request-changes", "comment"];
|
|
1186
|
+
if (!allowed.includes(action)) {
|
|
1187
|
+
return void res.status(400).json({ error: "action must be approve, request-changes, or comment" });
|
|
1188
|
+
}
|
|
1189
|
+
const result = await prReview(dir, Number(req.params.num), action, body ?? "");
|
|
1190
|
+
if ("available" in result)
|
|
1191
|
+
return void res.status(400).json({ error: result.detail });
|
|
1192
|
+
res.json(result);
|
|
1193
|
+
}));
|
|
1194
|
+
// ---- Worktrees — open a checked-out branch from any task ----------------
|
|
1195
|
+
app.get("/api/projects/:id/worktrees", projectRoute(async (dir, _req, res) => {
|
|
1196
|
+
res.json({ worktrees: await gitListWorktrees(dir) });
|
|
1197
|
+
}));
|
|
1198
|
+
app.post("/api/projects/:id/worktrees", projectRoute(async (dir, req, res) => {
|
|
1199
|
+
const b = (req.body ?? {});
|
|
1200
|
+
if (b.pr) {
|
|
1201
|
+
const n = Number(b.pr);
|
|
1202
|
+
const wt = await gitAddWorktree(dir, { slug: "pr-" + n, detached: true });
|
|
1203
|
+
try {
|
|
1204
|
+
// gh handles fork PRs (adds the remote, fetches, makes the branch)
|
|
1205
|
+
await runGh(["pr", "checkout", String(n)], wt.path);
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) {
|
|
1208
|
+
// don't strand an empty detached worktree if the checkout fails
|
|
1209
|
+
await gitRemoveWorktree(dir, wt.path, true).catch(() => { });
|
|
1210
|
+
throw err;
|
|
1211
|
+
}
|
|
1212
|
+
return void res.json({ path: wt.path, source: `PR #${n}` });
|
|
1213
|
+
}
|
|
1214
|
+
if (b.newBranch) {
|
|
1215
|
+
const wt = await gitAddWorktree(dir, {
|
|
1216
|
+
slug: b.newBranch,
|
|
1217
|
+
newBranch: String(b.newBranch),
|
|
1218
|
+
...(b.base ? { base: String(b.base) } : {}),
|
|
1219
|
+
});
|
|
1220
|
+
return void res.json({ path: wt.path, branch: wt.branch });
|
|
1221
|
+
}
|
|
1222
|
+
if (b.branch) {
|
|
1223
|
+
const wt = await gitAddWorktree(dir, { slug: String(b.branch), branch: String(b.branch) });
|
|
1224
|
+
return void res.json({ path: wt.path, branch: wt.branch });
|
|
1225
|
+
}
|
|
1226
|
+
if (b.issue) {
|
|
1227
|
+
const slug = "issue-" + Number(b.issue);
|
|
1228
|
+
const wt = await gitAddWorktree(dir, { slug, newBranch: slug });
|
|
1229
|
+
return void res.json({ path: wt.path, branch: wt.branch, source: `issue #${Number(b.issue)}` });
|
|
1230
|
+
}
|
|
1231
|
+
res.status(400).json({ error: "say which: pr, issue, branch, or newBranch" });
|
|
1232
|
+
}));
|
|
1233
|
+
app.delete("/api/projects/:id/worktrees", projectRoute(async (dir, req, res) => {
|
|
1234
|
+
const wtPath = String((req.body ?? {}).path ?? req.query.path ?? "");
|
|
1235
|
+
if (!wtPath)
|
|
1236
|
+
return void res.status(400).json({ error: "which worktree? pass its path" });
|
|
1237
|
+
await gitRemoveWorktree(dir, wtPath, Boolean((req.body ?? {}).force));
|
|
1238
|
+
res.json({ removed: wtPath });
|
|
1239
|
+
}));
|
|
1240
|
+
// ---- Linear — teams + create issue, through the user's own key ----------
|
|
1241
|
+
app.get("/api/projects/:id/linear/teams", projectRoute(async (_dir, _req, res) => {
|
|
1242
|
+
res.json(await linearTeams());
|
|
1243
|
+
}));
|
|
1244
|
+
app.get("/api/projects/:id/linear/issues", projectRoute(async (_dir, req, res) => {
|
|
1245
|
+
res.json(await listLinearIssues(req.query.team ? String(req.query.team) : undefined));
|
|
1246
|
+
}));
|
|
1247
|
+
app.post("/api/projects/:id/linear/issues", projectRoute(async (_dir, req, res) => {
|
|
1248
|
+
const { teamId, title, description } = (req.body ?? {});
|
|
1249
|
+
const result = await linearCreateIssue({
|
|
1250
|
+
teamId: teamId ?? "",
|
|
1251
|
+
title: title ?? "",
|
|
1252
|
+
...(description ? { description } : {}),
|
|
1253
|
+
});
|
|
1254
|
+
if (result.available)
|
|
1255
|
+
return void res.json(result);
|
|
1256
|
+
res.status(400).json({ error: result.detail });
|
|
1257
|
+
}));
|
|
1258
|
+
// Explorer: list a directory, read a file, search filenames. All strictly
|
|
1259
|
+
// sandboxed to the project directory (no traversal outside it).
|
|
1260
|
+
const contains = (base, target) => target === base || target.startsWith(base + path.sep);
|
|
1261
|
+
/**
|
|
1262
|
+
* Resolve a project-relative path, or null if it escapes the project.
|
|
1263
|
+
* Two checks, because they catch different attacks: the lexical one stops
|
|
1264
|
+
* `../` traversal (and works for paths that don't exist yet), and the
|
|
1265
|
+
* realpath one stops a symlink *inside* the project from pointing out of
|
|
1266
|
+
* it — path.resolve happily resolves through links.
|
|
1267
|
+
*/
|
|
1268
|
+
const projectPath = (id, rel) => {
|
|
1269
|
+
const info = findProject(id);
|
|
1270
|
+
if (!info)
|
|
1271
|
+
return null;
|
|
1272
|
+
let base;
|
|
1273
|
+
try {
|
|
1274
|
+
base = fs.realpathSync(path.resolve(info.dir));
|
|
1275
|
+
}
|
|
1276
|
+
catch {
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
const target = path.resolve(base, rel ?? ".");
|
|
1280
|
+
if (!contains(base, target))
|
|
1281
|
+
return null;
|
|
1282
|
+
try {
|
|
1283
|
+
if (!contains(base, fs.realpathSync(target)))
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
// doesn't exist — the lexical check above is the whole answer
|
|
1288
|
+
}
|
|
1289
|
+
return target;
|
|
1290
|
+
};
|
|
1291
|
+
const HIDE_DIRS = new Set([".git", "node_modules", "dist", "build", ".next", ".cache", "coverage"]);
|
|
1292
|
+
app.get("/api/projects/:id/files", (req, res) => {
|
|
1293
|
+
const dir = projectPath(String(req.params.id), req.query.dir ? String(req.query.dir) : ".");
|
|
1294
|
+
if (!dir)
|
|
1295
|
+
return void res.status(404).json({ error: "not found" });
|
|
1296
|
+
const base = projectPath(String(req.params.id), ".");
|
|
1297
|
+
fs.readdir(dir, { withFileTypes: true }, (err, ents) => {
|
|
1298
|
+
if (err)
|
|
1299
|
+
return void res.status(400).json({ error: err.message });
|
|
1300
|
+
const entries = ents
|
|
1301
|
+
.filter((e) => e.name !== ".git")
|
|
1302
|
+
.map((e) => ({
|
|
1303
|
+
name: e.name,
|
|
1304
|
+
path: path.relative(base, path.join(dir, e.name)),
|
|
1305
|
+
dir: e.isDirectory(),
|
|
1306
|
+
}))
|
|
1307
|
+
.sort((a, b) => a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1)
|
|
1308
|
+
.slice(0, 500);
|
|
1309
|
+
res.json({ dir: path.relative(base, dir), entries });
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
app.get("/api/projects/:id/file", (req, res) => {
|
|
1313
|
+
const file = projectPath(String(req.params.id), req.query.path ? String(req.query.path) : "");
|
|
1314
|
+
if (!file)
|
|
1315
|
+
return void res.status(404).json({ error: "not found" });
|
|
1316
|
+
fs.stat(file, (err, st) => {
|
|
1317
|
+
if (err)
|
|
1318
|
+
return void res.status(400).json({ error: err.message });
|
|
1319
|
+
if (st.isDirectory())
|
|
1320
|
+
return void res.status(400).json({ error: "is a directory" });
|
|
1321
|
+
const MAX = 400_000;
|
|
1322
|
+
const truncated = st.size > MAX;
|
|
1323
|
+
const stream = fs.createReadStream(file, { start: 0, end: Math.min(st.size, MAX) - 1, encoding: "utf8" });
|
|
1324
|
+
let content = "";
|
|
1325
|
+
stream.on("data", (c) => (content += c));
|
|
1326
|
+
stream.on("error", (e) => res.status(400).json({ error: e.message }));
|
|
1327
|
+
stream.on("end", () => {
|
|
1328
|
+
const base = projectPath(String(req.params.id), ".");
|
|
1329
|
+
res.json({ path: path.relative(base, file), content, truncated, size: st.size });
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
app.get("/api/projects/:id/find", (req, res) => {
|
|
1334
|
+
const base = projectPath(String(req.params.id), ".");
|
|
1335
|
+
if (!base)
|
|
1336
|
+
return void res.status(404).json({ error: "not found" });
|
|
1337
|
+
const q = String(req.query.q ?? "").trim().toLowerCase();
|
|
1338
|
+
if (!q)
|
|
1339
|
+
return void res.json({ matches: [] });
|
|
1340
|
+
const matches = [];
|
|
1341
|
+
let visited = 0;
|
|
1342
|
+
const walk = (dir) => {
|
|
1343
|
+
if (matches.length >= 200 || visited >= 20_000)
|
|
1344
|
+
return;
|
|
1345
|
+
let ents;
|
|
1346
|
+
try {
|
|
1347
|
+
ents = fs.readdirSync(dir, { withFileTypes: true });
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
for (const e of ents) {
|
|
1353
|
+
if (matches.length >= 200 || visited >= 20_000)
|
|
1354
|
+
return;
|
|
1355
|
+
visited++;
|
|
1356
|
+
if (e.isDirectory()) {
|
|
1357
|
+
if (HIDE_DIRS.has(e.name))
|
|
1358
|
+
continue;
|
|
1359
|
+
walk(path.join(dir, e.name));
|
|
1360
|
+
}
|
|
1361
|
+
else if (e.name.toLowerCase().includes(q)) {
|
|
1362
|
+
matches.push(path.relative(base, path.join(dir, e.name)));
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
walk(base);
|
|
1367
|
+
res.json({ matches });
|
|
1368
|
+
});
|
|
1369
|
+
/**
|
|
1370
|
+
* Stash a pasted image or dropped file, and hand back its path.
|
|
1371
|
+
*
|
|
1372
|
+
* The CLIs Loom drives take text and nothing else — SendInput is { text,
|
|
1373
|
+
* briefing }, no image channel. So the only honest way to "attach" an image
|
|
1374
|
+
* is to write it somewhere the agent can read and reference the path in the
|
|
1375
|
+
* message. Claude Code and Codex both read image files by path; for the
|
|
1376
|
+
* others it's at least a real artifact on disk rather than a lie in the UI.
|
|
1377
|
+
*
|
|
1378
|
+
* Under .loom/attachments/ so it's inside the project (the agent's cwd) but
|
|
1379
|
+
* out of the way. Name is derived from a content hash, never from the
|
|
1380
|
+
* client's — a caller doesn't get to choose where in the tree this lands.
|
|
1381
|
+
*/
|
|
1382
|
+
app.post("/api/projects/:id/attachments", (req, res) => {
|
|
1383
|
+
const base = projectPath(String(req.params.id), ".");
|
|
1384
|
+
if (!base)
|
|
1385
|
+
return void res.status(404).json({ error: "not found" });
|
|
1386
|
+
const { name, dataUrl } = (req.body ?? {});
|
|
1387
|
+
const m = /^data:([\w/+.-]+);base64,(.+)$/s.exec(dataUrl ?? "");
|
|
1388
|
+
if (!m)
|
|
1389
|
+
return void res.status(400).json({ error: "expected a base64 data URL" });
|
|
1390
|
+
const mime = m[1] ?? "application/octet-stream";
|
|
1391
|
+
const buf = Buffer.from(m[2] ?? "", "base64");
|
|
1392
|
+
const MAX = 12 * 1024 * 1024;
|
|
1393
|
+
if (buf.length > MAX)
|
|
1394
|
+
return void res.status(413).json({ error: "attachment over 12MB" });
|
|
1395
|
+
// Extension from the declared type or the client's name, whichever we
|
|
1396
|
+
// trust more — but only ever the extension, never the path.
|
|
1397
|
+
const extFromName = typeof name === "string" ? path.extname(name).replace(/[^.\w]/g, "").slice(0, 8) : "";
|
|
1398
|
+
const ext = extFromName || "." + (MIME_EXT[mime] ?? "bin");
|
|
1399
|
+
const hash = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 12);
|
|
1400
|
+
const rel = path.join(".loom", "attachments", hash + ext);
|
|
1401
|
+
const abs = projectPath(String(req.params.id), rel);
|
|
1402
|
+
if (!abs)
|
|
1403
|
+
return void res.status(400).json({ error: "bad attachment path" });
|
|
1404
|
+
try {
|
|
1405
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
1406
|
+
fs.writeFileSync(abs, buf);
|
|
1407
|
+
}
|
|
1408
|
+
catch (err) {
|
|
1409
|
+
return void res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
1410
|
+
}
|
|
1411
|
+
res.json({ path: rel, bytes: buf.length, mime });
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
// -------------------------------------------------------------------------
|
|
1415
|
+
// Runtimes & event fan-out
|
|
1416
|
+
// -------------------------------------------------------------------------
|
|
1417
|
+
async runtime(idOrName) {
|
|
1418
|
+
const info = findProject(idOrName);
|
|
1419
|
+
if (!info)
|
|
1420
|
+
throw new Error(`unknown project "${idOrName}" — run loom init first`);
|
|
1421
|
+
const existing = this.runtimes.get(info.id);
|
|
1422
|
+
if (existing) {
|
|
1423
|
+
// Hot-reload edited .loom/config.json once the project is quiet.
|
|
1424
|
+
if (existing.configStale() && !existing.anyBusy()) {
|
|
1425
|
+
await existing.close();
|
|
1426
|
+
this.runtimes.delete(info.id);
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
return existing;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const rt = await ProjectRuntime.open(info);
|
|
1433
|
+
rt.log.onEvent((e) => this.broadcast(info.id, e));
|
|
1434
|
+
this.runtimes.set(info.id, rt);
|
|
1435
|
+
return rt;
|
|
1436
|
+
}
|
|
1437
|
+
broadcast(projectId, event) {
|
|
1438
|
+
const frame = JSON.stringify({ type: "event", projectId, event });
|
|
1439
|
+
for (const [ws, sub] of this.sockets) {
|
|
1440
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
1441
|
+
continue;
|
|
1442
|
+
if (sub.project && sub.project !== projectId)
|
|
1443
|
+
continue;
|
|
1444
|
+
ws.send(frame);
|
|
1445
|
+
}
|
|
1446
|
+
// An agent's error is a thread event AND a log line. The thread shows it to
|
|
1447
|
+
// whoever is reading that conversation; the Console shows it to whoever is
|
|
1448
|
+
// wondering why nothing happened. Those are often the same person and never
|
|
1449
|
+
// the same moment.
|
|
1450
|
+
if (event.kind === "error") {
|
|
1451
|
+
logbook.error(event.agentId ? `agent:${event.agentId}` : "project", String(event.payload.message ?? "agent error"), event.payload.stderr ?? event.payload.detail, projectId);
|
|
1452
|
+
}
|
|
1453
|
+
this.maybePush(projectId, event);
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Push every log record to every connected client.
|
|
1457
|
+
*
|
|
1458
|
+
* Not per-project: a daemon-level fault (a crash guard firing, a bad route)
|
|
1459
|
+
* has no project, and it's exactly the one you most need to see. The Console
|
|
1460
|
+
* filters; the wire doesn't.
|
|
1461
|
+
*/
|
|
1462
|
+
streamLogs() {
|
|
1463
|
+
return logbook.subscribe((record) => {
|
|
1464
|
+
const frame = JSON.stringify({ type: "log", record });
|
|
1465
|
+
for (const [ws] of this.sockets) {
|
|
1466
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
1467
|
+
ws.send(frame);
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
/** Fan a terminal frame out to every socket watching this project. */
|
|
1472
|
+
broadcastTerm(projectId, frame) {
|
|
1473
|
+
const payload = JSON.stringify(frame);
|
|
1474
|
+
for (const [ws, sub] of this.sockets) {
|
|
1475
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
1476
|
+
continue;
|
|
1477
|
+
if (sub.project && sub.project !== projectId)
|
|
1478
|
+
continue;
|
|
1479
|
+
ws.send(payload);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
pushTokens() {
|
|
1483
|
+
const cfg = readDaemonConfig();
|
|
1484
|
+
return (cfg?.clients ?? [])
|
|
1485
|
+
.map((c) => c.pushToken)
|
|
1486
|
+
.filter((t) => Boolean(t));
|
|
1487
|
+
}
|
|
1488
|
+
/** Fire-and-notify to phones. Route hops stay quiet; the outcome pushes. */
|
|
1489
|
+
maybePush(projectId, event) {
|
|
1490
|
+
if (!PUSH_KINDS.has(event.kind))
|
|
1491
|
+
return;
|
|
1492
|
+
if (event.kind === "run_complete" && this.runtimes.get(projectId)?.routes.isActive()) {
|
|
1493
|
+
return; // a pipeline in flight buzzes once at the end, not per hop
|
|
1494
|
+
}
|
|
1495
|
+
const tokens = this.pushTokens();
|
|
1496
|
+
if (!tokens.length)
|
|
1497
|
+
return;
|
|
1498
|
+
const name = listProjects().find((p) => p.id === projectId)?.name ?? "project";
|
|
1499
|
+
void sendExpoPush(tokens, {
|
|
1500
|
+
...pushContent(name, event),
|
|
1501
|
+
data: { projectId, kind: event.kind },
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
// -------------------------------------------------------------------------
|
|
1505
|
+
// Lifecycle
|
|
1506
|
+
// -------------------------------------------------------------------------
|
|
1507
|
+
async listen(opts = {}) {
|
|
1508
|
+
if (opts.tailnet) {
|
|
1509
|
+
this.host = await tailscaleIp();
|
|
1510
|
+
}
|
|
1511
|
+
await new Promise((resolve, reject) => {
|
|
1512
|
+
// Use the `listening` *event*, not the listen() callback: Express fires the
|
|
1513
|
+
// callback even when the bind fails with EADDRINUSE, which would otherwise
|
|
1514
|
+
// resolve this as a phantom success — a daemon that prints "listening" and
|
|
1515
|
+
// exits 0 while another process actually holds the port.
|
|
1516
|
+
const server = this.app.listen(this.port, this.host);
|
|
1517
|
+
this.server = server;
|
|
1518
|
+
let settled = false;
|
|
1519
|
+
server.once("error", (err) => {
|
|
1520
|
+
if (!settled) {
|
|
1521
|
+
settled = true;
|
|
1522
|
+
reject(err);
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
server.once("listening", () => {
|
|
1526
|
+
if (!settled) {
|
|
1527
|
+
settled = true;
|
|
1528
|
+
resolve();
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
});
|
|
1532
|
+
const addr = this.server.address();
|
|
1533
|
+
if (addr && typeof addr === "object")
|
|
1534
|
+
this.port = addr.port; // ephemeral port support
|
|
1535
|
+
this.wss = this.attachWs(this.server);
|
|
1536
|
+
// Fan every log record out to connected clients (the Console tab).
|
|
1537
|
+
this.unstreamLogs = this.streamLogs();
|
|
1538
|
+
this.writeConfig();
|
|
1539
|
+
return { host: this.host, port: this.port };
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Attach a WebSocket server (path /ws) to an HTTP server and wire the
|
|
1543
|
+
* connection handler. Returned so extra phone-access listeners can track and
|
|
1544
|
+
* later close their own; all sockets land in the one shared `this.sockets`.
|
|
1545
|
+
*/
|
|
1546
|
+
attachWs(server) {
|
|
1547
|
+
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
1548
|
+
wss.on("connection", (ws, req) => {
|
|
1549
|
+
const url = new URL(req.url ?? "/ws", `http://${this.host}:${this.port}`);
|
|
1550
|
+
// Prefer the token in the `Sec-WebSocket-Protocol` header (a header isn't
|
|
1551
|
+
// written to browser history or a proxy's request-line log the way a
|
|
1552
|
+
// `?token=` query is); fall back to the query for the CLI/native clients.
|
|
1553
|
+
const sub = req.headers["sec-websocket-protocol"];
|
|
1554
|
+
const fromHeader = sub
|
|
1555
|
+
? sub.split(",").map((s) => s.trim()).find((s) => s.startsWith("loom.bearer."))?.slice("loom.bearer.".length)
|
|
1556
|
+
: undefined;
|
|
1557
|
+
const token = fromHeader ?? url.searchParams.get("token") ?? undefined;
|
|
1558
|
+
this.auth.reload(); // pick up freshly paired clients
|
|
1559
|
+
if (!this.auth.isAuthorized(token)) {
|
|
1560
|
+
ws.close(4401, "unauthorized");
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
const project = url.searchParams.get("project") ?? undefined;
|
|
1564
|
+
let resolvedProject;
|
|
1565
|
+
if (project) {
|
|
1566
|
+
resolvedProject = findProject(project)?.id ?? project;
|
|
1567
|
+
// Ensure the runtime is live so its events flow.
|
|
1568
|
+
void this.runtime(project).catch(() => { });
|
|
1569
|
+
}
|
|
1570
|
+
this.sockets.set(ws, { ...(resolvedProject ? { project: resolvedProject } : {}) });
|
|
1571
|
+
ws.send(JSON.stringify({
|
|
1572
|
+
type: "hello",
|
|
1573
|
+
projects: listProjects().map((p) => p.id),
|
|
1574
|
+
terminal: this.terminals.mode,
|
|
1575
|
+
}));
|
|
1576
|
+
// Terminal input comes back up this socket: a tty needs a round-trip per
|
|
1577
|
+
// keystroke, which a POST each time can't carry. Only a socket scoped to
|
|
1578
|
+
// a project may drive that project's terminals.
|
|
1579
|
+
ws.on("message", (raw) => {
|
|
1580
|
+
let msg;
|
|
1581
|
+
try {
|
|
1582
|
+
msg = JSON.parse(String(raw));
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
if (!resolvedProject || !msg.term)
|
|
1588
|
+
return;
|
|
1589
|
+
const sess = this.terminals.get(resolvedProject, String(msg.term));
|
|
1590
|
+
if (!sess)
|
|
1591
|
+
return;
|
|
1592
|
+
if (msg.type === "term-input" && typeof msg.data === "string")
|
|
1593
|
+
sess.write(msg.data);
|
|
1594
|
+
else if (msg.type === "term-resize") {
|
|
1595
|
+
sess.resize(Number(msg.cols) || 80, Number(msg.rows) || 24);
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
ws.on("close", () => this.sockets.delete(ws));
|
|
1599
|
+
});
|
|
1600
|
+
return wss;
|
|
1601
|
+
}
|
|
1602
|
+
/** Record where we actually bound so CLIs can find us. */
|
|
1603
|
+
writeConfig() {
|
|
1604
|
+
const cfg = ensureDaemonConfig({ host: this.host, port: this.port });
|
|
1605
|
+
cfg.host = this.host;
|
|
1606
|
+
cfg.port = this.port;
|
|
1607
|
+
cfg.pid = process.pid;
|
|
1608
|
+
writeDaemonConfig(cfg);
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Make this daemon reachable at a specific address — a LAN or tailnet IP — by
|
|
1612
|
+
* adding a *second* listener on that IP and the same port. The localhost
|
|
1613
|
+
* listener is never touched: no teardown, no dropped sockets, no window where
|
|
1614
|
+
* the web app you are looking at goes away, and none of the EADDRINUSE races a
|
|
1615
|
+
* single-socket rebind to 0.0.0.0 hit while the browser held the port open.
|
|
1616
|
+
* Two distinct IPs on one port coexist fine. Idempotent.
|
|
1617
|
+
*/
|
|
1618
|
+
async expose(ip) {
|
|
1619
|
+
if (!ip || ip === this.host || this.extra.has(ip))
|
|
1620
|
+
return;
|
|
1621
|
+
const server = http.createServer(this.app);
|
|
1622
|
+
await new Promise((resolve, reject) => {
|
|
1623
|
+
server.listen(this.port, ip, () => resolve());
|
|
1624
|
+
server.on("error", reject);
|
|
1625
|
+
});
|
|
1626
|
+
const wss = this.attachWs(server);
|
|
1627
|
+
this.extra.set(ip, { server, wss });
|
|
1628
|
+
logbook.info("daemon", `also listening on ${ip}:${this.port} for phone access`);
|
|
1629
|
+
}
|
|
1630
|
+
/** Extra addresses (LAN/tailnet) a phone can reach us on right now. */
|
|
1631
|
+
exposedIps() {
|
|
1632
|
+
return [...this.extra.keys()];
|
|
1633
|
+
}
|
|
1634
|
+
async close() {
|
|
1635
|
+
this.unstreamLogs?.();
|
|
1636
|
+
this.unstreamLogs = null;
|
|
1637
|
+
this.terminals.closeAll();
|
|
1638
|
+
for (const rt of this.runtimes.values())
|
|
1639
|
+
await rt.close();
|
|
1640
|
+
this.runtimes.clear();
|
|
1641
|
+
for (const { server, wss } of this.extra.values()) {
|
|
1642
|
+
wss.close();
|
|
1643
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1644
|
+
}
|
|
1645
|
+
this.extra.clear();
|
|
1646
|
+
this.wss?.close();
|
|
1647
|
+
await new Promise((resolve) => {
|
|
1648
|
+
if (!this.server)
|
|
1649
|
+
return resolve();
|
|
1650
|
+
this.server.close(() => resolve());
|
|
1651
|
+
});
|
|
1652
|
+
const cfg = readDaemonConfig();
|
|
1653
|
+
if (cfg && cfg.pid === process.pid) {
|
|
1654
|
+
delete cfg.pid;
|
|
1655
|
+
writeDaemonConfig(cfg);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
/** Resolve this machine's Tailscale IPv4 — the tailnet is the trust boundary. */
|
|
1660
|
+
export function tailscaleIp() {
|
|
1661
|
+
return new Promise((resolve, reject) => {
|
|
1662
|
+
execFile("tailscale", ["ip", "-4"], (err, stdout) => {
|
|
1663
|
+
if (err) {
|
|
1664
|
+
reject(new Error("could not resolve a Tailscale IP (is tailscale installed and up?) — refusing to bind beyond localhost"));
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const ip = stdout.trim().split("\n")[0];
|
|
1668
|
+
if (!ip)
|
|
1669
|
+
return void reject(new Error("tailscale returned no IPv4"));
|
|
1670
|
+
resolve(ip);
|
|
1671
|
+
});
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* This machine's LAN IPv4 — the address a phone on the same Wi-Fi uses. We skip
|
|
1676
|
+
* loopback, link-local (169.254), and Tailscale's own 100.64/10 CGNAT range so
|
|
1677
|
+
* "local network" and "tailnet" stay distinct choices. Returns null when the
|
|
1678
|
+
* only addresses are loopback (e.g. no network) — the caller says so honestly
|
|
1679
|
+
* rather than minting an unreachable QR.
|
|
1680
|
+
*/
|
|
1681
|
+
export function lanIp() {
|
|
1682
|
+
const ifaces = os.networkInterfaces();
|
|
1683
|
+
const candidates = [];
|
|
1684
|
+
for (const addrs of Object.values(ifaces)) {
|
|
1685
|
+
for (const a of addrs ?? []) {
|
|
1686
|
+
if (a.family !== "IPv4" || a.internal)
|
|
1687
|
+
continue;
|
|
1688
|
+
if (a.address.startsWith("169.254."))
|
|
1689
|
+
continue; // link-local, not routable
|
|
1690
|
+
if (a.address.startsWith("100."))
|
|
1691
|
+
continue; // Tailscale CGNAT — that's the tailnet
|
|
1692
|
+
candidates.push(a.address);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
// Prefer the common private ranges (a real LAN) over anything exotic.
|
|
1696
|
+
const priv = candidates.find((ip) => ip.startsWith("192.168.") || ip.startsWith("10.") || /^172\.(1[6-9]|2\d|3[01])\./.test(ip));
|
|
1697
|
+
return priv ?? candidates[0] ?? null;
|
|
1698
|
+
}
|
|
1699
|
+
/** Is this connection from the same machine? (127.0.0.1, ::1, or v4-mapped v6.) */
|
|
1700
|
+
export function isLoopback(remoteAddress) {
|
|
1701
|
+
if (!remoteAddress)
|
|
1702
|
+
return false;
|
|
1703
|
+
return (remoteAddress === "127.0.0.1" ||
|
|
1704
|
+
remoteAddress === "::1" ||
|
|
1705
|
+
remoteAddress === "::ffff:127.0.0.1");
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Is the request's `Host` header a loopback literal? The anti-DNS-rebinding
|
|
1709
|
+
* check: a rebinding attacker loads a page from their own domain, so the browser
|
|
1710
|
+
* sends `Host: attacker.example` even after the name rebinds to 127.0.0.1 — while
|
|
1711
|
+
* the genuine local console is always reached at `127.0.0.1`/`localhost`. Pairing
|
|
1712
|
+
* the socket check with this closes the "any website → localhost token oracle"
|
|
1713
|
+
* path. The port is ignored; only the host is checked.
|
|
1714
|
+
*/
|
|
1715
|
+
export function isLoopbackHost(hostHeader) {
|
|
1716
|
+
if (!hostHeader)
|
|
1717
|
+
return false;
|
|
1718
|
+
let host = hostHeader.trim().toLowerCase();
|
|
1719
|
+
if (host.startsWith("[")) {
|
|
1720
|
+
const end = host.indexOf("]");
|
|
1721
|
+
host = end > 0 ? host.slice(1, end) : host.slice(1);
|
|
1722
|
+
}
|
|
1723
|
+
else if ((host.match(/:/g) || []).length === 1) {
|
|
1724
|
+
host = host.slice(0, host.indexOf(":")); // strip the port off host:port
|
|
1725
|
+
}
|
|
1726
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
1727
|
+
}
|
|
1728
|
+
//# sourceMappingURL=server.js.map
|