loom-agent 1.2.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 (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,470 @@
1
+ // loom web — browser interface for Loom. A single Node `http` server (no build
2
+ // step, no framework): serves a small single-page UI, lists saved sessions,
3
+ // and lets you chat from the browser by driving the same core Session loop the
4
+ // TUI/ACP use. Streaming is plain Server-Sent Events.
5
+ //
6
+ // loom web # 127.0.0.1, random port, opens the browser
7
+ // loom web --port 4096 # fixed port
8
+ // loom web --hostname 0.0.0.0 # reachable on the LAN
9
+ // loom web --mdns # advertise as loom.local (implies 0.0.0.0)
10
+ // loom web --mdns-domain proj.local
11
+ // loom web --cors https://example.com
12
+ // LOOM_SERVER_PASSWORD=secret loom web # password-protect (user: LOOM_SERVER_USERNAME, default "loom")
13
+ //
14
+ // Config file: { "server": { "port": 4096, "hostname": "0.0.0.0", "mdns": true,
15
+ // "cors": ["https://example.com"] } } — CLI flags take precedence.
16
+ 'use strict';
17
+
18
+ const http = require('http');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const crypto = require('crypto');
23
+ const { Session } = require('../core/session');
24
+ const { loadConfig } = require('../config/settings');
25
+ const { listSessions, loadSession, saveSession, isValidSessionId } = require('../core/session-store');
26
+
27
+ const DEFAULT_USERNAME = 'loom';
28
+ const INDEX_FILE = path.join(__dirname, 'index.html');
29
+
30
+ // ── Option resolution: CLI flags win over config.server ────────────────
31
+
32
+ function resolveOptions(argv = []) {
33
+ const cfg = loadConfig();
34
+ const srv = (cfg && cfg.server) || {};
35
+ const at = (name) => argv.indexOf(name);
36
+ const val = (name, fb) => {
37
+ const i = at(name);
38
+ return i !== -1 && argv[i + 1] != null && !String(argv[i + 1]).startsWith('-') ? argv[i + 1] : fb;
39
+ };
40
+ const has = (name) => argv.includes(name);
41
+
42
+ let port = has('--port') ? parseInt(val('--port', '0'), 10) : (srv.port != null ? srv.port : 0);
43
+ if (Number.isNaN(port) || port < 0) port = 0;
44
+
45
+ const mdns = has('--mdns') ? true : (srv.mdns === true);
46
+ const mdnsDomain = has('--mdns-domain') ? String(val('--mdns-domain', '')).replace(/\.local$/i, '') : null;
47
+ const useMdns = mdns || mdnsDomain != null;
48
+
49
+ let hostname = has('--hostname') ? String(val('--hostname', '')) : (srv.hostname != null ? srv.hostname : '127.0.0.1');
50
+ if (useMdns && (hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '')) hostname = '0.0.0.0';
51
+
52
+ let cors = [];
53
+ const corsFlag = at('--cors');
54
+ if (corsFlag !== -1) {
55
+ cors = String(argv[corsFlag + 1] || '').split(',').filter(Boolean);
56
+ } else if (Array.isArray(srv.cors)) {
57
+ cors = srv.cors.filter((x) => typeof x === 'string');
58
+ }
59
+
60
+ return {
61
+ port,
62
+ hostname,
63
+ cors,
64
+ mdns: useMdns,
65
+ mdnsDomain: mdnsDomain || 'loom',
66
+ noOpen: has('--no-open'),
67
+ singleUser: has('--single-user') || !process.env.LOOM_SERVER_PASSWORD,
68
+ };
69
+ }
70
+
71
+ // ── LAN address discovery (for the 0.0.0.0 / mDNS banner) ──────────────
72
+
73
+ function lanAddresses() {
74
+ const out = [];
75
+ const nets = os.networkInterfaces();
76
+ for (const name of Object.keys(nets)) {
77
+ for (const net of nets[name] || []) {
78
+ if (net.family === 'IPv4' && !net.internal && net.address) out.push(net.address);
79
+ }
80
+ }
81
+ return [...new Set(out)];
82
+ }
83
+
84
+ function addressesFor(opts, port) {
85
+ const local = 'http://localhost:' + port;
86
+ if (opts.hostname === '0.0.0.0' || opts.hostname === '::') {
87
+ const lan = lanAddresses().map((a) => 'http://' + a + ':' + port);
88
+ return [{ label: 'Local access', url: local }, ...lan.map((u) => ({ label: 'Network access', url: u }))];
89
+ }
90
+ return [{ label: 'Local access', url: 'http://' + opts.hostname + ':' + port }];
91
+ }
92
+
93
+ // ── Small helpers ───────────────────────────────────────────────────────
94
+
95
+ function readBody(req, limit = 1_048_576) {
96
+ return new Promise((resolve, reject) => {
97
+ const chunks = [];
98
+ let size = 0;
99
+ req.on('data', (c) => {
100
+ size += c.length;
101
+ if (size > limit) { reject(new Error('payload too large')); req.destroy(); return; }
102
+ chunks.push(c);
103
+ });
104
+ req.on('end', () => {
105
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
106
+ try { resolve(JSON.parse(raw)); } catch { resolve({}); }
107
+ });
108
+ req.on('error', reject);
109
+ });
110
+ }
111
+
112
+ function sendJson(res, status, obj) {
113
+ const body = JSON.stringify(obj);
114
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
115
+ res.end(body);
116
+ }
117
+
118
+ function sendText(res, status, text, extra) {
119
+ const headers = Object.assign({ 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(text) }, extra || {});
120
+ res.writeHead(status, headers);
121
+ res.end(text);
122
+ }
123
+
124
+ // ── The web server ──────────────────────────────────────────────────────
125
+
126
+ function createWebServer(opts) {
127
+ const tokens = new Map(); // token -> true (in-memory auth, server-lifetime)
128
+ const password = process.env.LOOM_SERVER_PASSWORD || '';
129
+ const username = process.env.LOOM_SERVER_USERNAME || DEFAULT_USERNAME;
130
+ const hub = new Map(); // sessionId -> { session, active }
131
+ const stats = { requests: 0, sessionsCreated: 0, messagesRun: 0 };
132
+ const authFails = new Map(); // ip -> { count, lockedUntil }
133
+ const AUTH_MAX_ATTEMPTS = 5;
134
+ const AUTH_LOCKOUT_MS = 60000;
135
+
136
+ function authAttemptAllowed(ip) {
137
+ const rec = authFails.get(ip);
138
+ if (!rec) return true;
139
+ if (rec.lockedUntil > Date.now()) return false;
140
+ authFails.delete(ip);
141
+ return true;
142
+ }
143
+
144
+ function authAttemptFailed(ip) {
145
+ const rec = authFails.get(ip) || { count: 0, lockedUntil: 0 };
146
+ rec.count++;
147
+ if (rec.count >= AUTH_MAX_ATTEMPTS) {
148
+ rec.count = 0;
149
+ rec.lockedUntil = Date.now() + AUTH_LOCKOUT_MS;
150
+ }
151
+ authFails.set(ip, rec);
152
+ }
153
+
154
+ function passwordMatches(a, b) {
155
+ const ba = Buffer.from(String(a || ''));
156
+ const bb = Buffer.from(String(b || ''));
157
+ if (ba.length !== bb.length) return false;
158
+ return crypto.timingSafeEqual(ba, bb);
159
+ }
160
+
161
+ const isAllowedOrigin = (origin) => !origin || opts.cors.length === 0 || opts.cors.includes(origin) || opts.cors.includes('*');
162
+
163
+ function applyCors(req, res) {
164
+ const origin = req.headers.origin;
165
+ if (!origin || opts.cors.length === 0) return false;
166
+ if (opts.cors.includes('*')) { res.setHeader('Access-Control-Allow-Origin', '*'); }
167
+ else if (opts.cors.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); }
168
+ else { return false; }
169
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
170
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
171
+ return true;
172
+ }
173
+
174
+ function authOk(req) {
175
+ if (!password) return true;
176
+ const cookieHeader = req.headers.cookie || '';
177
+ const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(cookieHeader)?.[1]
178
+ || new URL(req.url, 'http://x').searchParams.get('token');
179
+ return !!(token && tokens.has(token));
180
+ }
181
+
182
+ function requireAuth(req, res) {
183
+ if (authOk(req)) return true;
184
+ sendJson(res, 401, { error: 'unauthorized', authRequired: true });
185
+ return false;
186
+ }
187
+
188
+ function publicPath(url) {
189
+ try { return decodeURIComponent(new URL(url, 'http://x').pathname); } catch { return url; }
190
+ }
191
+
192
+ function getWebSession(id) {
193
+ if (hub.has(id)) return hub.get(id);
194
+ const saved = (id && isValidSessionId(id)) ? loadSession(id) : null;
195
+ if (!saved) return null;
196
+ const session = new Session();
197
+ session.conversationId = id;
198
+ for (const m of saved.messages || []) {
199
+ try { session.addMessage({ role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || '') }); } catch {}
200
+ }
201
+ const entry = { session, active: false, mode: 'build' };
202
+ hub.set(id, entry);
203
+ return entry;
204
+ }
205
+
206
+ function newWebSession(mode) {
207
+ const session = new Session();
208
+ let id = 'web-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
209
+ session.conversationId = id;
210
+ try { if (mode === 'plan' || mode === 'chat' || mode === 'build') session.setMode(mode); } catch {}
211
+ const entry = { session, active: false, mode: session.mode };
212
+ hub.set(id, entry);
213
+ stats.sessionsCreated++;
214
+ return entry;
215
+ }
216
+
217
+ async function runChat(entry, text, res) {
218
+ const { session } = entry;
219
+ entry.active = true;
220
+ stats.messagesRun++;
221
+ const write = (type, payload) => { try { res.write('data: ' + JSON.stringify({ type, ...payload }) + '\n\n'); } catch {} };
222
+ const onClose = () => { if (!res.writableEnded) { try { session.interrupt(); } catch {} } };
223
+ res.on('close', onClose);
224
+ write('session.updated', { type: 'requested', id: session.conversationId });
225
+ try {
226
+ const resp = await session.sendUserMessage(text, {
227
+ onDelta: (t) => write('delta', { text: t, id: session.conversationId }),
228
+ onReasoning: (t) => write('reasoning', { text: t, id: session.conversationId }),
229
+ onTool: (toolName, input) => write('tool.use', { name: toolName, input, id: session.conversationId }),
230
+ onToolResult: (toolName, out, input) =>
231
+ write('tool.result', { name: toolName, input, result: out && out.result != null ? out.result : (out && out.error), id: session.conversationId }),
232
+ });
233
+ if (resp.interrupted) {
234
+ write('request.error', { message: '(interrupted)', id: session.conversationId });
235
+ } else if (resp.type === 'error') {
236
+ write('request.error', { message: String(resp.content || '') , id: session.conversationId });
237
+ } else {
238
+ write('message.completed', { id: session.conversationId });
239
+ write('request.completed', { response: { type: 'text', text: String(resp.content || '') }, id: session.conversationId });
240
+ }
241
+ } catch (e) {
242
+ write('request.error', { message: e && e.message ? e.message : String(e), id: session.conversationId });
243
+ } finally {
244
+ res.removeListener('close', onClose);
245
+ entry.active = false;
246
+ try { saveSession(session); } catch {}
247
+ write('session.updated', { type: 'saved', id: session.conversationId });
248
+ write('done', {});
249
+ try { res.end(); } catch {}
250
+ }
251
+ }
252
+
253
+ function handleApi(req, res) {
254
+ const url = new URL(req.url, 'http://x');
255
+ const p = publicPath(url.toString());
256
+ const seg = p.split('/').filter(Boolean); // ["api", ...]
257
+
258
+ // /api/auth
259
+ if (seg[0] === 'api' && seg.length === 2 && seg[1] === 'auth') {
260
+ if (req.method === 'POST') {
261
+ if (!password) return sendJson(res, 200, { required: false });
262
+ return (async () => {
263
+ const ip = (req.socket && req.socket.remoteAddress) || 'unknown';
264
+ if (!authAttemptAllowed(ip)) return sendJson(res, 429, { error: 'too many failed attempts; try again later' });
265
+ const body = await readBody(req).catch(() => ({}));
266
+ if (body.username === username && passwordMatches(body.password, password)) {
267
+ authFails.delete(ip);
268
+ const token = crypto.randomBytes(24).toString('hex');
269
+ tokens.set(token, true);
270
+ res.setHeader('Set-Cookie', 'loom_token=' + token + '; Path=/; HttpOnly; SameSite=Strict');
271
+ return sendJson(res, 200, { ok: true, username });
272
+ }
273
+ authAttemptFailed(ip);
274
+ return sendJson(res, 401, { error: 'invalid credentials' });
275
+ })();
276
+ }
277
+ return sendJson(res, 200, { required: !password ? false : true, username: password ? username : null });
278
+ }
279
+
280
+ if (seg[0] === 'api' && seg[1] === 'health') return sendJson(res, 200, { ok: true });
281
+
282
+ if (!requireAuth(req, res)) return;
283
+
284
+ if (seg[0] !== 'api' || seg.length < 2) return sendJson(res, 404, { error: 'not found' });
285
+
286
+ // GET /api/sessions | POST /api/sessions
287
+ if (seg[1] === 'sessions' && seg.length === 2) {
288
+ if (req.method === 'POST') {
289
+ return (async () => {
290
+ const body = await readBody(req).catch(() => ({}));
291
+ const entry = newWebSession(body.mode);
292
+ return sendJson(res, 200, { id: entry.session.conversationId, mode: entry.mode });
293
+ })();
294
+ }
295
+ if (req.method === 'GET') {
296
+ const rows = listSessions().map((s) => ({ ...s, active: hub.has(s.id) }));
297
+ return sendJson(res, 200, { sessions: rows });
298
+ }
299
+ return sendJson(res, 405, { error: 'method not allowed' });
300
+ }
301
+
302
+ // GET /api/sessions/:id
303
+ if (seg[1] === 'sessions' && seg.length === 3) {
304
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' });
305
+ const saved = isValidSessionId(seg[2]) ? loadSession(seg[2]) : null;
306
+ if (!saved) return sendJson(res, 404, { error: 'session not found' });
307
+ return sendJson(res, 200, { session: saved });
308
+ }
309
+
310
+ // POST /api/chat { id?, message, mode? }
311
+ if (seg[1] === 'chat' && seg.length === 2) {
312
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' });
313
+ return (async () => {
314
+ const body = await readBody(req).catch(() => ({}));
315
+ const text = String(body.message || '').trim();
316
+ if (!text) return sendJson(res, 400, { error: 'message is empty' });
317
+ let entry = body.id && hub.has(body.id) ? hub.get(body.id) : null;
318
+ if (!entry && body.id) entry = getWebSession(body.id);
319
+ if (!entry) entry = newWebSession(body.mode);
320
+ if (entry.active) return sendJson(res, 409, { error: 'session is busy; wait for the current request to finish' });
321
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
322
+ res.write('\n');
323
+ await runChat(entry, text, res);
324
+ })();
325
+ }
326
+
327
+ // POST /api/cancel { id }
328
+ if (seg[1] === 'cancel' && seg.length === 2) {
329
+ return (async () => {
330
+ const body = await readBody(req).catch(() => ({}));
331
+ const entry = hub.get(String(body.id || ''));
332
+ if (entry) { try { entry.session.interrupt(); } catch {} return sendJson(res, 200, { ok: true }); }
333
+ return sendJson(res, 404, { error: 'session not found' });
334
+ })();
335
+ }
336
+
337
+ // GET /api/servers
338
+ if (seg[1] === 'servers' && seg.length === 2) {
339
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' });
340
+ let servers = [];
341
+ try {
342
+ const mgr = require('../mcp/mcp-manager');
343
+ servers = (mgr.listServers() || []).map((s) => ({ name: s.name, command: s.command, args: s.args || [], enabled: !!s.enabled }));
344
+ } catch {}
345
+ return sendJson(res, 200, { servers });
346
+ }
347
+
348
+ // GET /api/config
349
+ if (seg[1] === 'config' && seg.length === 2) {
350
+ const cfg = loadConfig();
351
+ return sendJson(res, 200, {
352
+ authRequired: !!password,
353
+ username: password ? username : null,
354
+ provider: cfg.provider || null,
355
+ model: (cfg.model && cfg.model[cfg.provider]) || null,
356
+ budget: cfg.budgetLevel || 'auto',
357
+ version: require('../../package.json').version,
358
+ });
359
+ }
360
+
361
+ return sendJson(res, 404, { error: 'not found' });
362
+ }
363
+
364
+ const server = http.createServer((req, res) => {
365
+ stats.requests++;
366
+ applyCors(req, res);
367
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
368
+
369
+ const p = publicPath(req.url ? new URL(req.url, 'http://x').pathname : '/');
370
+
371
+ if (p === '/' || p === '/index.html') {
372
+ fs.readFile(INDEX_FILE, 'utf8', (err, html) => {
373
+ if (err) return sendText(res, 500, 'index.html missing: ' + err.message);
374
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(html) });
375
+ res.end(html);
376
+ });
377
+ return;
378
+ }
379
+
380
+ if (p.startsWith('/api/')) return handleApi(req, res);
381
+
382
+ sendJson(res, 404, { error: 'not found' });
383
+ });
384
+
385
+ server.getStats = () => stats;
386
+ server.getHub = () => hub;
387
+ server.getTokens = () => tokens;
388
+
389
+ return { server, opts: { ...opts, password, username } };
390
+ }
391
+
392
+ // ── mDNS advertising (bonjour-service) ─────────────────────────────────
393
+
394
+ function advertiseMdns(opts, port, onError) {
395
+ let Bonjour;
396
+ try { Bonjour = require('bonjour-service').Bonjour; } catch { onError && onError('bonjour-service not installed; mDNS skipped'); return null; }
397
+ let instance;
398
+ let svc;
399
+ try {
400
+ instance = new Bonjour();
401
+ svc = instance.publish({ name: opts.mdnsDomain || 'loom', type: 'http', port, txt: { path: '/' } });
402
+ } catch (e) {
403
+ try { instance && instance.destroy(); } catch {}
404
+ onError && onError('mDNS error: ' + (e && e.message));
405
+ return null;
406
+ }
407
+ return { instance, svc };
408
+ }
409
+
410
+ function openBrowser(url) {
411
+ try {
412
+ const { execSync } = require('child_process');
413
+ if (process.platform === 'win32') execSync('start "" "' + url + '"', { stdio: 'ignore', windowsHide: true });
414
+ else if (process.platform === 'darwin') execSync('open "' + url + '"', { stdio: 'ignore' });
415
+ else execSync('xdg-open "' + url + '"', { stdio: 'ignore' });
416
+ return true;
417
+ } catch { return false; }
418
+ }
419
+
420
+ // ── listen helper (also used by tests) ─────────────────────────────────
421
+
422
+ function listen(opts) {
423
+ return new Promise((resolve, reject) => {
424
+ const { server, opts: o } = createWebServer(opts);
425
+ const onError = (err) => { server.removeListener('listening', onListen); reject(err); };
426
+ const onListen = () => {
427
+ server.removeListener('error', onError);
428
+ const address = server.address();
429
+ const port = typeof address === 'object' && address ? address.port : o.port;
430
+ resolve({ server, port, opts: o, addresses: addressesFor(o, port) });
431
+ };
432
+ server.once('error', onError);
433
+ server.once('listening', onListen);
434
+ server.listen(o.port, o.hostname === '::' ? '::' : o.hostname);
435
+ });
436
+ }
437
+
438
+ // ── CLI entry: loom web ────────────────────────────────────────────────
439
+
440
+ async function main() {
441
+ const opts = resolveOptions(process.argv.slice(2));
442
+ const { server, port, addresses, opts: o } = await listen(opts);
443
+
444
+ process.title = 'loom-web';
445
+
446
+ console.log('Loom Web — version ' + require('../../package.json').version);
447
+ if (o.password) console.log('Authentication required — user: ' + o.username + ' (LOOM_SERVER_USERNAME)');
448
+ for (const a of addresses) console.log(' ' + a.label + ': ' + a.url);
449
+ if (o.mdns) {
450
+ const m = advertiseMdns(o, port, (msg) => console.error('[loom web] ' + msg));
451
+ console.log(' mDNS: ' + (m ? (o.mdnsDomain + '.local') : 'not advertised'));
452
+ o.__mdns = m;
453
+ }
454
+ console.log('\nPress Ctrl+C to stop.');
455
+
456
+ if (!o.noOpen) {
457
+ const first = addresses[0] && addresses[0].url;
458
+ setTimeout(() => { if (first && !openBrowser(first)) console.log('Open ' + first + ' in your browser.'); }, 200);
459
+ }
460
+
461
+ const shutdown = () => {
462
+ try { o.__mdns && o.__mdns.instance.destroy(); } catch {}
463
+ server.close(() => process.exit(0));
464
+ setTimeout(() => process.exit(0), 2000).unref();
465
+ };
466
+ process.on('SIGINT', shutdown);
467
+ process.on('SIGTERM', shutdown);
468
+ }
469
+
470
+ module.exports = { createWebServer, resolveOptions, listen, advertiseMdns, openBrowser, lanAddresses, main };