loom-agent 1.2.36 → 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.
- package/CHANGELOG.md +53 -0
- package/README.md +4 -29
- package/bin/loom-bun.js +2 -10
- package/bin/loom.js +0 -10
- package/package.json +6 -7
- 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/session.js +87 -6
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +17 -1
- package/src/tui/App.tsx +19 -4
- 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 +31 -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 +4 -2
- 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/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 };
|