loom-agent 1.2.33 → 1.2.37

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 (39) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +14 -30
  3. package/bin/loom-bun.js +95 -67
  4. package/bin/loom-tui.js +21 -1
  5. package/bin/loom.js +0 -10
  6. package/package.json +14 -9
  7. package/scripts/postinstall.js +57 -5
  8. package/src/connectors/convex.js +356 -0
  9. package/src/connectors/deploy.js +390 -0
  10. package/src/connectors/index.js +23 -0
  11. package/src/connectors/supabase.js +279 -0
  12. package/src/core/agents.js +32 -2
  13. package/src/core/cli.js +56 -7
  14. package/src/core/permissions.js +12 -1
  15. package/src/core/plugin-cmd.js +1 -0
  16. package/src/core/session.js +87 -6
  17. package/src/mcp/mcp-client.js +42 -2
  18. package/src/skills/skills-manager.js +25 -2
  19. package/src/tools/browser.js +204 -0
  20. package/src/tools/index.js +116 -5
  21. package/src/tui/App.tsx +19 -4
  22. package/src/tui/components/BreadcrumbBar.tsx +10 -9
  23. package/src/tui/components/ChatArea.tsx +3 -3
  24. package/src/tui/components/InputBar.tsx +22 -11
  25. package/src/tui/components/MdText.tsx +2 -1
  26. package/src/tui/components/Modals.tsx +31 -17
  27. package/src/tui/components/PermissionPopup.tsx +15 -12
  28. package/src/tui/components/Sidebar.tsx +6 -2
  29. package/src/tui/components/SplashScreen.tsx +17 -24
  30. package/src/tui/components/SubagentPanel.tsx +1 -1
  31. package/src/tui/components/ToastOverlay.tsx +23 -12
  32. package/src/tui/store.ts +4 -2
  33. package/src/tui/theme.ts +45 -30
  34. package/src/tui/tool-display.ts +13 -2
  35. package/docs/web.md +0 -205
  36. package/src/web/attach.js +0 -242
  37. package/src/web/graph-view.html +0 -262
  38. package/src/web/index.html +0 -824
  39. package/src/web/web-server.js +0 -470
