@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.
Files changed (177) hide show
  1. package/ARCHITECTURE.md +211 -0
  2. package/CHANGELOG.md +470 -0
  3. package/LICENSE +21 -0
  4. package/README.md +630 -0
  5. package/dist/adapters/base.d.ts +71 -0
  6. package/dist/adapters/base.js +187 -0
  7. package/dist/adapters/base.js.map +1 -0
  8. package/dist/adapters/bridges/antigravity.d.ts +53 -0
  9. package/dist/adapters/bridges/antigravity.js +106 -0
  10. package/dist/adapters/bridges/antigravity.js.map +1 -0
  11. package/dist/adapters/bridges/cdp.d.ts +82 -0
  12. package/dist/adapters/bridges/cdp.js +222 -0
  13. package/dist/adapters/bridges/cdp.js.map +1 -0
  14. package/dist/adapters/bridges/gui-chat.d.ts +113 -0
  15. package/dist/adapters/bridges/gui-chat.js +498 -0
  16. package/dist/adapters/bridges/gui-chat.js.map +1 -0
  17. package/dist/adapters/bridges/kiro.d.ts +19 -0
  18. package/dist/adapters/bridges/kiro.js +22 -0
  19. package/dist/adapters/bridges/kiro.js.map +1 -0
  20. package/dist/adapters/bridges/profiles.d.ts +82 -0
  21. package/dist/adapters/bridges/profiles.js +125 -0
  22. package/dist/adapters/bridges/profiles.js.map +1 -0
  23. package/dist/adapters/claude-code.d.ts +26 -0
  24. package/dist/adapters/claude-code.js +209 -0
  25. package/dist/adapters/claude-code.js.map +1 -0
  26. package/dist/adapters/codex.d.ts +51 -0
  27. package/dist/adapters/codex.js +291 -0
  28. package/dist/adapters/codex.js.map +1 -0
  29. package/dist/adapters/echo.d.ts +20 -0
  30. package/dist/adapters/echo.js +79 -0
  31. package/dist/adapters/echo.js.map +1 -0
  32. package/dist/adapters/grok.d.ts +61 -0
  33. package/dist/adapters/grok.js +217 -0
  34. package/dist/adapters/grok.js.map +1 -0
  35. package/dist/adapters/index.d.ts +9 -0
  36. package/dist/adapters/index.js +33 -0
  37. package/dist/adapters/index.js.map +1 -0
  38. package/dist/adapters/opencode.d.ts +64 -0
  39. package/dist/adapters/opencode.js +426 -0
  40. package/dist/adapters/opencode.js.map +1 -0
  41. package/dist/cli/common.d.ts +11 -0
  42. package/dist/cli/common.js +40 -0
  43. package/dist/cli/common.js.map +1 -0
  44. package/dist/cli/doctor.d.ts +12 -0
  45. package/dist/cli/doctor.js +181 -0
  46. package/dist/cli/doctor.js.map +1 -0
  47. package/dist/cli/index.d.ts +8 -0
  48. package/dist/cli/index.js +641 -0
  49. package/dist/cli/index.js.map +1 -0
  50. package/dist/cli/tui-model.d.ts +73 -0
  51. package/dist/cli/tui-model.js +312 -0
  52. package/dist/cli/tui-model.js.map +1 -0
  53. package/dist/cli/tui.d.ts +15 -0
  54. package/dist/cli/tui.js +451 -0
  55. package/dist/cli/tui.js.map +1 -0
  56. package/dist/cli/ui.d.ts +10 -0
  57. package/dist/cli/ui.js +135 -0
  58. package/dist/cli/ui.js.map +1 -0
  59. package/dist/core/ades.d.ts +79 -0
  60. package/dist/core/ades.js +130 -0
  61. package/dist/core/ades.js.map +1 -0
  62. package/dist/core/baton.d.ts +37 -0
  63. package/dist/core/baton.js +92 -0
  64. package/dist/core/baton.js.map +1 -0
  65. package/dist/core/brain-extract.d.ts +111 -0
  66. package/dist/core/brain-extract.js +248 -0
  67. package/dist/core/brain-extract.js.map +1 -0
  68. package/dist/core/brain-index.d.ts +128 -0
  69. package/dist/core/brain-index.js +338 -0
  70. package/dist/core/brain-index.js.map +1 -0
  71. package/dist/core/brain.d.ts +214 -0
  72. package/dist/core/brain.js +425 -0
  73. package/dist/core/brain.js.map +1 -0
  74. package/dist/core/claude-cli.d.ts +12 -0
  75. package/dist/core/claude-cli.js +47 -0
  76. package/dist/core/claude-cli.js.map +1 -0
  77. package/dist/core/distill.d.ts +19 -0
  78. package/dist/core/distill.js +88 -0
  79. package/dist/core/distill.js.map +1 -0
  80. package/dist/core/eventlog.d.ts +43 -0
  81. package/dist/core/eventlog.js +211 -0
  82. package/dist/core/eventlog.js.map +1 -0
  83. package/dist/core/git.d.ts +203 -0
  84. package/dist/core/git.js +460 -0
  85. package/dist/core/git.js.map +1 -0
  86. package/dist/core/logbook.d.ts +59 -0
  87. package/dist/core/logbook.js +118 -0
  88. package/dist/core/logbook.js.map +1 -0
  89. package/dist/core/memory.d.ts +34 -0
  90. package/dist/core/memory.js +124 -0
  91. package/dist/core/memory.js.map +1 -0
  92. package/dist/core/notify.d.ts +11 -0
  93. package/dist/core/notify.js +30 -0
  94. package/dist/core/notify.js.map +1 -0
  95. package/dist/core/projection.d.ts +20 -0
  96. package/dist/core/projection.js +96 -0
  97. package/dist/core/projection.js.map +1 -0
  98. package/dist/core/registry.d.ts +76 -0
  99. package/dist/core/registry.js +147 -0
  100. package/dist/core/registry.js.map +1 -0
  101. package/dist/core/router.d.ts +42 -0
  102. package/dist/core/router.js +99 -0
  103. package/dist/core/router.js.map +1 -0
  104. package/dist/core/routes.d.ts +93 -0
  105. package/dist/core/routes.js +399 -0
  106. package/dist/core/routes.js.map +1 -0
  107. package/dist/core/search.d.ts +63 -0
  108. package/dist/core/search.js +0 -0
  109. package/dist/core/search.js.map +1 -0
  110. package/dist/core/setup.d.ts +70 -0
  111. package/dist/core/setup.js +250 -0
  112. package/dist/core/setup.js.map +1 -0
  113. package/dist/core/suggestions.d.ts +15 -0
  114. package/dist/core/suggestions.js +38 -0
  115. package/dist/core/suggestions.js.map +1 -0
  116. package/dist/core/worktree.d.ts +32 -0
  117. package/dist/core/worktree.js +83 -0
  118. package/dist/core/worktree.js.map +1 -0
  119. package/dist/daemon/app-page.d.ts +32 -0
  120. package/dist/daemon/app-page.js +6363 -0
  121. package/dist/daemon/app-page.js.map +1 -0
  122. package/dist/daemon/auth.d.ts +51 -0
  123. package/dist/daemon/auth.js +135 -0
  124. package/dist/daemon/auth.js.map +1 -0
  125. package/dist/daemon/board.d.ts +117 -0
  126. package/dist/daemon/board.js +202 -0
  127. package/dist/daemon/board.js.map +1 -0
  128. package/dist/daemon/brand-icons.d.ts +29 -0
  129. package/dist/daemon/brand-icons.js +44 -0
  130. package/dist/daemon/brand-icons.js.map +1 -0
  131. package/dist/daemon/client.d.ts +127 -0
  132. package/dist/daemon/client.js +237 -0
  133. package/dist/daemon/client.js.map +1 -0
  134. package/dist/daemon/geist-font.d.ts +10 -0
  135. package/dist/daemon/geist-font.js +783 -0
  136. package/dist/daemon/geist-font.js.map +1 -0
  137. package/dist/daemon/guards.d.ts +18 -0
  138. package/dist/daemon/guards.js +40 -0
  139. package/dist/daemon/guards.js.map +1 -0
  140. package/dist/daemon/linear.d.ts +59 -0
  141. package/dist/daemon/linear.js +117 -0
  142. package/dist/daemon/linear.js.map +1 -0
  143. package/dist/daemon/push.d.ts +22 -0
  144. package/dist/daemon/push.js +58 -0
  145. package/dist/daemon/push.js.map +1 -0
  146. package/dist/daemon/runtime.d.ts +299 -0
  147. package/dist/daemon/runtime.js +1016 -0
  148. package/dist/daemon/runtime.js.map +1 -0
  149. package/dist/daemon/server.d.ts +125 -0
  150. package/dist/daemon/server.js +1728 -0
  151. package/dist/daemon/server.js.map +1 -0
  152. package/dist/daemon/tasks.d.ts +153 -0
  153. package/dist/daemon/tasks.js +264 -0
  154. package/dist/daemon/tasks.js.map +1 -0
  155. package/dist/daemon/terminals.d.ts +82 -0
  156. package/dist/daemon/terminals.js +372 -0
  157. package/dist/daemon/terminals.js.map +1 -0
  158. package/dist/index.d.ts +20 -0
  159. package/dist/index.js +20 -0
  160. package/dist/index.js.map +1 -0
  161. package/dist/sdk.d.ts +17 -0
  162. package/dist/sdk.js +17 -0
  163. package/dist/sdk.js.map +1 -0
  164. package/dist/types.d.ts +236 -0
  165. package/dist/types.js +18 -0
  166. package/dist/types.js.map +1 -0
  167. package/docs/SETUP.md +265 -0
  168. package/docs/adapters.md +132 -0
  169. package/docs/design-system.md +245 -0
  170. package/docs/img/board-linear.png +0 -0
  171. package/docs/img/board-projects.png +0 -0
  172. package/docs/img/brain.png +0 -0
  173. package/docs/img/settings.png +0 -0
  174. package/docs/img/workspace.png +0 -0
  175. package/docs/integration-notes.md +76 -0
  176. package/docs/proposals/brain.md +262 -0
  177. 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