loom-agent 1.2.36 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -0
- package/README.md +38 -56
- package/bin/loom-bun.js +2 -10
- package/bin/loom.js +0 -10
- package/package.json +126 -126
- package/src/connectors/convex.js +356 -0
- package/src/connectors/deploy.js +390 -0
- package/src/connectors/index.js +23 -0
- package/src/connectors/supabase.js +279 -0
- package/src/core/agents.js +32 -2
- package/src/core/cli.js +30 -5
- package/src/core/permissions.js +12 -1
- package/src/core/plugin-cmd.js +1 -0
- package/src/core/sandbox.js +400 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +157 -11
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +35 -1
- package/src/tui/App.tsx +103 -8
- package/src/tui/components/BreadcrumbBar.tsx +10 -9
- package/src/tui/components/ChatArea.tsx +3 -3
- package/src/tui/components/InputBar.tsx +22 -11
- package/src/tui/components/MdText.tsx +2 -1
- package/src/tui/components/Modals.tsx +191 -17
- package/src/tui/components/PermissionPopup.tsx +15 -12
- package/src/tui/components/Sidebar.tsx +6 -2
- package/src/tui/components/SplashScreen.tsx +17 -24
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/components/ToastOverlay.tsx +23 -12
- package/src/tui/store.ts +38 -35
- package/src/tui/theme.ts +45 -30
- package/src/tui/tool-display.ts +13 -2
- package/docs/web.md +0 -205
- package/src/web/attach.js +0 -242
- package/src/web/graph-view.html +0 -262
- package/src/web/index.html +0 -824
- package/src/web/web-server.js +0 -535
package/src/web/web-server.js
DELETED
|
@@ -1,535 +0,0 @@
|
|
|
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, Object.assign(securityHeaders(), {
|
|
115
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
116
|
-
'Content-Length': Buffer.byteLength(body),
|
|
117
|
-
}));
|
|
118
|
-
res.end(body);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function sendText(res, status, text, extra) {
|
|
122
|
-
const headers = Object.assign(securityHeaders(), {
|
|
123
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
124
|
-
'Content-Length': Buffer.byteLength(text),
|
|
125
|
-
}, extra || {});
|
|
126
|
-
res.writeHead(status, headers);
|
|
127
|
-
res.end(text);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Baseline hardening on every response: stops MIME sniffing, clickjacking
|
|
131
|
-
// and referrer leakage. The API is JSON-only and index.html is a single
|
|
132
|
-
// self-contained page (one inline script, no external assets), so this
|
|
133
|
-
// cannot break the UI.
|
|
134
|
-
const SECURITY_HEADERS = {
|
|
135
|
-
'X-Content-Type-Options': 'nosniff',
|
|
136
|
-
'X-Frame-Options': 'DENY',
|
|
137
|
-
'Referrer-Policy': 'no-referrer',
|
|
138
|
-
};
|
|
139
|
-
const HTML_SECURITY_HEADERS = Object.assign({}, SECURITY_HEADERS, {
|
|
140
|
-
'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
|
|
141
|
-
});
|
|
142
|
-
/** @returns {Record<string,string>} fresh copy so callers can extend safely */
|
|
143
|
-
function securityHeaders() {
|
|
144
|
-
return Object.assign({}, SECURITY_HEADERS);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ── The web server ──────────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
function createWebServer(opts) {
|
|
150
|
-
const tokens = new Map(); // token -> issued-at ms (auth is server-lifetime, per-token TTL)
|
|
151
|
-
const password = process.env.LOOM_SERVER_PASSWORD || '';
|
|
152
|
-
const username = process.env.LOOM_SERVER_USERNAME || DEFAULT_USERNAME;
|
|
153
|
-
const tokenTtlMs =
|
|
154
|
-
Number((opts && opts.tokenTtlMs) || process.env.LOOM_SERVER_TOKEN_TTL_MS) || 12 * 60 * 60 * 1000;
|
|
155
|
-
const hub = new Map(); // sessionId -> { session, active }
|
|
156
|
-
const stats = { requests: 0, sessionsCreated: 0, messagesRun: 0 };
|
|
157
|
-
const authFails = new Map(); // ip -> { count, lockedUntil }
|
|
158
|
-
const AUTH_MAX_ATTEMPTS = 5;
|
|
159
|
-
const AUTH_LOCKOUT_MS = 60000;
|
|
160
|
-
|
|
161
|
-
function authAttemptAllowed(ip) {
|
|
162
|
-
const rec = authFails.get(ip);
|
|
163
|
-
if (!rec) return true;
|
|
164
|
-
if (rec.lockedUntil > Date.now()) return false;
|
|
165
|
-
authFails.delete(ip);
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function authAttemptFailed(ip) {
|
|
170
|
-
const rec = authFails.get(ip) || { count: 0, lockedUntil: 0 };
|
|
171
|
-
rec.count++;
|
|
172
|
-
if (rec.count >= AUTH_MAX_ATTEMPTS) {
|
|
173
|
-
rec.count = 0;
|
|
174
|
-
rec.lockedUntil = Date.now() + AUTH_LOCKOUT_MS;
|
|
175
|
-
}
|
|
176
|
-
authFails.set(ip, rec);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function passwordMatches(a, b) {
|
|
180
|
-
const ba = Buffer.from(String(a || ''));
|
|
181
|
-
const bb = Buffer.from(String(b || ''));
|
|
182
|
-
if (ba.length !== bb.length) return false;
|
|
183
|
-
return crypto.timingSafeEqual(ba, bb);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const isAllowedOrigin = (origin) => !origin || opts.cors.length === 0 || opts.cors.includes(origin) || opts.cors.includes('*');
|
|
187
|
-
|
|
188
|
-
function applyCors(req, res) {
|
|
189
|
-
const origin = req.headers.origin;
|
|
190
|
-
if (!origin || opts.cors.length === 0) return false;
|
|
191
|
-
if (opts.cors.includes('*')) { res.setHeader('Access-Control-Allow-Origin', '*'); }
|
|
192
|
-
else if (opts.cors.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); }
|
|
193
|
-
else { return false; }
|
|
194
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
195
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function authOk(req) {
|
|
200
|
-
if (!password) return true;
|
|
201
|
-
sweepTokens();
|
|
202
|
-
const cookieHeader = req.headers.cookie || '';
|
|
203
|
-
const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(cookieHeader)?.[1]
|
|
204
|
-
|| new URL(req.url, 'http://x').searchParams.get('token');
|
|
205
|
-
return !!(token && tokenIsLive(token));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Tokens are stamped at issue time and expire after tokenTtlMs (default 12h,
|
|
209
|
-
// env LOOM_SERVER_TOKEN_TTL_MS). Sweep runs opportunistically on each auth
|
|
210
|
-
// check — the map holds only this server's logins, so it stays tiny.
|
|
211
|
-
function tokenIsLive(token) {
|
|
212
|
-
const issuedAt = tokens.get(token);
|
|
213
|
-
if (typeof issuedAt !== 'number') return false;
|
|
214
|
-
return Date.now() - issuedAt <= tokenTtlMs && Date.now() >= issuedAt - 60_000;
|
|
215
|
-
}
|
|
216
|
-
function sweepTokens() {
|
|
217
|
-
if (!tokens.size) return;
|
|
218
|
-
const now = Date.now();
|
|
219
|
-
for (const [tok, issuedAt] of tokens) {
|
|
220
|
-
if (typeof issuedAt === 'number' && now - issuedAt > tokenTtlMs) tokens.delete(tok);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function requireAuth(req, res) {
|
|
225
|
-
if (authOk(req)) return true;
|
|
226
|
-
sendJson(res, 401, { error: 'unauthorized', authRequired: true });
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function publicPath(url) {
|
|
231
|
-
try { return decodeURIComponent(new URL(url, 'http://x').pathname); } catch { return url; }
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function getWebSession(id) {
|
|
235
|
-
if (hub.has(id)) return hub.get(id);
|
|
236
|
-
const saved = (id && isValidSessionId(id)) ? loadSession(id) : null;
|
|
237
|
-
if (!saved) return null;
|
|
238
|
-
const session = new Session();
|
|
239
|
-
session.conversationId = id;
|
|
240
|
-
for (const m of saved.messages || []) {
|
|
241
|
-
try { session.addMessage({ role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || '') }); } catch {}
|
|
242
|
-
}
|
|
243
|
-
const entry = { session, active: false, mode: 'build' };
|
|
244
|
-
hub.set(id, entry);
|
|
245
|
-
return entry;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function newWebSession(mode) {
|
|
249
|
-
const session = new Session();
|
|
250
|
-
let id = 'web-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
251
|
-
session.conversationId = id;
|
|
252
|
-
try { if (mode === 'plan' || mode === 'chat' || mode === 'build') session.setMode(mode); } catch {}
|
|
253
|
-
const entry = { session, active: false, mode: session.mode };
|
|
254
|
-
hub.set(id, entry);
|
|
255
|
-
stats.sessionsCreated++;
|
|
256
|
-
return entry;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async function runChat(entry, text, res) {
|
|
260
|
-
const { session } = entry;
|
|
261
|
-
entry.active = true;
|
|
262
|
-
stats.messagesRun++;
|
|
263
|
-
const write = (type, payload) => { try { res.write('data: ' + JSON.stringify({ type, ...payload }) + '\n\n'); } catch {} };
|
|
264
|
-
const onClose = () => { if (!res.writableEnded) { try { session.interrupt(); } catch {} } };
|
|
265
|
-
res.on('close', onClose);
|
|
266
|
-
write('session.updated', { type: 'requested', id: session.conversationId });
|
|
267
|
-
try {
|
|
268
|
-
const resp = await session.sendUserMessage(text, {
|
|
269
|
-
onDelta: (t) => write('delta', { text: t, id: session.conversationId }),
|
|
270
|
-
onReasoning: (t) => write('reasoning', { text: t, id: session.conversationId }),
|
|
271
|
-
onTool: (toolName, input) => write('tool.use', { name: toolName, input, id: session.conversationId }),
|
|
272
|
-
onToolResult: (toolName, out, input) =>
|
|
273
|
-
write('tool.result', { name: toolName, input, result: out && out.result != null ? out.result : (out && out.error), id: session.conversationId }),
|
|
274
|
-
});
|
|
275
|
-
if (resp.interrupted) {
|
|
276
|
-
write('request.error', { message: '(interrupted)', id: session.conversationId });
|
|
277
|
-
} else if (resp.type === 'error') {
|
|
278
|
-
write('request.error', { message: String(resp.content || '') , id: session.conversationId });
|
|
279
|
-
} else {
|
|
280
|
-
write('message.completed', { id: session.conversationId });
|
|
281
|
-
write('request.completed', { response: { type: 'text', text: String(resp.content || '') }, id: session.conversationId });
|
|
282
|
-
}
|
|
283
|
-
} catch (e) {
|
|
284
|
-
write('request.error', { message: e && e.message ? e.message : String(e), id: session.conversationId });
|
|
285
|
-
} finally {
|
|
286
|
-
res.removeListener('close', onClose);
|
|
287
|
-
entry.active = false;
|
|
288
|
-
try { saveSession(session); } catch {}
|
|
289
|
-
write('session.updated', { type: 'saved', id: session.conversationId });
|
|
290
|
-
write('done', {});
|
|
291
|
-
try { res.end(); } catch {}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function handleApi(req, res) {
|
|
296
|
-
const url = new URL(req.url, 'http://x');
|
|
297
|
-
const p = publicPath(url.toString());
|
|
298
|
-
const seg = p.split('/').filter(Boolean); // ["api", ...]
|
|
299
|
-
|
|
300
|
-
// /api/auth
|
|
301
|
-
if (seg[0] === 'api' && seg.length === 2 && seg[1] === 'auth') {
|
|
302
|
-
if (req.method === 'POST') {
|
|
303
|
-
if (!password) return sendJson(res, 200, { required: false });
|
|
304
|
-
return (async () => {
|
|
305
|
-
const ip = (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
306
|
-
if (!authAttemptAllowed(ip)) return sendJson(res, 429, { error: 'too many failed attempts; try again later' });
|
|
307
|
-
const body = await readBody(req).catch(() => ({}));
|
|
308
|
-
if (body.username === username && passwordMatches(body.password, password)) {
|
|
309
|
-
authFails.delete(ip);
|
|
310
|
-
const token = crypto.randomBytes(24).toString('hex');
|
|
311
|
-
tokens.set(token, Date.now());
|
|
312
|
-
res.setHeader('Set-Cookie', 'loom_token=' + token + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + Math.floor(tokenTtlMs / 1000));
|
|
313
|
-
return sendJson(res, 200, { ok: true, username });
|
|
314
|
-
}
|
|
315
|
-
authAttemptFailed(ip);
|
|
316
|
-
return sendJson(res, 401, { error: 'invalid credentials' });
|
|
317
|
-
})();
|
|
318
|
-
}
|
|
319
|
-
return sendJson(res, 200, { required: !password ? false : true, username: password ? username : null });
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// POST /api/auth/logout — revoke the presented session token immediately.
|
|
323
|
-
if (seg[0] === 'api' && seg.length === 3 && seg[1] === 'auth' && seg[2] === 'logout') {
|
|
324
|
-
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' });
|
|
325
|
-
const cookieHeader = req.headers.cookie || '';
|
|
326
|
-
const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(cookieHeader)?.[1];
|
|
327
|
-
if (token) tokens.delete(token);
|
|
328
|
-
res.setHeader('Set-Cookie', 'loom_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0');
|
|
329
|
-
return sendJson(res, 200, { ok: true });
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (seg[0] === 'api' && seg[1] === 'health') return sendJson(res, 200, { ok: true });
|
|
333
|
-
|
|
334
|
-
if (!requireAuth(req, res)) return;
|
|
335
|
-
|
|
336
|
-
if (seg[0] !== 'api' || seg.length < 2) return sendJson(res, 404, { error: 'not found' });
|
|
337
|
-
|
|
338
|
-
// GET /api/sessions | POST /api/sessions
|
|
339
|
-
if (seg[1] === 'sessions' && seg.length === 2) {
|
|
340
|
-
if (req.method === 'POST') {
|
|
341
|
-
return (async () => {
|
|
342
|
-
const body = await readBody(req).catch(() => ({}));
|
|
343
|
-
const entry = newWebSession(body.mode);
|
|
344
|
-
return sendJson(res, 200, { id: entry.session.conversationId, mode: entry.mode });
|
|
345
|
-
})();
|
|
346
|
-
}
|
|
347
|
-
if (req.method === 'GET') {
|
|
348
|
-
const rows = listSessions().map((s) => ({ ...s, active: hub.has(s.id) }));
|
|
349
|
-
return sendJson(res, 200, { sessions: rows });
|
|
350
|
-
}
|
|
351
|
-
return sendJson(res, 405, { error: 'method not allowed' });
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// GET /api/sessions/:id
|
|
355
|
-
if (seg[1] === 'sessions' && seg.length === 3) {
|
|
356
|
-
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' });
|
|
357
|
-
const saved = isValidSessionId(seg[2]) ? loadSession(seg[2]) : null;
|
|
358
|
-
if (!saved) return sendJson(res, 404, { error: 'session not found' });
|
|
359
|
-
return sendJson(res, 200, { session: saved });
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// POST /api/chat { id?, message, mode? }
|
|
363
|
-
if (seg[1] === 'chat' && seg.length === 2) {
|
|
364
|
-
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' });
|
|
365
|
-
return (async () => {
|
|
366
|
-
const body = await readBody(req).catch(() => ({}));
|
|
367
|
-
const text = String(body.message || '').trim();
|
|
368
|
-
if (!text) return sendJson(res, 400, { error: 'message is empty' });
|
|
369
|
-
let entry = body.id && hub.has(body.id) ? hub.get(body.id) : null;
|
|
370
|
-
if (!entry && body.id) entry = getWebSession(body.id);
|
|
371
|
-
if (!entry) entry = newWebSession(body.mode);
|
|
372
|
-
if (entry.active) return sendJson(res, 409, { error: 'session is busy; wait for the current request to finish' });
|
|
373
|
-
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
374
|
-
res.write('\n');
|
|
375
|
-
await runChat(entry, text, res);
|
|
376
|
-
})();
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// POST /api/cancel { id }
|
|
380
|
-
if (seg[1] === 'cancel' && seg.length === 2) {
|
|
381
|
-
return (async () => {
|
|
382
|
-
const body = await readBody(req).catch(() => ({}));
|
|
383
|
-
const entry = hub.get(String(body.id || ''));
|
|
384
|
-
if (entry) { try { entry.session.interrupt(); } catch {} return sendJson(res, 200, { ok: true }); }
|
|
385
|
-
return sendJson(res, 404, { error: 'session not found' });
|
|
386
|
-
})();
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// GET /api/servers
|
|
390
|
-
if (seg[1] === 'servers' && seg.length === 2) {
|
|
391
|
-
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' });
|
|
392
|
-
let servers = [];
|
|
393
|
-
try {
|
|
394
|
-
const mgr = require('../mcp/mcp-manager');
|
|
395
|
-
servers = (mgr.listServers() || []).map((s) => ({ name: s.name, command: s.command, args: s.args || [], enabled: !!s.enabled }));
|
|
396
|
-
} catch {}
|
|
397
|
-
return sendJson(res, 200, { servers });
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// GET /api/config
|
|
401
|
-
if (seg[1] === 'config' && seg.length === 2) {
|
|
402
|
-
const cfg = loadConfig();
|
|
403
|
-
return sendJson(res, 200, {
|
|
404
|
-
authRequired: !!password,
|
|
405
|
-
username: password ? username : null,
|
|
406
|
-
provider: cfg.provider || null,
|
|
407
|
-
model: (cfg.model && cfg.model[cfg.provider]) || null,
|
|
408
|
-
budget: cfg.budgetLevel || 'auto',
|
|
409
|
-
version: require('../../package.json').version,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
return sendJson(res, 404, { error: 'not found' });
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const server = http.createServer((req, res) => {
|
|
417
|
-
stats.requests++;
|
|
418
|
-
applyCors(req, res);
|
|
419
|
-
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
420
|
-
|
|
421
|
-
const p = publicPath(req.url ? new URL(req.url, 'http://x').pathname : '/');
|
|
422
|
-
|
|
423
|
-
if (p === '/' || p === '/index.html') {
|
|
424
|
-
fs.readFile(INDEX_FILE, 'utf8', (err, html) => {
|
|
425
|
-
if (err) return sendText(res, 500, 'index.html missing: ' + err.message);
|
|
426
|
-
res.writeHead(200, Object.assign(HTML_SECURITY_HEADERS, {
|
|
427
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
428
|
-
'Content-Length': Buffer.byteLength(html),
|
|
429
|
-
}));
|
|
430
|
-
res.end(html);
|
|
431
|
-
});
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (p.startsWith('/api/')) return handleApi(req, res);
|
|
436
|
-
|
|
437
|
-
sendJson(res, 404, { error: 'not found' });
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
server.getStats = () => stats;
|
|
441
|
-
server.getHub = () => hub;
|
|
442
|
-
server.getTokens = () => tokens;
|
|
443
|
-
|
|
444
|
-
return { server, opts: { ...opts, password, username } };
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// ── mDNS advertising (bonjour-service) ─────────────────────────────────
|
|
448
|
-
|
|
449
|
-
function advertiseMdns(opts, port, onError) {
|
|
450
|
-
let Bonjour;
|
|
451
|
-
try { Bonjour = require('bonjour-service').Bonjour; } catch { onError && onError('bonjour-service not installed; mDNS skipped'); return null; }
|
|
452
|
-
let instance;
|
|
453
|
-
let svc;
|
|
454
|
-
try {
|
|
455
|
-
instance = new Bonjour();
|
|
456
|
-
svc = instance.publish({ name: opts.mdnsDomain || 'loom', type: 'http', port, txt: { path: '/' } });
|
|
457
|
-
} catch (e) {
|
|
458
|
-
try { instance && instance.destroy(); } catch {}
|
|
459
|
-
onError && onError('mDNS error: ' + (e && e.message));
|
|
460
|
-
return null;
|
|
461
|
-
}
|
|
462
|
-
return { instance, svc };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function openBrowser(url) {
|
|
466
|
-
try {
|
|
467
|
-
const { execSync } = require('child_process');
|
|
468
|
-
if (process.platform === 'win32') execSync('start "" "' + url + '"', { stdio: 'ignore', windowsHide: true });
|
|
469
|
-
else if (process.platform === 'darwin') execSync('open "' + url + '"', { stdio: 'ignore' });
|
|
470
|
-
else execSync('xdg-open "' + url + '"', { stdio: 'ignore' });
|
|
471
|
-
return true;
|
|
472
|
-
} catch { return false; }
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// ── listen helper (also used by tests) ─────────────────────────────────
|
|
476
|
-
|
|
477
|
-
function listen(opts) {
|
|
478
|
-
return new Promise((resolve, reject) => {
|
|
479
|
-
const { server, opts: o } = createWebServer(opts);
|
|
480
|
-
const onError = (err) => { server.removeListener('listening', onListen); reject(err); };
|
|
481
|
-
const onListen = () => {
|
|
482
|
-
server.removeListener('error', onError);
|
|
483
|
-
const address = server.address();
|
|
484
|
-
const port = typeof address === 'object' && address ? address.port : o.port;
|
|
485
|
-
resolve({ server, port, opts: o, addresses: addressesFor(o, port) });
|
|
486
|
-
};
|
|
487
|
-
server.once('error', onError);
|
|
488
|
-
server.once('listening', onListen);
|
|
489
|
-
server.listen(o.port, o.hostname === '::' ? '::' : o.hostname);
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// ── CLI entry: loom web ────────────────────────────────────────────────
|
|
494
|
-
|
|
495
|
-
async function main() {
|
|
496
|
-
const opts = resolveOptions(process.argv.slice(2));
|
|
497
|
-
const { server, port, addresses, opts: o } = await listen(opts);
|
|
498
|
-
|
|
499
|
-
process.title = 'loom-web';
|
|
500
|
-
|
|
501
|
-
console.log('Loom Web — version ' + require('../../package.json').version);
|
|
502
|
-
if (o.password) console.log('Authentication required — user: ' + o.username + ' (LOOM_SERVER_USERNAME)');
|
|
503
|
-
const exposedToLan = o.hostname === '0.0.0.0' || o.hostname === '::';
|
|
504
|
-
if (exposedToLan) {
|
|
505
|
-
console.log('! NETWORK EXPOSURE WARNING !');
|
|
506
|
-
if (!o.password) {
|
|
507
|
-
console.log('! Binding ' + o.hostname + ' WITHOUT a password: anyone on your LAN can run an AI agent ');
|
|
508
|
-
console.log('! on this machine and reach its tools/files. Set LOOM_SERVER_PASSWORD before exposing.');
|
|
509
|
-
} else {
|
|
510
|
-
console.log('! Bound to ' + o.hostname + ' — protected by login, but keep the port firewalled where possible.');
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
for (const a of addresses) console.log(' ' + a.label + ': ' + a.url);
|
|
514
|
-
if (o.mdns) {
|
|
515
|
-
const m = advertiseMdns(o, port, (msg) => console.error('[loom web] ' + msg));
|
|
516
|
-
console.log(' mDNS: ' + (m ? (o.mdnsDomain + '.local') : 'not advertised'));
|
|
517
|
-
o.__mdns = m;
|
|
518
|
-
}
|
|
519
|
-
console.log('\nPress Ctrl+C to stop.');
|
|
520
|
-
|
|
521
|
-
if (!o.noOpen) {
|
|
522
|
-
const first = addresses[0] && addresses[0].url;
|
|
523
|
-
setTimeout(() => { if (first && !openBrowser(first)) console.log('Open ' + first + ' in your browser.'); }, 200);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const shutdown = () => {
|
|
527
|
-
try { o.__mdns && o.__mdns.instance.destroy(); } catch {}
|
|
528
|
-
server.close(() => process.exit(0));
|
|
529
|
-
setTimeout(() => process.exit(0), 2000).unref();
|
|
530
|
-
};
|
|
531
|
-
process.on('SIGINT', shutdown);
|
|
532
|
-
process.on('SIGTERM', shutdown);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
module.exports = { createWebServer, resolveOptions, listen, advertiseMdns, openBrowser, lanAddresses, main };
|