package/docs/web.md DELETED
@@ -1,205 +0,0 @@
1
- # Web — running Loom in your browser
2
-
3
- Loom can run as a **web application** in your browser, giving you the same
4
- multi-provider coding agent without a terminal. It uses a small Node `http`
5
- server (no framework, no build step) that serves a single-page UI and drives
6
- the **same core Session loop** as the TUI and ACP — so any provider/model,
7
- tool, MCP server, or saved session works identically.
8
-
9
- ## Getting started
10
-
11
- Start the web interface:
12
-
13
- ```bash
14
- loom web
15
- ```
16
-
17
- This starts a local server on `127.0.0.1`, picks a random available port,
18
- prints the URL(s), and **opens your default browser** automatically.
19
-
20
- > **Caution — security.** If `LOOM_SERVER_PASSWORD` is not set, the server is
21
- > **unsecured**. That's fine for local single-user use but **must** be set
22
- > before binding to a network interface (`--hostname 0.0.0.0` / `--mdns`).
23
- > HTTP credentials and the `loom_token` cookie travel **unencrypted** — plain
24
- > HTTP is only safe on a trusted network; for access from anything else, use a
25
- > VPN or a TLS-terminating reverse proxy.
26
-
27
- > **Windows.** `loom web` works from PowerShell, but if you want full terminal
28
- > parity run it from WSL — same advice opencode gives for filesystem access.
29
-
30
- ## Configuration
31
-
32
- You can configure the server with **command-line flags** or the `server` block
33
- in your config (`~/.loom/config.json`). CLI flags take precedence.
34
-
35
- ### Port
36
-
37
- By default Loom picks an available port. Pin one with:
38
-
39
- ```bash
40
- loom web --port 4096
41
- ```
42
-
43
- ### Hostname
44
-
45
- By default the server binds to `127.0.0.1` (localhost only). Make it reachable
46
- on your network:
47
-
48
- ```bash
49
- loom web --hostname 0.0.0.0
50
- ```
51
-
52
- With `0.0.0.0`, Loom prints both local and network addresses, e.g.:
53
-
54
- ```
55
- Local access: http://localhost:4096
56
- Network access: http://192.168.1.100:4096
57
- ```
58
-
59
- ### mDNS
60
-
61
- Advertise the server on the local network (sets hostname to `0.0.0.0` and
62
- publishes it as `loom.local` via [bonjour-service](https://www.npmjs.com/package/bonjour-service)):
63
-
64
- ```bash
65
- loom web --mdns
66
- ```
67
-
68
- Run multiple instances on the same network with a custom domain name:
69
-
70
- ```bash
71
- loom web --mdns --mdns-domain myproject.local
72
- ```
73
-
74
- ### CORS
75
-
76
- Allow additional origins for custom frontends:
77
-
78
- ```bash
79
- loom web --cors https://example.com # one origin
80
- loom web --cors https://a.com,https://b.com # several
81
- loom web --cors '*' # any
82
- ```
83
-
84
- ### Authentication
85
-
86
- Protect access with a password environment variable:
87
-
88
- ```bash
89
- LOOM_SERVER_PASSWORD=secret loom web
90
- ```
91
-
92
- > Setting the password via the command line (or passing `--password` to
93
- > `loom attach`) can leave the literal value in shell history and process
94
- > metadata — prefer the `LOOM_SERVER_PASSWORD` environment variable (or a
95
- > `.env` file) where possible.
96
-
97
- The username defaults to `loom` and can be changed with
98
- `LOOM_SERVER_USERNAME`. When a password is set, the browser shows a login
99
- screen; API endpoints return `401` until you log in.
100
-
101
- ### Skip the auto-open
102
-
103
- ```bash
104
- loom web --no-open
105
- ```
106
-
107
- ## Using the web interface
108
-
109
- Once started, the homepage lists your **saved sessions** (from
110
- `~/.loom/sessions/`), showing provider, model, and message count. Click a
111
- session to view its transcript; **Send a message** to continue it (or pick
112
- **New chat** to start fresh). Responses stream live (Server-Sent Events) with
113
- inline tool-use and reasoning.
114
-
115
- ### See Servers
116
-
117
- Click **See Servers** in the header to view your configured MCP servers and
118
- their enabled/disabled status (the same `/mcp` view the TUI shows).
119
-
120
- ## Attaching a terminal
121
-
122
- You can attach a terminal client to a running web server — the two share the
123
- same sessions and state:
124
-
125
- ```bash
126
- # Terminal A — start the web server
127
- loom web --port 4096
128
-
129
- # Terminal B — attach to it
130
- loom attach http://localhost:4096
131
- ```
132
-
133
- `loom attach` is a **line-mode** terminal client (the SolidJS OpenTUI runs
134
- in-process today and isn't rewired to a remote server). It lets you:
135
-
136
- - **pick** or **create** a session (shares the server's session list),
137
- - print a session's transcript,
138
- - chat with live streaming, and
139
- - send **Ctrl+C** to cancel the current request.
140
-
141
- For password-protected servers:
142
-
143
- ```bash
144
- LOOM_SERVER_PASSWORD=secret loom attach http://localhost:4096
145
- # or inline:
146
- loom attach http://localhost:4096 --username loom --password secret
147
- ```
148
-
149
- Resume a specific session without prompting:
150
-
151
- ```bash
152
- loom attach http://localhost:4096 --session web-abc123
153
- ```
154
-
155
- ## Config file
156
-
157
- You can put the same server settings in your `~/.loom/config.json` under a
158
- `server` key:
159
-
160
- ```json
161
- {
162
- "server": {
163
- "port": 4096,
164
- "hostname": "0.0.0.0",
165
- "mdns": true,
166
- "cors": ["https://example.com"]
167
- }
168
- }
169
- ```
170
-
171
- **Command-line flags take precedence** over config-file settings.
172
-
173
- ## API (for custom clients)
174
-
175
- The UI talks to a small JSON API over the same HTTP server — feel free to use
176
- it from your own client or script. (Set `LOOM_SERVER_PASSWORD` and send
177
- `Cookie: loom_token=…` to authenticate; get the token from `POST /api/auth`.)
178
-
179
- | Endpoint | Method | Notes |
180
- |---|---|---|
181
- | `GET /api/health` | GET | `{ ok: true }` |
182
- | `GET /api/auth` | GET | `{ required, username }` |
183
- | `POST /api/auth` | POST | `{ username, password }` → sets `loom_token` cookie |
184
- | `GET /api/sessions` | GET | List saved sessions |
185
- | `GET /api/sessions/:id` | GET | Load a session transcript |
186
- | `POST /api/sessions` | POST | `{ mode }` → `{ id, mode }` (new web session) |
187
- | `POST /api/chat` | POST | `{ id?, message, mode? }` → `text/event-stream` of `{type, …}` |
188
- | `POST /api/cancel` | POST | `{ id }` interrupts the active request |
189
- | `GET /api/servers` | GET | MCP server list + status |
190
- | `GET /api/config` | GET | Provider/model/version banner data |
191
-
192
- SSE event `type`s: `delta`, `reasoning`, `tool.use`, `tool.result`,
193
- `message.completed`, `request.completed`, `request.error`, `session.updated`,
194
- `done`.
195
-
196
- ## Troubleshooting
197
-
198
- - **"Authentication required"** — set `LOOM_SERVER_PASSWORD` (and optionally
199
- `LOOM_SERVER_USERNAME`) before starting the server.
200
- - **Tail of `--cors`** — separate multiple origins with a comma: `--cors a,b`.
201
- - **mDNS not advertised on Windows** — `bonjour-service` is pure JS but mDNS
202
- is best-effort on the OS; if it fails, an error is printed and the HTTP
203
- server still works at the printed URLs.
204
- - **"session is busy" from a client** — you must wait for `request.completed`
205
- / `request.error` (or call `/api/cancel`) before sending the next message.
package/src/web/attach.js DELETED
@@ -1,242 +0,0 @@
1
- // loom attach <url> — terminal client for a running `loom web` server. Shares
2
- // the same sessions and state: pick an existing session (or create a new one),
3
- // then chat from the terminal. Streaming is plain Server-Sent Events parsed
4
- // from a fetch ReadableStream.
5
- //
6
- // loom attach http://localhost:4096
7
- // loom attach http://loom.local:80
8
- // LOOM_SERVER_PASSWORD=... loom attach http://localhost:4096
9
- // loom attach http://localhost:4096 --username me --password secret
10
- //
11
- // This is a line-mode attach (not the full OpenTUI): the SolidJS TUI runs in-
12
- // process and isn't rewired to a remote server yet, so terminal attach is the
13
- // honest v1 that actually shares state with the browser.
14
- 'use strict';
15
-
16
- const readline = require('readline');
17
-
18
- const COLOR = {
19
- reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
20
- blue: '\x1b[34m', gray: '\x1b[90m', cyan: '\x1b[36m',
21
- yellow: '\x1b[33m', red: '\x1b[31m', green: '\x1b[32m',
22
- };
23
-
24
- function parseArgs(argv) {
25
- const out = { url: null, username: process.env.LOOM_SERVER_USERNAME || null, password: process.env.LOOM_SERVER_PASSWORD || null, sessionId: null };
26
- for (let i = 0; i < argv.length; i++) {
27
- const a = argv[i];
28
- if (a === '--username' || a === '-u') out.username = argv[++i];
29
- else if (a === '--password' || a === '-p') out.password = argv[++i];
30
- else if (a === '--session' || a === '-s') out.sessionId = argv[++i];
31
- else if (a === '--help' || a === '-h') out.help = true;
32
- else if (!a.startsWith('-') && !out.url) out.url = a;
33
- }
34
- return out;
35
- }
36
-
37
- async function jsonFetch(client, path, options) {
38
- const opts = options ? { ...options, headers: { ...(options.headers || {}), ...(client.cookie ? { Cookie: client.cookie } : {}) } }
39
- : (client.cookie ? { headers: { Cookie: client.cookie } } : undefined);
40
- const res = await fetch(new URL(path, client.base), opts);
41
- if (res.status === 401) {
42
- const err = new Error('unauthorized');
43
- err.unauthorized = true;
44
- throw err;
45
- }
46
- const ct = res.headers.get('content-type') || '';
47
- const body = ct.includes('json') ? await res.json() : await res.text();
48
- if (!res.ok) throw new Error(body && body.error ? body.error : res.statusText);
49
- return body;
50
- }
51
-
52
- async function ensureAuth(client) {
53
- // Try fetching /api/auth: if required and we have credentials, POST /api/auth/login.
54
- let info;
55
- try { info = await jsonFetch(client, '/api/auth'); } catch (e) { if (e.unauthorized) throw e; info = { required: false }; }
56
- if (!info.required) { client.cookie = null; return; }
57
- if (!client.username || !client.password) {
58
- console.error('Server is password-protected. Pass --username and --password, or set LOOM_SERVER_USERNAME / LOOM_SERVER_PASSWORD.');
59
- process.exit(2);
60
- }
61
- const res = await fetch(new URL('/api/auth', client.base), {
62
- method: 'POST',
63
- headers: { 'Content-Type': 'application/json' },
64
- body: JSON.stringify({ username: client.username, password: client.password }),
65
- });
66
- if (!res.ok) {
67
- console.error('Login failed (' + res.status + ').');
68
- process.exit(2);
69
- }
70
- const sc = res.headers.get('set-cookie') || '';
71
- const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(sc)?.[1];
72
- client.cookie = token ? 'loom_token=' + token : null;
73
- }
74
-
75
- function makeClient(args) {
76
- if (!args.url) { console.error('Usage: loom attach <url> [--username U --password P] [--session ID]'); process.exit(2); }
77
- let u;
78
- try { u = new URL(args.url); } catch { console.error('Invalid URL: ' + args.url); process.exit(2); }
79
- if (u.protocol !== 'http:' && u.protocol !== 'https:') { console.error('Only http/https URLs are supported.'); process.exit(2); }
80
- return { base: u, cookie: null, username: args.username, password: args.password };
81
- }
82
-
83
- async function chooseSession(client) {
84
- const { sessions } = await jsonFetch(client, '/api/sessions');
85
- if (!sessions.length) {
86
- console.log(COLOR.gray + '(no saved sessions — a new chat will be created)' + COLOR.reset);
87
- return { id: null };
88
- }
89
- console.log(COLOR.bold + 'Sessions' + COLOR.reset);
90
- sessions.forEach((s, i) => {
91
- const when = s.createdAt ? new Date(s.createdAt).toLocaleString() : 'unknown';
92
- const meta = [when, (s.messageCount || 0) + ' msgs', s.provider ? s.provider : '', s.model ? s.model : ''].filter(Boolean).join(' · ');
93
- console.log(' ' + COLOR.cyan + (i + 1) + COLOR.reset + ') ' + s.id + ' ' + COLOR.gray + meta + COLOR.reset + (s.active ? ' ' + COLOR.green + '(active)' + COLOR.reset : ''));
94
- });
95
- console.log(' ' + COLOR.cyan + '0' + COLOR.reset + ') New chat');
96
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
97
- const ans = await new Promise((resolve) => rl.question(COLOR.bold + 'Choose [0-' + sessions.length + ']: ' + COLOR.reset, (v) => { rl.close(); resolve(v.trim()); }));
98
- const n = parseInt(ans, 10);
99
- if (!isNaN(n) && n >= 1 && n <= sessions.length) {
100
- const chosen = sessions[n - 1];
101
- return { id: chosen.id, transcript: chosen };
102
- }
103
- return { id: null };
104
- }
105
-
106
- async function loadTranscript(client, id) {
107
- try {
108
- const { session } = await jsonFetch(client, '/api/sessions/' + id);
109
- if (session && session.messages && session.messages.length) {
110
- console.log(COLOR.gray + '\nTranscript (' + session.messages.length + ' messages):' + COLOR.reset);
111
- for (const m of session.messages) {
112
- const who = m.role === 'user' ? COLOR.blue + 'you' : COLOR.green + 'loom';
113
- const content = String(m.content || '');
114
- console.log(' ' + who + COLOR.reset + ': ' + content.split('\n').join('\n '));
115
- }
116
- console.log('');
117
- }
118
- } catch (e) {
119
- if (!e.unauthorized) console.error(COLOR.gray + '(could not load transcript: ' + e.message + ')' + COLOR.reset);
120
- }
121
- }
122
-
123
- async function createSession(client) {
124
- const { id } = await jsonFetch(client, '/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'build' }) });
125
- return id;
126
- }
127
-
128
- async function streamChat(client, id, text, rlState) {
129
- const headers = { 'Content-Type': 'application/json' };
130
- if (client.cookie) headers.Cookie = client.cookie;
131
- let res;
132
- try {
133
- res = await fetch(new URL('/api/chat', client.base), {
134
- method: 'POST', headers,
135
- body: JSON.stringify({ id, message: text }),
136
- });
137
- } catch (e) {
138
- console.error(COLOR.red + 'Network error: ' + e.message + COLOR.reset);
139
- return { error: e.message };
140
- }
141
- if (res.status === 401) { ensureAuth(client).catch(() => {}); return { unauthorized: true }; }
142
- if (!res.ok) { const body = await res.text().catch(() => ''); console.error(COLOR.red + 'Server ' + res.status + ': ' + body + COLOR.reset); return { error: body }; }
143
-
144
- const reader = res.body.getReader();
145
- const dec = new TextDecoder();
146
- let buf = '';
147
- let text_started = false;
148
- process.stdout.write(COLOR.green + 'loom' + COLOR.reset + ': ');
149
- const flushTextStart = () => { if (!text_started) { text_started = true; } };
150
- const newline = () => { if (text_started) process.stdout.write('\n'); };
151
- for (;;) {
152
- const { value, done } = await reader.read();
153
- if (done) break;
154
- buf += dec.decode(value, { stream: true });
155
- let idx;
156
- while ((idx = buf.indexOf('\n\n')) !== -1) {
157
- const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
158
- const line = raw.split('\n').find((l) => l.startsWith('data: '));
159
- if (!line) continue;
160
- let ev; try { ev = JSON.parse(line.slice(6)); } catch { continue; }
161
- switch (ev.type) {
162
- case 'delta': flushTextStart(); process.stdout.write(ev.text || ''); break;
163
- case 'reasoning': flushTextStart(); process.stdout.write(COLOR.gray + (ev.text || '') + COLOR.reset); break;
164
- case 'tool.use':
165
- newline();
166
- console.log(COLOR.dim + ' ↳ tool: ' + COLOR.yellow + ev.name + COLOR.reset + COLOR.dim + ' ' + (() => { try { return JSON.stringify(ev.input); } catch { return ''; } })().slice(0, 200) + COLOR.reset);
167
- break;
168
- case 'tool.result':
169
- newline();
170
- console.log(COLOR.dim + ' ↳ result: ' + String(ev.result != null ? (typeof ev.result === 'string' ? ev.result : JSON.stringify(ev.result)) : '').slice(0, 240) + COLOR.reset);
171
- break;
172
- case 'request.error': newline(); console.error(COLOR.red + 'error: ' + (ev.message || '') + COLOR.reset); return { error: ev.message };
173
- case 'request.completed': newline(); break;
174
- case 'done': newline(); return { ok: true };
175
- }
176
- }
177
- }
178
- newline();
179
- return { ok: true };
180
- }
181
-
182
- async function cancelCurrent(client, id) {
183
- try {
184
- const headers = { 'Content-Type': 'application/json' };
185
- if (client.cookie) headers.Cookie = client.cookie;
186
- await fetch(new URL('/api/cancel', client.base), { method: 'POST', headers, body: JSON.stringify({ id }) });
187
- } catch {}
188
- }
189
-
190
- async function main() {
191
- const args = parseArgs(process.argv.slice(2));
192
- if (args.help) {
193
- console.log('Usage: loom attach <url> [options]');
194
- console.log(' --username, -u Username for password-protected server');
195
- console.log(' --password, -p Password (or set LOOM_SERVER_PASSWORD)');
196
- console.log(' --session, -s Resume a specific session id without prompting');
197
- process.exit(0);
198
- }
199
- const client = makeClient(args);
200
- await ensureAuth(client);
201
-
202
- // Try to verify the server is reachable.
203
- try {
204
- const { ok } = await jsonFetch(client, '/api/health');
205
- if (!ok) throw new Error('health failed');
206
- } catch (e) {
207
- if (e.unauthorized) { /* auth handled above; ok */ }
208
- else { console.error(COLOR.red + 'Cannot reach ' + client.base + ' (' + e.message + ').' + COLOR.reset); process.exit(2); }
209
- }
210
-
211
- let sessionId = args.sessionId || null;
212
- if (!sessionId) {
213
- const pick = await chooseSession(client);
214
- sessionId = pick.id;
215
- if (!sessionId) sessionId = await createSession(client);
216
- }
217
- console.log(COLOR.gray + 'Attached to ' + client.base + ' — session ' + sessionId + COLOR.reset);
218
- if (!args.sessionId) await loadTranscript(client, sessionId);
219
-
220
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
221
- let busy = false;
222
- let stopping = false;
223
- rl.on('SIGINT', () => {
224
- if (busy) { stopping = true; cancelCurrent(client, sessionId).catch(() => {}); console.log(COLOR.gray + '\n(stopping)' + COLOR.reset); }
225
- else { console.log(COLOR.gray + '\nbye.' + COLOR.reset); rl.close(); process.exit(0); }
226
- });
227
- const ask = () => new Promise((resolve) => rl.question(COLOR.bold + '> ' + COLOR.reset, (v) => resolve(v.trim())));
228
- for (;;) {
229
- let text;
230
- try { text = await ask(); } catch { break; }
231
- if (!text) continue;
232
- if (text === '/exit' || text === ':q') { console.log(COLOR.gray + 'bye.' + COLOR.reset); break; }
233
- busy = true; stopping = false;
234
- console.log(COLOR.blue + 'you' + COLOR.reset + ': ' + text);
235
- await streamChat(client, sessionId, text, {});
236
- busy = false;
237
- }
238
- rl.close();
239
- process.exit(0);
240
- }
241
-
242
- module.exports = { main };