@oharness/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,2789 @@
1
+ import { createServer } from "node:http";
2
+ import { randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { Harness } from "@oharness/engine/harness.ts";
7
+ import { updateUserConfig } from "@oharness/engine/config.ts";
8
+ import { connectorConfig, officialConnectors } from "@oharness/engine/mcp/registry.ts";
9
+ import { missingSecrets } from "@oharness/engine/mcp/secrets.ts";
10
+ import { connectorIcon } from "./icons.js";
11
+ import { PERMISSION_MODES } from "@oharness/engine/agent/permissions.ts";
12
+ import { isReadable } from "@oharness/engine/agent/workspace.ts";
13
+ import { messageText } from "@oharness/engine/core/types.ts";
14
+ import { errorMessage } from "@oharness/engine/core/errors.ts";
15
+ import { officialBaseUrl } from "@oharness/engine/auth/providers/official.ts";
16
+ import { browserSignInUrl, exchangeHandoff, siteUrl } from "@oharness/engine/auth/providers/account.ts";
17
+ import { ChangeLedger } from "./changes.js";
18
+ import { addProject, ensureProject, listProjects, removeProject } from "./projects.js";
19
+ import { readPrefs, writePrefs } from "./prefs.js";
20
+ import { listDirectories, pickDirectory, pickFiles } from "./pick.js";
21
+ import { browseFiles, searchFiles, within } from "./files.js";
22
+ import { analyticsState } from "./analytics.js";
23
+ import { PAGE } from "./page.js";
24
+ import { artifactDocument, sendArtifact } from "./artifacts.js";
25
+ import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreArtifacts, } from "@oharness/engine/artifacts/store.ts";
26
+ import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engine/artifacts/publish.ts";
27
+ /**
28
+ * How many stored sessions the usage page counts.
29
+ *
30
+ * A cap rather than "everything", because the totals are read by walking the
31
+ * session metadata on disk and someone who has been using this for months has
32
+ * thousands of them. The answer says how many it looked at, so a total that
33
+ * stops short says so rather than reading as the whole history.
34
+ */
35
+ const USAGE_SESSION_SCAN = 200;
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+ /**
38
+ * The windows the usage page offers.
39
+ *
40
+ * Days rather than calendar months: "the last 30 days" is a question with one
41
+ * answer, where "this month" changes what it means on the first of the month
42
+ * and makes two visits a day apart incomparable.
43
+ */
44
+ const USAGE_RANGES = {
45
+ "7d": { id: "7d", days: 7 },
46
+ "30d": { id: "30d", days: 30 },
47
+ "90d": { id: "90d", days: 90 },
48
+ all: { id: "all", days: 0 },
49
+ };
50
+ /**
51
+ * What was spent over time, per model.
52
+ *
53
+ * Bucketed by day while a day is a readable width, and by week once the window
54
+ * is long enough that a daily column is a hair. Empty buckets are included on
55
+ * purpose: a week with no work is a fact about the week, and dropping it would
56
+ * pull the weeks on either side together and put time on an axis that does not
57
+ * pass evenly.
58
+ *
59
+ * A session's spend lands in the bucket it was last touched, because that is
60
+ * what the metadata records — a session worked on across three days counts on
61
+ * the third. Said out loud on the page rather than smoothed over: the
62
+ * alternative is reading every transcript to place each turn, which is a lot
63
+ * of disk for a curve nobody reads to four decimal places.
64
+ */
65
+ function usageSeries(rows, since) {
66
+ const now = Date.now();
67
+ const span = Math.max(now - since, DAY_MS);
68
+ const step = span > 60 * DAY_MS ? "week" : "day";
69
+ const width = step === "week" ? 7 * DAY_MS : DAY_MS;
70
+ // Anchored to the end rather than to midnight of the first day: the last
71
+ // bucket should be the one happening now, so the newest column is never a
72
+ // sliver that reads as a collapse in spending.
73
+ const end = Math.floor(now / width) * width + width;
74
+ const count = Math.min(Math.ceil((end - since) / width), 400);
75
+ const from = end - count * width;
76
+ const buckets = Array.from({ length: count }, (_, i) => ({
77
+ at: from + i * width,
78
+ costUsd: 0,
79
+ models: {},
80
+ }));
81
+ for (const row of rows) {
82
+ const at = Math.floor((row.updatedAt - from) / width);
83
+ const bucket = buckets[at];
84
+ if (!bucket)
85
+ continue;
86
+ bucket.costUsd += row.costUsd;
87
+ bucket.models[row.model] = (bucket.models[row.model] ?? 0) + row.costUsd;
88
+ }
89
+ return { step, from, buckets };
90
+ }
91
+ /**
92
+ * The four token counts, with the optional halves filled in.
93
+ *
94
+ * Cache reads and writes are optional on the wire because a provider without
95
+ * a prompt cache never reports them. Undefined and zero mean the same thing to
96
+ * anyone reading a total, so the page is spared the distinction.
97
+ */
98
+ function tokensOf(usage) {
99
+ return {
100
+ input: usage.inputTokens || 0,
101
+ output: usage.outputTokens || 0,
102
+ cacheRead: usage.cacheReadTokens || 0,
103
+ cacheWrite: usage.cacheWriteTokens || 0,
104
+ };
105
+ }
106
+ export async function startWebServer(opts) {
107
+ const token = randomUUID();
108
+ /**
109
+ * A second credential, for artifact bodies only.
110
+ *
111
+ * An artifact is loaded into an iframe by URL, and a sandboxed document can
112
+ * read its own URL — so whatever authorises that route is legible to the
113
+ * content served through it. With one shared token, an HTML artifact could
114
+ * read the credential that drives the agent. This one only unlocks
115
+ * `/artifact/view`, so the worst an artifact can learn is how to read other
116
+ * artifacts in the same session.
117
+ */
118
+ const artifactToken = randomUUID();
119
+ const host = opts.host ?? "127.0.0.1";
120
+ const clients = new Set();
121
+ const pending = new Map();
122
+ const runners = new Map();
123
+ const anyRunning = () => [...runners.values()].some((runner) => Boolean(runner.running));
124
+ const makeRunner = (session, harness) => {
125
+ const ledger = new ChangeLedger(session.cwd);
126
+ ledger.attach(harness.hooks);
127
+ return { session, agent: harness.agent(session), harness, ledger };
128
+ };
129
+ /** The runner a request names, or the most recently focused one. */
130
+ let focused = opts.session.id;
131
+ const runnerFor = (id) => runners.get(typeof id === "string" && id ? id : focused);
132
+ runners.set(opts.session.id, makeRunner(opts.session, opts.harness));
133
+ let current = { session: opts.session, agent: opts.agent, harness: opts.harness };
134
+ /**
135
+ * Sign-ins waiting for their emailed code.
136
+ *
137
+ * A login is two requests — send the code, then verify it — and the second
138
+ * one needs the closure the first produced. Dropped after ten minutes so a
139
+ * tab closed halfway through does not leave one open indefinitely.
140
+ */
141
+ const pendingSignIns = new Map();
142
+ const SIGNIN_TTL_MS = 10 * 60_000;
143
+ /**
144
+ * Sign-ins happening in the user's browser, keyed by the nonce they carry.
145
+ *
146
+ * The browser comes back to a callback on this server, and it comes back
147
+ * without the token — it is the *system* browser, opened by a click, and it
148
+ * never had one. The nonce is what stands in for it: 32 bytes this process
149
+ * generated, held only in memory, good once and only for the few minutes
150
+ * somebody spends signing in.
151
+ */
152
+ const pendingBrowserSignIns = new Map();
153
+ const BROWSER_SIGNIN_TTL_MS = 10 * 60_000;
154
+ /**
155
+ * One harness per project directory.
156
+ *
157
+ * A harness binds to its cwd at construction, so a second project needs a
158
+ * second harness rather than a mutated field. They are kept after a switch
159
+ * because rebuilding one reconnects every MCP server.
160
+ */
161
+ const harnesses = new Map([[opts.session.cwd, opts.harness]]);
162
+ void ensureProject(opts.session.cwd);
163
+ /**
164
+ * The model the user last picked, carried into every session started here.
165
+ *
166
+ * Read once at startup and kept in memory: the file is written when the
167
+ * choice changes, so re-reading it per session would only add a disk hit to
168
+ * answer a question this already knows.
169
+ */
170
+ let preferredModel = (await readPrefs()).model;
171
+ /**
172
+ * A new session on a harness, honouring that preference.
173
+ *
174
+ * Guarded by the harness's own catalog: a remembered model from a provider
175
+ * that has since been disconnected would otherwise fail at the first request
176
+ * rather than falling back to the configured default.
177
+ */
178
+ const freshSession = (harness) => {
179
+ const known = preferredModel && harness.listModels().some((model) => model.id === preferredModel);
180
+ return harness.createSession(known ? { model: preferredModel } : {});
181
+ };
182
+ /**
183
+ * Files staged for the next prompt.
184
+ *
185
+ * Held in memory and dropped once used: writing an upload into the working
186
+ * tree would put a file the agent never asked for into the user's repo, and
187
+ * it would show up in the change ledger as work the agent did.
188
+ *
189
+ * Memory is the whole cost of that choice, so nothing staged is allowed to
190
+ * stay indefinitely. A prompt claims what it uses; a dropped chip says so;
191
+ * and what neither happens to — a tab closed on a pasted screenshot — is
192
+ * reclaimed by `sweepAttachments`.
193
+ */
194
+ const attachments = new Map();
195
+ /**
196
+ * Carry a chosen file whose bytes are the only way it can mean anything.
197
+ *
198
+ * The dialog reaches the whole machine; the agent's readable roots are the
199
+ * project and little else. A path from outside them is a reference `read`
200
+ * will refuse, so for those the file travels with the message instead —
201
+ * which is what the row promising "files or photos" implied all along.
202
+ * Returns nothing when it cannot be carried either, and the page then says
203
+ * so rather than staging a turn that is already lost.
204
+ */
205
+ const stageFromDisk = async (target) => {
206
+ try {
207
+ const info = await stat(target);
208
+ if (!info.isFile() || info.size > MAX_UPLOAD_BYTES)
209
+ return undefined;
210
+ const data = await readFile(target);
211
+ const name = path.basename(target);
212
+ // Images by extension, and everything else left unnamed: `buildMessage`
213
+ // decides text from bytes rather than from a type, so guessing one for
214
+ // a .log would add nothing and could be wrong.
215
+ const mediaType = PICKED_IMAGE_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
216
+ const id = randomUUID();
217
+ attachments.set(id, { name, mediaType, data, at: Date.now() });
218
+ sweepAttachments();
219
+ return {
220
+ id,
221
+ name,
222
+ mediaType,
223
+ bytes: data.byteLength,
224
+ kind: mediaType.startsWith("image/") ? "image" : "file",
225
+ };
226
+ }
227
+ catch {
228
+ return undefined;
229
+ }
230
+ };
231
+ /**
232
+ * Forget staged uploads nobody is going to send.
233
+ *
234
+ * Age first, since a file staged half an hour ago belongs to a composer
235
+ * that is gone. Then total size, oldest first, because the bound that
236
+ * actually protects the process is bytes rather than entries: one map of
237
+ * screenshots can outweigh a thousand pasted snippets.
238
+ */
239
+ const sweepAttachments = () => {
240
+ const cutoff = Date.now() - STAGED_TTL_MS;
241
+ let total = 0;
242
+ for (const [id, file] of attachments) {
243
+ if (file.at < cutoff)
244
+ attachments.delete(id);
245
+ else
246
+ total += file.data.byteLength;
247
+ }
248
+ // Insertion order is arrival order, so the first entries are the oldest.
249
+ for (const [id, file] of attachments) {
250
+ if (total <= MAX_STAGED_BYTES)
251
+ break;
252
+ attachments.delete(id);
253
+ total -= file.data.byteLength;
254
+ }
255
+ };
256
+ // The review pane's whole reason to exist: what the agent did to the tree.
257
+ const broadcast = (event) => {
258
+ const payload = `data: ${JSON.stringify(event)}\n\n`;
259
+ for (const client of clients)
260
+ client.write(payload);
261
+ };
262
+ const permissionPrompt = (request) => new Promise((resolve) => {
263
+ const id = randomUUID();
264
+ pending.set(id, { id, request, resolve });
265
+ broadcast({ type: "permission_request", id, request });
266
+ // No timeout here on purpose. A prompt that auto-denies after a while
267
+ // would silently fail long-running work while the user is away; a prompt
268
+ // that auto-allows would be worse. It waits.
269
+ });
270
+ const server = createServer((req, res) => {
271
+ void handle(req, res).catch((err) => {
272
+ send(res, 500, { error: errorMessage(err) });
273
+ });
274
+ });
275
+ async function handle(req, res) {
276
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
277
+ // The page itself is the only thing served without a token, and only so a
278
+ // bare URL with the token in the query string can bootstrap.
279
+ if (url.pathname === "/" && req.method === "GET") {
280
+ if (!authorized(url, req))
281
+ return send(res, 401, { error: "missing or bad token" });
282
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
283
+ res.end(PAGE);
284
+ return;
285
+ }
286
+ // The other exception, and for the same kind of reason: this one is
287
+ // arrived at by the system browser coming back from the site, which never
288
+ // held the token. The nonce it carries is the credential — unguessable,
289
+ // single-use, and issued by the request that opened the browser.
290
+ if (url.pathname === "/signin/handoff" && req.method === "GET") {
291
+ return browserSignInReturn(url, res);
292
+ }
293
+ // Third exception, and the reason it is one is in artifacts.ts: this route
294
+ // is read by a sandboxed iframe that can see its own URL, so it is gated
295
+ // on the artifact credential rather than the session token.
296
+ if (url.pathname === "/artifact/view" && req.method === "GET") {
297
+ const supplied = url.searchParams.get("k") ?? "";
298
+ if (!constantTimeEqual(supplied, artifactToken)) {
299
+ return send(res, 401, { error: "missing or bad artifact key" });
300
+ }
301
+ return serveArtifactBody(url, res);
302
+ }
303
+ if (!authorized(url, req))
304
+ return send(res, 401, { error: "missing or bad token" });
305
+ switch (`${req.method} ${url.pathname}`) {
306
+ case "GET /events":
307
+ return stream(res);
308
+ case "GET /state": {
309
+ const runner = runnerFor(url.searchParams.get("session"));
310
+ if (!runner)
311
+ return send(res, 404, { error: "no such session" });
312
+ focused = runner.session.id;
313
+ return send(res, 200, state(runner));
314
+ }
315
+ /**
316
+ * A staged upload, before any turn has carried it.
317
+ *
318
+ * The composer's own uploads are previewed from the bytes the browser
319
+ * already holds; one the server read off the disk has never been near
320
+ * the page, and without this its chip would be the only one in the row
321
+ * with no picture on it.
322
+ */
323
+ case "GET /upload/preview": {
324
+ const staged = attachments.get(url.searchParams.get("id") ?? "");
325
+ if (!staged)
326
+ return send(res, 404, { error: "no such upload" });
327
+ // The same allowlist as the transcript's images, for the same reason:
328
+ // this is served same-origin to a page holding the session token.
329
+ if (!VIEWABLE_IMAGE_TYPES.has(staged.mediaType)) {
330
+ return send(res, 415, { error: `cannot display ${staged.mediaType}` });
331
+ }
332
+ res.writeHead(200, {
333
+ "content-type": staged.mediaType,
334
+ "content-length": staged.data.byteLength,
335
+ "x-content-type-options": "nosniff",
336
+ "content-security-policy": "default-src 'none'; sandbox",
337
+ // Staged bytes are replaced by a new id, never edited under this
338
+ // one, but they do go away when the prompt claims them.
339
+ "cache-control": "private, no-store",
340
+ });
341
+ res.end(staged.data);
342
+ return;
343
+ }
344
+ /**
345
+ * One image out of the transcript.
346
+ *
347
+ * Addressed by its position in the session rather than by an id of its
348
+ * own, because a message block has no identity to give it: the bytes
349
+ * live in the transcript, and the transcript is the only copy. Reads
350
+ * are bounded to what the URL names and refuse anything that is not an
351
+ * image, so the route cannot be turned into a way to read arbitrary
352
+ * message content back out as a file.
353
+ */
354
+ case "GET /attachment": {
355
+ const runner = runnerFor(url.searchParams.get("session"));
356
+ if (!runner)
357
+ return send(res, 404, { error: "no such session" });
358
+ const index = Number(url.searchParams.get("message"));
359
+ const position = Number(url.searchParams.get("block"));
360
+ const message = Number.isInteger(index) ? runner.session.messages[index - 1] : undefined;
361
+ const block = Number.isInteger(position) ? message?.content[position] : undefined;
362
+ if (!block || block.type !== "image")
363
+ return send(res, 404, { error: "no such image" });
364
+ // The media type on a block is whatever the upload claimed, and this
365
+ // response is same-origin with the page's own token. An allowlist of
366
+ // raster types is the whole defence: SVG is scriptable, and anything
367
+ // outside the list could be sniffed into markup that runs here.
368
+ if (!VIEWABLE_IMAGE_TYPES.has(block.mimeType)) {
369
+ return send(res, 415, { error: `cannot display ${block.mimeType}` });
370
+ }
371
+ const data = Buffer.from(block.data, "base64");
372
+ res.writeHead(200, {
373
+ "content-type": block.mimeType,
374
+ "content-length": data.byteLength,
375
+ "x-content-type-options": "nosniff",
376
+ "content-security-policy": "default-src 'none'; sandbox",
377
+ // The transcript is append-only, so this position holds the same
378
+ // bytes forever: worth saying, since the page asks for every image
379
+ // in a conversation again on every switch between tabs.
380
+ "cache-control": "private, max-age=31536000, immutable",
381
+ });
382
+ res.end(data);
383
+ return;
384
+ }
385
+ case "GET /artifacts": {
386
+ const runner = runnerFor(url.searchParams.get("session"));
387
+ if (!runner)
388
+ return send(res, 404, { error: "no such session" });
389
+ // The key travels with the list so the page never has to construct a
390
+ // credentialled URL from a token it holds separately — one place
391
+ // builds these, and it is the one that knows which key is right.
392
+ return send(res, 200, {
393
+ artifacts: getArtifactStore(runner.session.state).list(),
394
+ viewKey: artifactToken,
395
+ });
396
+ }
397
+ /**
398
+ * Every artifact on this machine, grouped by the session that made it.
399
+ *
400
+ * This used to list only what the process had loaded — the open tabs —
401
+ * because the store is memory and lives on the session. That made the
402
+ * panel's answer depend on which tabs happened to be open, which is not
403
+ * an answer anybody can act on: something you made yesterday was simply
404
+ * absent, with nothing on screen to say it existed.
405
+ *
406
+ * The revision logs are the durable record, so the listing is folded
407
+ * from those and a loaded session overrides its own log — it holds the
408
+ * revision a person just made in a canvas, and it knows about publishing,
409
+ * which the log does not record.
410
+ */
411
+ case "GET /artifacts/all": {
412
+ const catalogue = await artifactCatalogue();
413
+ return send(res, 200, { ...catalogue, viewKey: artifactToken });
414
+ }
415
+ // The body, for the kinds the page renders itself into DOM nodes. Gated
416
+ // on the session token, not the artifact key: this response is read by
417
+ // the page's own script, which already holds the stronger credential.
418
+ case "GET /artifact/raw": {
419
+ const runner = runnerFor(url.searchParams.get("session"));
420
+ if (!runner)
421
+ return send(res, 404, { error: "no such session" });
422
+ const id = url.searchParams.get("id") ?? "";
423
+ const versionParam = url.searchParams.get("version");
424
+ const found = getArtifactStore(runner.session.state).get(id, versionParam ? Number(versionParam) : undefined);
425
+ if (!found)
426
+ return send(res, 404, { error: "no such artifact" });
427
+ return send(res, 200, {
428
+ id: found.artifact.id,
429
+ title: found.artifact.title,
430
+ mime: found.artifact.mime,
431
+ kind: found.artifact.kind,
432
+ language: found.artifact.language,
433
+ version: found.revision.version,
434
+ versions: found.artifact.versions.map((v) => v.version),
435
+ content: found.revision.content,
436
+ });
437
+ }
438
+ /**
439
+ * A revision written by the person looking at it.
440
+ *
441
+ * The frontend posts this on the canvas editor's behalf; the editor
442
+ * itself never can. It runs in a sandbox with `connect-src 'none'` and
443
+ * has no session token — deliberately, since it is showing model-authored
444
+ * markup — so the only thing it can do is postMessage its new content to
445
+ * the page that framed it, and let that page, which already holds the
446
+ * credential, decide whether to make the call. The sandbox stays intact
447
+ * and the editor gains no reach it did not have.
448
+ *
449
+ * Restricted to canvases because a canvas is the only artifact with an
450
+ * editor. Accepting an edit for a `page` would mean any framed artifact
451
+ * could rewrite itself by asking the parent nicely.
452
+ */
453
+ case "POST /artifact/edit": {
454
+ const runner = runnerFor(url.searchParams.get("session"));
455
+ if (!runner)
456
+ return send(res, 404, { error: "no such session" });
457
+ const body = await readJson(req);
458
+ const id = typeof body.id === "string" ? body.id : "";
459
+ const content = typeof body.content === "string" ? body.content : null;
460
+ if (!id || content === null)
461
+ return send(res, 400, { error: "id and content are required" });
462
+ const store = getArtifactStore(runner.session.state);
463
+ const found = store.get(id);
464
+ if (!found)
465
+ return send(res, 404, { error: "no such artifact" });
466
+ if (!isCanvas(found.artifact.mime)) {
467
+ return send(res, 400, { error: "only a canvas can be edited in place" });
468
+ }
469
+ try {
470
+ const descriptor = store.edit(id, content);
471
+ if (!descriptor)
472
+ return send(res, 404, { error: "no such artifact" });
473
+ // Broadcast for the same reason publishing does: this page is not
474
+ // the only one open, and the version picker everywhere else is now
475
+ // one revision behind.
476
+ broadcast({ type: "artifact", artifact: descriptor, session: runner.session.id });
477
+ return send(res, 200, descriptor);
478
+ }
479
+ catch (err) {
480
+ return send(res, 400, { error: errorMessage(err) });
481
+ }
482
+ }
483
+ case "POST /artifact/publish": {
484
+ const runner = runnerFor(url.searchParams.get("session"));
485
+ if (!runner)
486
+ return send(res, 404, { error: "no such session" });
487
+ const body = await readJson(req);
488
+ const id = typeof body.id === "string" ? body.id : "";
489
+ const found = getArtifactStore(runner.session.state).get(id);
490
+ if (!found)
491
+ return send(res, 404, { error: "no such artifact" });
492
+ const visibility = body.visibility === "team" ? "team" : "unlisted";
493
+ const teamId = typeof body.teamId === "string" ? body.teamId : undefined;
494
+ if (visibility === "team" && !teamId) {
495
+ return send(res, 400, { error: "publishing to a team needs a team" });
496
+ }
497
+ // A canvas is stored as a fragment of artboards and only becomes a
498
+ // document when this server wraps it in the editor. The website has no
499
+ // such wrapper, so publishing the stored bytes would put a row of
500
+ // unstyled boxes behind the link. What travels instead is the rendered
501
+ // document — self-contained, and read-only on the other side because
502
+ // nothing there speaks the save protocol.
503
+ const canvas = isCanvas(found.artifact.mime);
504
+ const published = canvas
505
+ ? artifactDocument(found.artifact, found.revision).body
506
+ : found.revision.content;
507
+ try {
508
+ const result = await publishArtifact({
509
+ sourceId: found.artifact.id,
510
+ title: found.artifact.title,
511
+ mime: canvas ? "text/html" : found.artifact.mime,
512
+ kind: found.artifact.kind,
513
+ ...(found.artifact.language ? { language: found.artifact.language } : {}),
514
+ content: published,
515
+ version: found.revision.version,
516
+ visibility,
517
+ ...(teamId ? { teamId } : {}),
518
+ }, { accountToken: await current.harness.accountToken() });
519
+ // Recorded against the artifact, not just returned: the page that
520
+ // asked is not the only one open, and a reload should not offer to
521
+ // publish something that already has a link. The revision goes in
522
+ // too — a link serves what was last published, so an artifact
523
+ // revised since is a link showing something older.
524
+ const descriptor = getArtifactStore(runner.session.state).markPublished(id, {
525
+ url: result.url,
526
+ visibility,
527
+ ...(teamId ? { teamId } : {}),
528
+ version: found.revision.version,
529
+ });
530
+ if (descriptor)
531
+ broadcast({ type: "artifact", artifact: descriptor, session: runner.session.id });
532
+ return send(res, 200, result);
533
+ }
534
+ catch (err) {
535
+ // Not signed in, offline, over the limit, or the site said no — all
536
+ // things the person clicking Publish can act on, so they travel as a
537
+ // message rather than a 500.
538
+ return send(res, 400, { error: errorMessage(err) });
539
+ }
540
+ }
541
+ case "POST /artifact/unpublish": {
542
+ const body = await readJson(req);
543
+ const id = typeof body.id === "string" ? body.id : "";
544
+ if (!id)
545
+ return send(res, 400, { error: "id is required" });
546
+ try {
547
+ await unpublishArtifact(id, { accountToken: await current.harness.accountToken() });
548
+ const runner = runnerFor(url.searchParams.get("session"));
549
+ if (runner) {
550
+ const descriptor = getArtifactStore(runner.session.state).clearPublication(id);
551
+ if (descriptor) {
552
+ broadcast({ type: "artifact", artifact: descriptor, session: runner.session.id });
553
+ }
554
+ }
555
+ return send(res, 200, { withdrawn: true });
556
+ }
557
+ catch (err) {
558
+ return send(res, 400, { error: errorMessage(err) });
559
+ }
560
+ }
561
+ case "GET /teams": {
562
+ // For the Publish menu. Empty when signed out, which is the same
563
+ // answer the menu needs in order to offer an unlisted link only.
564
+ return send(res, 200, {
565
+ teams: await accountTeams({ accountToken: await current.harness.accountToken() }),
566
+ });
567
+ }
568
+ case "GET /history":
569
+ {
570
+ // Opening the page mid-session should show what has happened, not an
571
+ // empty transcript that silently drops the context the model still has.
572
+ const runner = runnerFor(url.searchParams.get("session"));
573
+ if (!runner)
574
+ return send(res, 404, { error: "no such session" });
575
+ return send(res, 200, { entries: history(runner) });
576
+ }
577
+ case "GET /projects": {
578
+ const projects = await listProjects();
579
+ const prefs = await readPrefs();
580
+ const pinned = new Set(prefs.pinnedProjects ?? []);
581
+ return send(res, 200, {
582
+ order: prefs.projectOrder ?? [],
583
+ projects: projects.map((project) => ({
584
+ ...project,
585
+ pinned: pinned.has(project.path),
586
+ current: project.path === current.session.cwd,
587
+ })),
588
+ });
589
+ }
590
+ /**
591
+ * Keep one session or project at the top of its list.
592
+ *
593
+ * A preference about this sidebar, so it is written where the other ones
594
+ * are and never into the session record: a pin is not a fact about the
595
+ * work, and it has no business travelling to another machine to
596
+ * rearrange a list somebody there had arranged for themselves.
597
+ */
598
+ case "POST /pin": {
599
+ const body = await readJson(req);
600
+ const id = typeof body.id === "string" ? body.id : "";
601
+ if (!id)
602
+ return send(res, 400, { error: "id is required" });
603
+ const key = body.kind === "project" ? "pinnedProjects" : "pinnedSessions";
604
+ const held = (await readPrefs())[key] ?? [];
605
+ const rest = held.filter((one) => one !== id);
606
+ // Newest pin first, so pinning something does not bury it under
607
+ // everything pinned before it.
608
+ const next = body.pinned === false ? rest : [id, ...rest];
609
+ await writePrefs({ [key]: next });
610
+ return send(res, 200, { pinned: body.pinned !== false, order: next });
611
+ }
612
+ /**
613
+ * Remember an order somebody made by hand.
614
+ *
615
+ * Only the list that was dragged, and only the ids it held: a session
616
+ * started tomorrow has no place in an order recorded today, and it
617
+ * should arrive where a new session belongs rather than at the end of a
618
+ * snapshot taken before it existed.
619
+ */
620
+ case "POST /order": {
621
+ const body = await readJson(req);
622
+ const ids = Array.isArray(body.ids)
623
+ ? body.ids.filter((id) => typeof id === "string")
624
+ : undefined;
625
+ if (!ids)
626
+ return send(res, 400, { error: "ids must be an array" });
627
+ if (body.kind === "project") {
628
+ await writePrefs({ projectOrder: ids });
629
+ return send(res, 200, { order: ids });
630
+ }
631
+ const cwd = typeof body.cwd === "string" ? body.cwd : "";
632
+ if (!cwd)
633
+ return send(res, 400, { error: "cwd is required" });
634
+ const order = { ...((await readPrefs()).sessionOrder ?? {}), [cwd]: ids };
635
+ await writePrefs({ sessionOrder: order });
636
+ return send(res, 200, { order: ids });
637
+ }
638
+ case "POST /pick": {
639
+ const body = await readJson(req);
640
+ // The dialog opens on this machine's desktop, which is the same
641
+ // machine the agent already runs on — reachable only with the token.
642
+ const start = typeof body.start === "string" ? body.start : current.session.cwd;
643
+ if (body.kind === "files") {
644
+ const chosen = await pickFiles(start);
645
+ if (!chosen.paths)
646
+ return send(res, 200, chosen);
647
+ // A path under the project comes back relative: it is shorter to
648
+ // read, it survives the project moving, and it is the form the
649
+ // agent's own tools take. Which of the two it is comes from the
650
+ // disk rather than from the name — one dialog returns both.
651
+ const entries = await Promise.all(chosen.paths.map(async (chosenPath) => {
652
+ const dir = await isDirectory(chosenPath);
653
+ const reachable = isReadable(current.harness.workspace, chosenPath);
654
+ const carried = dir || reachable ? undefined : await stageFromDisk(chosenPath);
655
+ return {
656
+ path: within(current.session.cwd, chosenPath),
657
+ dir,
658
+ // Said even when a copy was staged, because the two chips
659
+ // differ and the page has to know which one it is drawing.
660
+ ...(reachable ? {} : { unreachable: true }),
661
+ ...(carried ? { staged: carried } : {}),
662
+ };
663
+ }));
664
+ return send(res, 200, { entries });
665
+ }
666
+ const result = await pickDirectory(start);
667
+ return send(res, 200, {
668
+ ...result,
669
+ ...(result.path
670
+ ? {
671
+ relative: within(current.session.cwd, result.path),
672
+ // A folder cannot be carried, so all this can do is say so
673
+ // before the turn spends itself finding out.
674
+ ...(isReadable(current.harness.workspace, result.path)
675
+ ? {}
676
+ : { unreachable: true }),
677
+ }
678
+ : {}),
679
+ });
680
+ }
681
+ case "GET /fs":
682
+ return send(res, 200, await listDirectories(url.searchParams.get("path") ?? undefined));
683
+ // The project's own tree, for the composer's reference menu. Scoped to
684
+ // the session's working directory and answered in relative paths: the
685
+ // page has no business learning where on the disk the project sits.
686
+ case "GET /files": {
687
+ const runner = runnerFor(url.searchParams.get("session"));
688
+ if (!runner)
689
+ return send(res, 404, { error: "no such session" });
690
+ const query = (url.searchParams.get("q") ?? "").trim();
691
+ // Searching and browsing answer in the same shape, so the menu paints
692
+ // one list either way and only the header changes.
693
+ return send(res, 200, query
694
+ ? { query, ...(await searchFiles(runner.session.cwd, query)) }
695
+ : {
696
+ query: "",
697
+ ...(await browseFiles(runner.session.cwd, url.searchParams.get("dir") ?? "")),
698
+ });
699
+ }
700
+ // The other two things a message can point at: an MCP server this
701
+ // harness is connected to, and a plugin it loaded.
702
+ case "GET /context":
703
+ return send(res, 200, contextOffer());
704
+ // The connectors on offer rather than the ones already here: the list
705
+ // ships as data, so this is a read of a file plus what is installed.
706
+ case "GET /registry": {
707
+ const installed = new Set(contextOffer().connectors.map((one) => one.name));
708
+ const entries = await officialConnectors();
709
+ return send(res, 200, {
710
+ connectors: entries.map((entry) => ({
711
+ id: entry.id,
712
+ label: entry.label,
713
+ ...(entry.description ? { description: entry.description } : {}),
714
+ ...(entry.publisher ? { publisher: entry.publisher } : {}),
715
+ ...(entry.docsUrl ? { docsUrl: entry.docsUrl } : {}),
716
+ installed: installed.has(entry.id),
717
+ // Said before the click, not after it. An entry that needs a key
718
+ // fails at connect with a perfectly clear message, but a menu that
719
+ // knew and stayed quiet spent a click to deliver it.
720
+ ...(missingSecrets(entry.config).length
721
+ ? { missingEnv: missingSecrets(entry.config) }
722
+ : {}),
723
+ })),
724
+ });
725
+ }
726
+ /**
727
+ * A connector's vendor icon, fetched by this server and cached on disk.
728
+ *
729
+ * Served from here rather than linked from the page: twelve rows would
730
+ * otherwise be twelve third-party requests, each one telling a vendor
731
+ * that this user opened the menu before connecting anything.
732
+ */
733
+ case "GET /connector-icon": {
734
+ const id = url.searchParams.get("id") ?? "";
735
+ const entry = (await officialConnectors()).find((one) => one.id === id);
736
+ const icon = entry?.siteUrl ? await connectorIcon(entry.id, entry.siteUrl) : undefined;
737
+ // A row without a picture draws a monogram, so this is a normal answer
738
+ // rather than something worth reporting.
739
+ if (!icon)
740
+ return send(res, 404, { error: "no icon" });
741
+ res.writeHead(200, {
742
+ "content-type": icon.type,
743
+ "content-length": icon.bytes.length,
744
+ "cache-control": "private, max-age=86400",
745
+ });
746
+ res.end(icon.bytes);
747
+ return;
748
+ }
749
+ /**
750
+ * Add or remove one connector.
751
+ *
752
+ * Both halves happen: the server is connected now, so its tools are
753
+ * usable in the next turn without a restart, and the declaration is
754
+ * written to the user's config so it comes back tomorrow. Doing only the
755
+ * first would make an install evaporate; only the second would make it
756
+ * look like nothing happened.
757
+ */
758
+ case "POST /connector": {
759
+ const body = await readJson(req);
760
+ const id = typeof body.id === "string" ? body.id.trim() : "";
761
+ if (!id)
762
+ return send(res, 400, { error: "id is required" });
763
+ if (body.remove === true) {
764
+ await current.harness.removeMcpServer(id);
765
+ await updateUserConfig((config) => {
766
+ const servers = { ...config.mcpServers };
767
+ delete servers[id];
768
+ return { ...config, mcpServers: servers };
769
+ });
770
+ return send(res, 200, { removed: id, ...contextOffer() });
771
+ }
772
+ const entry = (await officialConnectors()).find((one) => one.id === id);
773
+ if (!entry)
774
+ return send(res, 404, { error: `no connector named "${id}"` });
775
+ const config = connectorConfig(entry, {
776
+ project: current.session.cwd,
777
+ home: homedir(),
778
+ });
779
+ try {
780
+ const status = await current.harness.addMcpServer(id, config);
781
+ // A server that starts and then fails to speak MCP is worth
782
+ // reporting as the failure it is, rather than leaving a dead entry
783
+ // in the menu and a line in the config file.
784
+ //
785
+ // A server that asked to be logged in to is not that. It is working
786
+ // exactly as intended and is one browser round trip from being
787
+ // usable, so it stays — rolling it back here would delete the thing
788
+ // the user is about to authorize.
789
+ if (!status.connected && !status.needsAuth) {
790
+ await current.harness.removeMcpServer(id);
791
+ return send(res, 400, { error: status.error ?? `${id} did not start` });
792
+ }
793
+ await updateUserConfig((existing) => ({
794
+ ...existing,
795
+ mcpServers: { ...existing.mcpServers, [id]: config },
796
+ }));
797
+ return send(res, 200, { status, ...contextOffer() });
798
+ }
799
+ catch (err) {
800
+ await current.harness.removeMcpServer(id).catch(() => { });
801
+ return send(res, 400, { error: errorMessage(err) });
802
+ }
803
+ }
804
+ /**
805
+ * Log in to a connector that asked for it.
806
+ *
807
+ * The URL comes back immediately and the flow finishes on its own: the
808
+ * redirect lands on a loopback listener this process opened, not on this
809
+ * server, so there is nothing for the page to post back. It waits for
810
+ * the broadcast instead.
811
+ */
812
+ case "POST /connector/authorize": {
813
+ const body = await readJson(req);
814
+ const id = typeof body.id === "string" ? body.id.trim() : "";
815
+ if (!id)
816
+ return send(res, 400, { error: "id is required" });
817
+ let flow;
818
+ try {
819
+ flow = await current.harness.mcpLogin(id);
820
+ }
821
+ catch (err) {
822
+ return send(res, 400, { error: errorMessage(err) });
823
+ }
824
+ if (flow.method !== "auto" || !flow.url) {
825
+ return send(res, 400, { error: `${id} did not offer a browser login` });
826
+ }
827
+ send(res, 200, { url: flow.url, instructions: flow.instructions });
828
+ // Broadcast either way. The page has a row waiting on this, and a
829
+ // login that was refused has to release it just as much as one that
830
+ // worked — otherwise the button stays disabled until a reload.
831
+ void flow
832
+ .callback()
833
+ .then((result) => {
834
+ if (result.type !== "success") {
835
+ broadcast({
836
+ type: "notice",
837
+ message: `${id} was not authorized: ${result.error ?? "the login was refused"}`,
838
+ });
839
+ }
840
+ broadcast({
841
+ type: "connectors_changed",
842
+ connector: id,
843
+ authorized: result.type === "success",
844
+ ...contextOffer(),
845
+ });
846
+ })
847
+ .catch((err) => {
848
+ broadcast({
849
+ type: "notice",
850
+ message: `${id} could not be authorized: ${errorMessage(err)}`,
851
+ });
852
+ broadcast({
853
+ type: "connectors_changed",
854
+ connector: id,
855
+ authorized: false,
856
+ ...contextOffer(),
857
+ });
858
+ });
859
+ return;
860
+ }
861
+ /** Forget a connector's credential without removing the connector. */
862
+ case "POST /connector/signout": {
863
+ const body = await readJson(req);
864
+ const id = typeof body.id === "string" ? body.id.trim() : "";
865
+ if (!id)
866
+ return send(res, 400, { error: "id is required" });
867
+ const forgotten = await current.harness.mcpLogout(id);
868
+ return send(res, 200, { forgotten, ...contextOffer() });
869
+ }
870
+ case "POST /projects": {
871
+ const body = await readJson(req);
872
+ const dir = typeof body.path === "string" ? body.path.trim() : "";
873
+ if (!dir)
874
+ return send(res, 400, { error: "path is required" });
875
+ try {
876
+ const project = await addProject(dir);
877
+ return send(res, 200, { project });
878
+ }
879
+ catch (err) {
880
+ return send(res, 400, { error: errorMessage(err) });
881
+ }
882
+ }
883
+ case "POST /project": {
884
+ const body = await readJson(req);
885
+ const dir = typeof body.path === "string" ? body.path : "";
886
+ // Any tab: switching projects rebuilds the harness under every runner.
887
+ if (anyRunning())
888
+ return send(res, 409, { error: "a turn is running" });
889
+ if (body.remove === true) {
890
+ return send(res, 200, { removed: await removeProject(dir) });
891
+ }
892
+ /**
893
+ * Throw away everything recorded about one tree.
894
+ *
895
+ * Separate from `remove`, and deliberately harder to reach, because
896
+ * the two are not degrees of the same act: forgetting a project tidies
897
+ * a list, and this destroys transcripts. The directory and its files
898
+ * are untouched either way — what goes is what the harness wrote about
899
+ * the work.
900
+ */
901
+ if (body.purge === true) {
902
+ const deleted = await current.harness.deleteSessionsIn(dir);
903
+ for (const id of deleted)
904
+ runners.delete(id);
905
+ // Their artifact logs went with them, and a listing built a moment
906
+ // ago still names them.
907
+ cachedLogs = undefined;
908
+ const here = current.session.cwd === dir;
909
+ // Whether the window ended up somewhere else, which is also what
910
+ // decides whether the directory is forgotten: staying and then
911
+ // dropping it from the list would forget the project under the
912
+ // window showing it.
913
+ let left = !here;
914
+ let moved;
915
+ if (here) {
916
+ // A listed directory that no longer exists is not a destination —
917
+ // the switch throws and the window is left pointing at transcripts
918
+ // that are gone — so those are skipped, each candidate is tried in
919
+ // turn, and this directory is the last resort: nothing was deleted
920
+ // from it but records, and it is where the window already is.
921
+ const others = (await listProjects()).filter((project) => project.path !== dir && !project.missing);
922
+ for (const target of [...others.map((project) => project.path), dir]) {
923
+ try {
924
+ moved = await switchProject(target);
925
+ left = target !== dir;
926
+ break;
927
+ }
928
+ catch {
929
+ // Gone since it was listed. Try the next one.
930
+ }
931
+ }
932
+ // Nowhere reachable at all. The harness for this tree is already
933
+ // built, so it gets a fresh session rather than the window being
934
+ // left on a deleted one.
935
+ if (!moved)
936
+ moved = await switchSession({ action: "new" });
937
+ }
938
+ else if (deleted.includes(focused)) {
939
+ // Not the open project, but the focused tab was showing one of its
940
+ // sessions. Focus has to move off it before any route that falls
941
+ // back to `current` answers for a transcript that is gone.
942
+ const next = runners.values().next().value;
943
+ if (next) {
944
+ focused = next.session.id;
945
+ current = { session: next.session, agent: next.agent, harness: next.harness };
946
+ }
947
+ else {
948
+ moved = await switchSession({ action: "new" });
949
+ }
950
+ }
951
+ if (left)
952
+ await removeProject(dir);
953
+ return send(res, 200, {
954
+ deleted: deleted.length,
955
+ removed: left,
956
+ open: [...runners.keys()],
957
+ ...(moved ? { state: moved } : {}),
958
+ });
959
+ }
960
+ try {
961
+ const resume = typeof body.session === "string" ? body.session : undefined;
962
+ return send(res, 200, await switchProject(dir, resume));
963
+ }
964
+ catch (err) {
965
+ return send(res, 400, { error: errorMessage(err) });
966
+ }
967
+ }
968
+ case "GET /routines":
969
+ // The scheduler is real machinery; a panel that invented its own list
970
+ // would drift from what actually fires.
971
+ return send(res, 200, { routines: await current.harness.scheduler.status() });
972
+ case "POST /routine": {
973
+ const body = await readJson(req);
974
+ const name = typeof body.name === "string" ? body.name : "";
975
+ if (!name)
976
+ return send(res, 400, { error: "name is required" });
977
+ if (anyRunning())
978
+ return send(res, 409, { error: "a turn is running" });
979
+ try {
980
+ const run = await current.harness.scheduler.runNow(name);
981
+ broadcast({
982
+ type: "notice",
983
+ message: `routine ${name} ${run.ok ? "finished" : "failed"} in ${run.turns} turns`,
984
+ });
985
+ return send(res, 200, { run });
986
+ }
987
+ catch (err) {
988
+ return send(res, 400, { error: errorMessage(err) });
989
+ }
990
+ }
991
+ case "GET /settings":
992
+ return send(res, 200, {
993
+ cwd: current.session.cwd,
994
+ workspace: current.harness.workspace,
995
+ // Beside the full list, not instead of it: what the page needs to
996
+ // show is which of those roots someone chose, because those are the
997
+ // only ones a control on that page can change.
998
+ configuredRoots: current.harness.configuredWritableRoots(),
999
+ permissions: current.session.permissions.listRules(),
1000
+ // The whole status, not just the path: the page needs to know
1001
+ // whether the switch it draws is the user's to flip.
1002
+ audit: await current.harness.auditStatus(),
1003
+ settingsPaths: {
1004
+ user: current.harness.settings.pathFor("user"),
1005
+ project: current.harness.settings.pathFor("project"),
1006
+ local: current.harness.settings.pathFor("local"),
1007
+ },
1008
+ });
1009
+ /**
1010
+ * Replace the write roots this machine allows.
1011
+ *
1012
+ * Only the configured ones travel here. The rest of that list is
1013
+ * contributed by installed plugins and is rebuilt at launch, so
1014
+ * accepting an edit to it would be accepting a change that silently
1015
+ * reverts.
1016
+ */
1017
+ case "POST /settings/writable-roots": {
1018
+ const body = await readJson(req);
1019
+ if (!Array.isArray(body.roots) || body.roots.some((r) => typeof r !== "string")) {
1020
+ return send(res, 400, { error: "roots must be an array of paths" });
1021
+ }
1022
+ const saved = await current.harness.setWritableRoots(body.roots);
1023
+ return send(res, 200, { roots: saved });
1024
+ }
1025
+ /**
1026
+ * Who is signed in, if anyone.
1027
+ *
1028
+ * The same entry `accountToken` reads, so this answers for the
1029
+ * credential the rest of the app would actually use — an account row
1030
+ * reporting a different one from the thing doing the work would be
1031
+ * worse than none.
1032
+ */
1033
+ case "GET /account": {
1034
+ const credential = await current.harness.auth.store.get("official").catch(() => undefined);
1035
+ if (credential?.type === "oauth") {
1036
+ // The site as well as the account: the row's menu leads to the
1037
+ // dashboard, and where that lives is this process's setting rather
1038
+ // than something a page should carry a copy of.
1039
+ return send(res, 200, {
1040
+ signedIn: true,
1041
+ provider: "official",
1042
+ site: siteUrl(),
1043
+ ...accountIdentity(credential.access),
1044
+ });
1045
+ }
1046
+ // Named even when signed out, so the button knows which sign-in to
1047
+ // start without the page hard-coding a provider id of its own.
1048
+ return send(res, 200, { signedIn: false, provider: "official" });
1049
+ }
1050
+ /**
1051
+ * What the account has left, and what it could buy.
1052
+ *
1053
+ * Proxied rather than called from the page. The gateway wants the
1054
+ * account's bearer token, and that token is the one thing that must not
1055
+ * reach a browser tab: anything able to read the page could then spend
1056
+ * money as well as read a transcript.
1057
+ *
1058
+ * Every failure answers 200 with a reason rather than a status. The
1059
+ * caller is a paywall that has to render something either way, and "the
1060
+ * shop cannot be reached" is a sentence someone can act on where a 502
1061
+ * is not.
1062
+ */
1063
+ case "GET /billing": {
1064
+ const token = await current.harness.accountToken();
1065
+ if (!token)
1066
+ return send(res, 200, { signedIn: false, provider: "official" });
1067
+ const [state, catalogue] = await Promise.all([
1068
+ gatewayJson("/api/v1/billing/balance", { token }),
1069
+ gatewayJson("/api/v1/billing/plans"),
1070
+ ]);
1071
+ if (!state.ok) {
1072
+ // 501 is a deployment that sells nothing — self-hosted, or a gateway
1073
+ // with no Stripe key. Not an error to report: it is the reason the
1074
+ // buttons should not be on screen at all.
1075
+ return send(res, 200, {
1076
+ signedIn: true,
1077
+ unavailable: state.status === 501 ? "not_for_sale" : "unreachable",
1078
+ error: state.error,
1079
+ site: siteUrl(),
1080
+ });
1081
+ }
1082
+ return send(res, 200, {
1083
+ signedIn: true,
1084
+ balanceMicro: state.value["balanceMicro"] ?? 0,
1085
+ credits: state.value["credits"] ?? 0,
1086
+ ...(state.value["subscription"] ? { subscription: state.value["subscription"] } : {}),
1087
+ // An empty list is the honest answer when the price list cannot be
1088
+ // read; the paywall falls back to sending people to the site, which
1089
+ // is where buying works without this app at all.
1090
+ plans: catalogue.ok ? (catalogue.value["plans"] ?? []) : [],
1091
+ site: siteUrl(),
1092
+ });
1093
+ }
1094
+ /**
1095
+ * A Stripe checkout, as a URL for the browser to open.
1096
+ *
1097
+ * No card field is ever rendered here and none is ever seen: what comes
1098
+ * back is a link to Stripe's own page, opened the way the sign-in is —
1099
+ * in the real browser, where 3-D Secure and a bank's own app can do what
1100
+ * they need to and a local window cannot.
1101
+ */
1102
+ case "POST /billing/checkout": {
1103
+ const body = await readJson(req);
1104
+ const plan = typeof body.plan === "string" ? body.plan : "";
1105
+ if (!plan)
1106
+ return send(res, 400, { error: "which plan?" });
1107
+ const token = await current.harness.accountToken();
1108
+ if (!token)
1109
+ return send(res, 400, { error: "sign in first" });
1110
+ const locale = typeof body.locale === "string" ? body.locale : undefined;
1111
+ const result = await gatewayJson("/api/v1/billing/checkout", {
1112
+ token,
1113
+ body: { plan, ...(locale ? { locale } : {}) },
1114
+ });
1115
+ if (!result.ok)
1116
+ return send(res, 400, { error: result.error });
1117
+ return send(res, 200, { url: result.value["url"] });
1118
+ }
1119
+ /**
1120
+ * Whether transcripts and artifacts are leaving this machine.
1121
+ *
1122
+ * `signedIn` travels beside the decision because the two off-states need
1123
+ * opposite advice and look identical otherwise: "you turned this off" is
1124
+ * fixed by the switch, "you are not signed in" is not, and a toggle that
1125
+ * silently does nothing in the second case is the worst of both.
1126
+ */
1127
+ case "GET /sync": {
1128
+ const status = current.harness.syncStatus();
1129
+ return send(res, 200, {
1130
+ ...status,
1131
+ signedIn: Boolean(await current.harness.accountToken()),
1132
+ });
1133
+ }
1134
+ case "POST /sync": {
1135
+ const body = await readJson(req);
1136
+ if (typeof body.enabled !== "boolean") {
1137
+ return send(res, 400, { error: "enabled must be a boolean" });
1138
+ }
1139
+ await current.harness.setSyncEnabled(body.enabled);
1140
+ const status = current.harness.syncStatus();
1141
+ return send(res, 200, {
1142
+ ...status,
1143
+ signedIn: Boolean(await current.harness.accountToken()),
1144
+ });
1145
+ }
1146
+ /**
1147
+ * Push what is waiting, now.
1148
+ *
1149
+ * The mirror already pushes on its own, so this is not how sync works —
1150
+ * it is how someone finds out that it has, before closing a laptop. That
1151
+ * is not a question a background timer can answer.
1152
+ */
1153
+ case "POST /sync/now": {
1154
+ const pushed = await current.harness.syncNow();
1155
+ if (!pushed) {
1156
+ const status = current.harness.syncStatus();
1157
+ return send(res, 400, {
1158
+ error: status.reason === "no-credential"
1159
+ ? "not signed in, so there is nowhere to push to"
1160
+ : status.reason === "excluded"
1161
+ ? "this project is excluded from sync"
1162
+ : "sync is off",
1163
+ });
1164
+ }
1165
+ return send(res, 200, { pushed: true });
1166
+ }
1167
+ /**
1168
+ * Start or stop recording permission decisions.
1169
+ *
1170
+ * Its own route rather than a field on a settings PUT, because that is
1171
+ * the shape every other switch on that page already has, and because
1172
+ * the refusal — a project that decided this for you — needs somewhere to
1173
+ * be said.
1174
+ */
1175
+ case "POST /audit": {
1176
+ const body = await readJson(req);
1177
+ if (typeof body.enabled !== "boolean") {
1178
+ return send(res, 400, { error: "enabled must be a boolean" });
1179
+ }
1180
+ try {
1181
+ return send(res, 200, await current.harness.setAudit(body.enabled));
1182
+ }
1183
+ catch (err) {
1184
+ return send(res, 400, { error: errorMessage(err) });
1185
+ }
1186
+ }
1187
+ /**
1188
+ * The trail, as a file.
1189
+ *
1190
+ * The same JSON lines that are on disk, with the session id on every one
1191
+ * — not a report generated for the occasion. An export whose shape
1192
+ * differs from the record it came from is a second format to keep
1193
+ * honest, and the first question anybody asks of an audit file is
1194
+ * whether it is the audit.
1195
+ *
1196
+ * `session=all` walks every session on this machine. That is a file read
1197
+ * per session and nobody does it twice a minute; it is the answer to
1198
+ * "give me the record", which is what an export is for.
1199
+ */
1200
+ case "GET /audit/export": {
1201
+ const asked = url.searchParams.get("session") ?? current.session.id;
1202
+ const ids = asked === "all"
1203
+ ? (await current.harness.listSessions(Number.MAX_SAFE_INTEGER))
1204
+ .sort((a, b) => a.updatedAt - b.updatedAt)
1205
+ .map((meta) => meta.id)
1206
+ : [asked];
1207
+ let body = "";
1208
+ for (const id of ids) {
1209
+ for (const entry of await current.harness.decisions(id)) {
1210
+ body += `${JSON.stringify({ ...entry, session: id })}\n`;
1211
+ }
1212
+ }
1213
+ // Named for what it holds and when it was taken: an audit file called
1214
+ // audit.jsonl in a downloads folder full of them is one nobody can
1215
+ // place afterwards.
1216
+ const stamp = new Date().toISOString().slice(0, 10);
1217
+ const name = `oharness-audit-${asked === "all" ? "all" : asked}-${stamp}.jsonl`;
1218
+ res.writeHead(200, {
1219
+ "content-type": "application/x-ndjson; charset=utf-8",
1220
+ "content-length": Buffer.byteLength(body),
1221
+ "content-disposition": `attachment; filename="${name}"`,
1222
+ "cache-control": "private, no-store",
1223
+ });
1224
+ return void res.end(body);
1225
+ }
1226
+ /**
1227
+ * One session's permission trail.
1228
+ *
1229
+ * Scoped to a session because that is where it is written and how it is
1230
+ * meant to be read — beside the conversation it describes. Defaults to
1231
+ * the focused one so the panel does not have to know an id to open.
1232
+ */
1233
+ case "GET /audit": {
1234
+ const id = url.searchParams.get("session") ?? current.session.id;
1235
+ return send(res, 200, {
1236
+ session: id,
1237
+ entries: await current.harness.decisions(id),
1238
+ });
1239
+ }
1240
+ // The key lives behind this rather than in the HTML: a build that has one
1241
+ // still hands it to nobody until they have opted in.
1242
+ case "GET /analytics":
1243
+ return send(res, 200, await analyticsState());
1244
+ case "POST /analytics": {
1245
+ const body = await readJson(req);
1246
+ if (typeof body.enabled !== "boolean") {
1247
+ return send(res, 400, { error: "enabled must be a boolean" });
1248
+ }
1249
+ await writePrefs({ analytics: body.enabled });
1250
+ return send(res, 200, await analyticsState());
1251
+ }
1252
+ case "GET /changes": {
1253
+ const runner = runnerFor(url.searchParams.get("session"));
1254
+ return send(res, 200, { changes: runner ? runner.ledger.list() : [] });
1255
+ }
1256
+ case "GET /sessions": {
1257
+ const prefs = await readPrefs();
1258
+ const pinned = new Set(prefs.pinnedSessions ?? []);
1259
+ return send(res, 200, {
1260
+ // What somebody arranged by hand travels beside the list rather than
1261
+ // being applied to it here: the sidebar groups by project and hides
1262
+ // the tail of each group, so it is the only side that knows which
1263
+ // rows an order has to survive.
1264
+ order: prefs.sessionOrder ?? {},
1265
+ sessions: (await current.harness.listSessions(50)).map((meta) => ({
1266
+ id: meta.id,
1267
+ pinned: pinned.has(meta.id),
1268
+ title: meta.title,
1269
+ updatedAt: meta.updatedAt,
1270
+ turns: meta.turns,
1271
+ costUsd: meta.costUsd,
1272
+ current: meta.id === current.session.id,
1273
+ forkedFrom: meta.forkedFrom,
1274
+ // Sessions are stored per home, not per project, so this list spans
1275
+ // every tree opened here. The page groups by it rather than showing
1276
+ // one project's transcripts as another's history.
1277
+ cwd: meta.cwd,
1278
+ })),
1279
+ });
1280
+ }
1281
+ /**
1282
+ * What has been spent on this machine, and on what.
1283
+ *
1284
+ * Read back from the session metadata on disk rather than kept as a
1285
+ * running total. A counter of its own would have to survive restarts,
1286
+ * deleted sessions and runs started from the terminal, and it would be
1287
+ * wrong in a different way after each of those — the transcripts are the
1288
+ * only record that cannot drift from what actually happened.
1289
+ *
1290
+ * Sessions open in this process are laid over their stored copy: one
1291
+ * mid-turn has already spent money its metadata will not know about
1292
+ * until the turn lands, and the page asking is the page that spent it.
1293
+ */
1294
+ case "GET /usage": {
1295
+ // How far back the page is asking about. Everything below — the
1296
+ // totals, the models, the sessions and the curve — is that window and
1297
+ // nothing else, because a figure that answers a different question
1298
+ // from the chart under it is worse than no figure.
1299
+ const range = USAGE_RANGES[url.searchParams.get("range") ?? ""] ?? USAGE_RANGES["30d"];
1300
+ const since = range.days ? Date.now() - range.days * DAY_MS : 0;
1301
+ const stored = await current.harness.listSessions(USAGE_SESSION_SCAN);
1302
+ const rows = new Map();
1303
+ for (const meta of stored) {
1304
+ rows.set(meta.id, {
1305
+ id: meta.id,
1306
+ title: meta.title,
1307
+ model: meta.model,
1308
+ updatedAt: meta.updatedAt,
1309
+ turns: meta.turns,
1310
+ costUsd: meta.costUsd || 0,
1311
+ ...tokensOf(meta.usage),
1312
+ });
1313
+ }
1314
+ for (const runner of runners.values()) {
1315
+ const sess = runner.session;
1316
+ rows.set(sess.id, {
1317
+ id: sess.id,
1318
+ title: sess.title,
1319
+ model: sess.model.id,
1320
+ // A session opened but not yet spoken to has no stored copy and no
1321
+ // last-updated time of its own; it is here, so it is now.
1322
+ updatedAt: rows.get(sess.id)?.updatedAt ?? Date.now(),
1323
+ turns: sess.turns,
1324
+ costUsd: sess.costUsd,
1325
+ ...tokensOf(sess.usage),
1326
+ });
1327
+ }
1328
+ // Sessions that never sent anything are dropped: they contribute zero
1329
+ // to every total here, and a page about what was spent that opens with
1330
+ // a screen of "$0.0000 · 0 tokens" buries the ones that cost money.
1331
+ // They are still counted in `scanned`, which is what says how far back
1332
+ // the numbers above them reach.
1333
+ const list = [...rows.values()]
1334
+ .filter((row) => row.updatedAt >= since)
1335
+ .filter((row) => row.costUsd > 0 || row.input || row.output || row.cacheRead || row.cacheWrite)
1336
+ .sort((a, b) => b.updatedAt - a.updatedAt);
1337
+ const zero = { costUsd: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1338
+ const add = (into, row) => {
1339
+ into.costUsd += row.costUsd;
1340
+ into.input += row.input;
1341
+ into.output += row.output;
1342
+ into.cacheRead += row.cacheRead;
1343
+ into.cacheWrite += row.cacheWrite;
1344
+ return into;
1345
+ };
1346
+ const totals = list.reduce(add, { ...zero });
1347
+ // Per model, because that is the axis a bill is read along: the same
1348
+ // work costs an order of magnitude more on one model than another, and
1349
+ // the only way to see that is to have them side by side.
1350
+ const byModel = new Map();
1351
+ for (const row of list) {
1352
+ const bucket = byModel.get(row.model)
1353
+ ?? { model: row.model, sessions: 0, lastUsedAt: 0, ...zero };
1354
+ bucket.sessions += 1;
1355
+ bucket.lastUsedAt = Math.max(bucket.lastUsedAt, row.updatedAt);
1356
+ byModel.set(row.model, add(bucket, row));
1357
+ }
1358
+ return send(res, 200, {
1359
+ range: range.id,
1360
+ series: usageSeries(list, since || (list.length ? list[list.length - 1].updatedAt : Date.now())),
1361
+ totals: { ...totals, sessions: list.length },
1362
+ // Sorted by what was spent, not by name: the row worth seeing first
1363
+ // is the expensive one.
1364
+ models: [...byModel.values()].sort((a, b) => b.costUsd - a.costUsd),
1365
+ // A page of them is a page nobody reads. The rest are counted in the
1366
+ // totals above, which is where a number that large belongs.
1367
+ sessions: list.slice(0, 20).map((row) => ({
1368
+ ...row,
1369
+ current: row.id === current.session.id,
1370
+ })),
1371
+ scanned: rows.size,
1372
+ // Said out loud so a total that stops short is not read as final.
1373
+ truncated: stored.length >= USAGE_SESSION_SCAN,
1374
+ });
1375
+ }
1376
+ case "GET /models": {
1377
+ // Which providers actually have credentials, so the picker can say
1378
+ // "sign in" instead of offering a model whose first request 401s.
1379
+ const configured = await current.harness.configuredProviders();
1380
+ // Where each credential came from. The engine already distinguishes an
1381
+ // environment variable from a stored key; the panel could not, so a
1382
+ // provider connected purely by env looked the same as an unconnected
1383
+ // one with a stale entry.
1384
+ const sources = new Map((await current.harness.authStatus()).map((entry) => [entry.providerId, entry.source]));
1385
+ // Who is signed in, where anyone is. "Connected" answers whether a
1386
+ // credential exists; on an account-backed plan the useful question is
1387
+ // *whose* account, which is the one thing a stale login gets wrong.
1388
+ const accounts = new Map();
1389
+ for (const provider of current.harness.auth.list()) {
1390
+ const credential = await current.harness.auth.store.get(provider.id);
1391
+ const email = credential?.type === "oauth" ? accountEmail(credential.access) : undefined;
1392
+ if (email)
1393
+ accounts.set(provider.id, email);
1394
+ }
1395
+ return send(res, 200, {
1396
+ models: current.harness.listModels().map((model) => ({
1397
+ id: model.id,
1398
+ provider: model.provider,
1399
+ contextWindow: model.contextWindow,
1400
+ configured: configured.has(model.provider),
1401
+ current: model.id === current.session.model.id,
1402
+ })),
1403
+ providers: current.harness.listCatalogProviders().map((entry) => ({
1404
+ id: entry.id,
1405
+ label: entry.label,
1406
+ envVars: entry.envVars,
1407
+ docsUrl: entry.docsUrl,
1408
+ // Where it would be reached. The only useful thing to say when a
1409
+ // local server discovers nothing is which address was tried.
1410
+ baseUrl: entry.baseUrl,
1411
+ popular: Boolean(entry.popular),
1412
+ recommended: Boolean(entry.recommended),
1413
+ description: entry.description,
1414
+ source: sources.get(entry.id),
1415
+ account: accounts.get(entry.id),
1416
+ configured: configured.has(entry.id),
1417
+ // Which vendors can be signed into from here rather than pasted a
1418
+ // key into. The panel has no other way to tell, and offering a key
1419
+ // field for an account-backed plan is a dead end.
1420
+ signIn: signInMethod(entry.id),
1421
+ })),
1422
+ });
1423
+ }
1424
+ case "POST /auth": {
1425
+ const body = await readJson(req);
1426
+ const providerId = typeof body.provider === "string" ? body.provider : "";
1427
+ const key = typeof body.key === "string" ? body.key.trim() : "";
1428
+ if (!providerId || !key)
1429
+ return send(res, 400, { error: "provider and key are required" });
1430
+ const provider = current.harness.auth.get(providerId);
1431
+ if (!provider)
1432
+ return send(res, 404, { error: `unknown provider: ${providerId}` });
1433
+ const index = provider.methods.findIndex((method) => method.type === "api");
1434
+ if (index === -1) {
1435
+ return send(res, 400, {
1436
+ error: `${provider.label} does not take an API key — sign in from the terminal with \`oharness auth login ${providerId}\``,
1437
+ });
1438
+ }
1439
+ // Same path the CLI uses, so the credential lands in auth.json with
1440
+ // the same shape and the same 0600 permissions.
1441
+ await current.harness.auth.submit(providerId, index, { key });
1442
+ // Answer as soon as the credential is stored. Discovery reaches out to
1443
+ // the vendor, and holding the response until it finishes made the save
1444
+ // button depend on a third party being reachable.
1445
+ send(res, 200, { ok: true });
1446
+ void current.harness
1447
+ .refreshModels(providerId)
1448
+ .then((found) => {
1449
+ broadcast({
1450
+ type: "models_changed",
1451
+ provider: providerId,
1452
+ discovered: found.length,
1453
+ });
1454
+ })
1455
+ .catch((err) => {
1456
+ broadcast({
1457
+ type: "notice",
1458
+ message: `signed in to ${providerId}, but discovering its models failed: ${errorMessage(err)}`,
1459
+ });
1460
+ });
1461
+ return;
1462
+ }
1463
+ case "POST /signin": {
1464
+ const body = await readJson(req);
1465
+ const providerId = typeof body.provider === "string" ? body.provider : "";
1466
+ const method = providerId ? signInMethod(providerId) : undefined;
1467
+ if (!method)
1468
+ return send(res, 400, { error: "that provider has no sign-in" });
1469
+ // Whatever the method asked for — an email for the account plans. Sent
1470
+ // through verbatim so a provider can add a field without this endpoint
1471
+ // learning about it.
1472
+ const answers = {};
1473
+ for (const [key, value] of Object.entries(body.answers ?? {})) {
1474
+ if (typeof value === "string")
1475
+ answers[key] = value;
1476
+ }
1477
+ try {
1478
+ const flow = await current.harness.auth.authorize(providerId, method.index, answers);
1479
+ if (flow.method !== "code") {
1480
+ return send(res, 400, {
1481
+ error: `${providerId} signs in a way this page cannot drive — use \`oharness auth login ${providerId}\``,
1482
+ });
1483
+ }
1484
+ // The callback is a closure over the flow's state, so it has to
1485
+ // outlive this request. Kept in memory only: it is worthless after
1486
+ // the code is used, and writing a half-finished login to disk would
1487
+ // leave one there whenever the tab is closed mid-flow.
1488
+ const id = randomUUID();
1489
+ pendingSignIns.set(id, { provider: providerId, callback: flow.callback });
1490
+ setTimeout(() => pendingSignIns.delete(id), SIGNIN_TTL_MS).unref?.();
1491
+ return send(res, 200, { flow: id, instructions: flow.instructions });
1492
+ }
1493
+ catch (err) {
1494
+ return send(res, 400, { error: errorMessage(err) });
1495
+ }
1496
+ }
1497
+ case "POST /signin/browser": {
1498
+ const body = await readJson(req);
1499
+ const providerId = typeof body.provider === "string" ? body.provider : "";
1500
+ // The same providers the emailed code serves: this is the OHarness
1501
+ // account, and a vendor with no account here has no site to go to.
1502
+ if (!providerId || !signInMethod(providerId)) {
1503
+ return send(res, 400, { error: "that provider has no sign-in" });
1504
+ }
1505
+ // Built from the host the request came in on, so the browser is sent
1506
+ // back to the address this server is actually reachable at. It
1507
+ // defaults to 4310 but falls back to whatever was free when 4310 was
1508
+ // taken, so the running process is still the only thing that knows.
1509
+ const host = req.headers.host ?? "127.0.0.1";
1510
+ // Asked before a tab is opened, not after. The site redirects a
1511
+ // session only to loopback — anything else would make it an open
1512
+ // redirect that hands out sessions — so a UI reached over a LAN
1513
+ // address or a tunnel cannot use this route at all. Left to the site,
1514
+ // the refusal arrives as a 400 in a tab the user has already switched
1515
+ // to, with the emailed code that would have worked nowhere in sight.
1516
+ if (!isLoopbackHost(host)) {
1517
+ return send(res, 400, {
1518
+ error: `this window is open at ${host}, and the sign-in can only come back to a copy reached at localhost — use the emailed code instead`,
1519
+ fallback: "code",
1520
+ });
1521
+ }
1522
+ const state = randomUUID() + randomUUID();
1523
+ pendingBrowserSignIns.set(state, { provider: providerId, started: Date.now() });
1524
+ setTimeout(() => pendingBrowserSignIns.delete(state), BROWSER_SIGNIN_TTL_MS).unref?.();
1525
+ const origin = `http://${host}`;
1526
+ const locale = typeof body.locale === "string" ? body.locale : undefined;
1527
+ return send(res, 200, {
1528
+ url: browserSignInUrl({
1529
+ state,
1530
+ redirectUri: `${origin}/signin/handoff`,
1531
+ ...(locale ? { locale } : {}),
1532
+ }),
1533
+ site: siteUrl(),
1534
+ });
1535
+ }
1536
+ case "POST /signin/code": {
1537
+ const body = await readJson(req);
1538
+ const id = typeof body.flow === "string" ? body.flow : "";
1539
+ const code = typeof body.code === "string" ? body.code.trim() : "";
1540
+ const pending = pendingSignIns.get(id);
1541
+ if (!pending)
1542
+ return send(res, 400, { error: "that sign-in expired — start it again" });
1543
+ if (!code)
1544
+ return send(res, 400, { error: "code is required" });
1545
+ try {
1546
+ const result = await pending.callback(code);
1547
+ if (result.type !== "success") {
1548
+ // The flow stays open: a mistyped code should cost a retry, not
1549
+ // another email.
1550
+ return send(res, 400, { error: result.error ?? "that code was not accepted" });
1551
+ }
1552
+ await current.harness.auth.complete(pending.provider, result);
1553
+ }
1554
+ catch (err) {
1555
+ return send(res, 400, { error: errorMessage(err) });
1556
+ }
1557
+ pendingSignIns.delete(id);
1558
+ send(res, 200, { ok: true });
1559
+ // Same as a saved key: answer first, then go ask the gateway what the
1560
+ // account can actually call.
1561
+ void current.harness
1562
+ .refreshModels(pending.provider)
1563
+ .then((found) => {
1564
+ broadcast({
1565
+ type: "models_changed",
1566
+ provider: pending.provider,
1567
+ discovered: found.length,
1568
+ });
1569
+ })
1570
+ .catch((err) => {
1571
+ broadcast({
1572
+ type: "notice",
1573
+ message: `signed in to ${pending.provider}, but discovering its models failed: ${errorMessage(err)}`,
1574
+ });
1575
+ });
1576
+ return;
1577
+ }
1578
+ case "POST /logout": {
1579
+ const body = await readJson(req);
1580
+ const providerId = typeof body.provider === "string" ? body.provider : "";
1581
+ if (!providerId)
1582
+ return send(res, 400, { error: "provider is required" });
1583
+ const removed = await current.harness.auth.logout(providerId);
1584
+ return send(res, 200, { removed });
1585
+ }
1586
+ case "POST /models/refresh": {
1587
+ const body = await readJson(req);
1588
+ const providerId = typeof body.provider === "string" ? body.provider : undefined;
1589
+ try {
1590
+ // Asks each vendor's own /models endpoint. Model ids change faster
1591
+ // than this package ships, so discovery is the only list that can be
1592
+ // right — the built-in catalog carries providers, not model names.
1593
+ const found = await current.harness.refreshModels(providerId);
1594
+ return send(res, 200, { discovered: found.length });
1595
+ }
1596
+ catch (err) {
1597
+ return send(res, 400, { error: errorMessage(err) });
1598
+ }
1599
+ }
1600
+ case "POST /model": {
1601
+ const body = await readJson(req);
1602
+ const runner = runnerFor(body.session);
1603
+ if (!runner)
1604
+ return send(res, 404, { error: "no such session" });
1605
+ if (runner.running)
1606
+ return send(res, 409, { error: "a turn is running" });
1607
+ if (typeof body.id !== "string")
1608
+ return send(res, 400, { error: "id is required" });
1609
+ try {
1610
+ const change = runner.session.setModel(body.id);
1611
+ // Remembered for the sessions started after this one, here and in the
1612
+ // next run: picking a model is a decision, not a per-session detail.
1613
+ preferredModel = change.to.id;
1614
+ void writePrefs({ model: preferredModel });
1615
+ broadcast({
1616
+ type: "model_changed",
1617
+ session: runner.session.id,
1618
+ from: change.from.id,
1619
+ to: change.to.id,
1620
+ });
1621
+ return send(res, 200, state(runner));
1622
+ }
1623
+ catch (err) {
1624
+ return send(res, 400, { error: errorMessage(err) });
1625
+ }
1626
+ }
1627
+ case "POST /mode": {
1628
+ const body = await readJson(req);
1629
+ const runner = runnerFor(body.session);
1630
+ if (!runner)
1631
+ return send(res, 404, { error: "no such session" });
1632
+ const mode = String(body.mode ?? "");
1633
+ if (!PERMISSION_MODES.includes(mode)) {
1634
+ return send(res, 400, { error: `unknown mode: ${mode}` });
1635
+ }
1636
+ runner.session.permissions.setMode(mode);
1637
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
1638
+ return send(res, 200, state(runner));
1639
+ }
1640
+ case "POST /session": {
1641
+ const body = await readJson(req);
1642
+ // Closing frees the runner but leaves the transcript on disk, so the
1643
+ // session reopens from the sidebar exactly as it was.
1644
+ if (body.action === "close") {
1645
+ const id = String(body.id ?? "");
1646
+ const runner = runners.get(id);
1647
+ runner?.running?.abort();
1648
+ runners.delete(id);
1649
+ // Its artifacts move from memory to the log half of the catalogue,
1650
+ // and a fold taken before this session existed does not have them.
1651
+ cachedLogs = undefined;
1652
+ if (focused === id)
1653
+ focused = [...runners.keys()][0] ?? focused;
1654
+ return send(res, 200, { closed: Boolean(runner), open: [...runners.keys()] });
1655
+ }
1656
+ // Deleting throws the transcript away for good, so unlike `close` it
1657
+ // refuses to touch a session that is mid-turn: aborting only asks the
1658
+ // loop to stop, and a turn still writing would recreate the log we
1659
+ // just removed.
1660
+ if (body.action === "delete") {
1661
+ const id = String(body.id ?? "");
1662
+ if (!id)
1663
+ return send(res, 400, { error: "id is required" });
1664
+ if (runners.get(id)?.running)
1665
+ return send(res, 409, { error: "session is busy" });
1666
+ // Reported rather than blocked: a fork records its parent, so the
1667
+ // page can say what the lineage loses instead of the branches
1668
+ // quietly pointing at nothing.
1669
+ const forks = (await current.harness.store.children(id)).length;
1670
+ const deleted = await current.harness.deleteSession(id);
1671
+ // A session nobody has spoken to has nothing on disk yet — the store
1672
+ // writes on the first message — but it is still a session someone
1673
+ // opened, and it is exactly the kind that gets thrown away when the
1674
+ // workspace under it changes. What makes this a 404 is nothing
1675
+ // knowing about it at all, not the absence of a transcript.
1676
+ if (!deleted && !runners.has(id))
1677
+ return send(res, 404, { error: "no such session" });
1678
+ runners.delete(id);
1679
+ // Its artifact log went with it, and a fold taken a moment ago
1680
+ // still names it.
1681
+ cachedLogs = undefined;
1682
+ if (focused === id)
1683
+ focused = [...runners.keys()][0] ?? focused;
1684
+ // `current` is what routes that name no session fall back to. Left
1685
+ // pointing at a deleted one it would answer for a transcript that is
1686
+ // no longer on disk, so it moves to whatever is still open.
1687
+ if (current.session.id === id) {
1688
+ const next = runners.get(focused);
1689
+ if (next)
1690
+ current = { session: next.session, agent: next.agent, harness: next.harness };
1691
+ }
1692
+ return send(res, 200, { deleted: true, forks, open: [...runners.keys()] });
1693
+ }
1694
+ if (body.action === "focus") {
1695
+ const runner = runnerFor(body.id);
1696
+ if (!runner)
1697
+ return send(res, 404, { error: "no such session" });
1698
+ focused = runner.session.id;
1699
+ // Tabs can sit in different trees, so focus moves the fallback every
1700
+ // route without a session name uses — which project is current, and
1701
+ // which tree the folder dialog and the file search start from.
1702
+ current = { session: runner.session, agent: runner.agent, harness: runner.harness };
1703
+ return send(res, 200, state(runner));
1704
+ }
1705
+ return send(res, 200, await switchSession(body));
1706
+ }
1707
+ case "GET /open":
1708
+ // What the page should draw tabs for after a reload.
1709
+ return send(res, 200, {
1710
+ open: [...runners.values()].map((runner) => ({
1711
+ id: runner.session.id,
1712
+ title: runner.session.title,
1713
+ cwd: runner.session.cwd,
1714
+ busy: Boolean(runner.running),
1715
+ current: runner.session.id === focused,
1716
+ })),
1717
+ });
1718
+ /**
1719
+ * Rename a session. The focused one, or any other by id.
1720
+ *
1721
+ * The id arrived with the sidebar's row menu: renaming from a list means
1722
+ * renaming something other than what is on screen, and a route that
1723
+ * always renamed `current` would have quietly retitled the open session
1724
+ * instead of the row that was clicked.
1725
+ */
1726
+ case "POST /rename": {
1727
+ const body = await readJson(req);
1728
+ const title = typeof body.title === "string" ? body.title.trim() : "";
1729
+ if (!title)
1730
+ return send(res, 400, { error: "title is required" });
1731
+ const id = typeof body.id === "string" && body.id ? body.id : current.session.id;
1732
+ const renamed = await current.harness.renameSession(id, title);
1733
+ if (!renamed)
1734
+ return send(res, 404, { error: "no such session" });
1735
+ // The one on screen carries its title in memory too, or the tab keeps
1736
+ // the old one until something else reloads it.
1737
+ const runner = runnerFor(id);
1738
+ if (runner)
1739
+ runner.session.title = title;
1740
+ return send(res, 200, { ok: true, id, title });
1741
+ }
1742
+ case "POST /upload": {
1743
+ const name = String(req.headers["x-filename"] ?? "file");
1744
+ const mediaType = String(req.headers["content-type"] ?? "application/octet-stream");
1745
+ const runner = runnerFor(url.searchParams.get("session"));
1746
+ // Refused here rather than at send time, while the person is still
1747
+ // looking at the file they chose: a model that cannot read an image
1748
+ // answers a screenshot with a guess about its filename, which reads
1749
+ // like a bad answer rather than a wrong model. Only a model the
1750
+ // catalog documents as text-only is refused — a discovered endpoint
1751
+ // that published no capabilities at all has said nothing, and
1752
+ // refusing on a guess would break the common case.
1753
+ const spec = runner?.session.model;
1754
+ if (mediaType.startsWith("image/") &&
1755
+ spec &&
1756
+ !spec.supportsImages &&
1757
+ !spec.capabilitiesAssumed) {
1758
+ return send(res, 400, {
1759
+ error: `${spec.displayName ?? spec.id} cannot read images — switch models to attach one`,
1760
+ });
1761
+ }
1762
+ const data = await readBody(req, MAX_UPLOAD_BYTES);
1763
+ const id = randomUUID();
1764
+ attachments.set(id, { name, mediaType, data, at: Date.now() });
1765
+ sweepAttachments();
1766
+ return send(res, 200, {
1767
+ id,
1768
+ name,
1769
+ mediaType,
1770
+ bytes: data.byteLength,
1771
+ kind: mediaType.startsWith("image/") ? "image" : "file",
1772
+ });
1773
+ }
1774
+ /**
1775
+ * A staged file the composer no longer points at.
1776
+ *
1777
+ * Best effort, and deliberately quiet about a miss: the page has already
1778
+ * dropped the chip, so nothing here can fail in a way the person could
1779
+ * act on. Without it the bytes would sit in memory until the sweep, and
1780
+ * removing a 12MB screenshot would free nothing for half an hour.
1781
+ */
1782
+ case "POST /upload/drop": {
1783
+ const body = await readJson(req);
1784
+ if (typeof body.id === "string")
1785
+ attachments.delete(body.id);
1786
+ return send(res, 200, { ok: true });
1787
+ }
1788
+ case "POST /prompt": {
1789
+ const body = await readJson(req);
1790
+ const runner = runnerFor(body.session);
1791
+ if (!runner)
1792
+ return send(res, 404, { error: "no such session" });
1793
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1794
+ const ids = Array.isArray(body.attachments) ? body.attachments : [];
1795
+ const references = readReferences(body.references);
1796
+ if (!prompt && !ids.length && !references.length) {
1797
+ return send(res, 400, { error: "prompt is required" });
1798
+ }
1799
+ // Per runner, not global: a turn in one tab must not block another.
1800
+ if (runner.running)
1801
+ return send(res, 409, { error: "a turn is already running" });
1802
+ const staged = ids
1803
+ .map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
1804
+ .filter((entry) => Boolean(entry));
1805
+ for (const id of ids)
1806
+ if (typeof id === "string")
1807
+ attachments.delete(id);
1808
+ // The other end of the staging map's lifecycle: sending is the moment
1809
+ // a composer is known to have moved on, so it is a good moment to ask
1810
+ // what else has been abandoned.
1811
+ sweepAttachments();
1812
+ void runTurn(runner, prompt, staged, references);
1813
+ return send(res, 202, { ok: true, attached: staged.length, referenced: references.length });
1814
+ }
1815
+ case "POST /permission": {
1816
+ const body = await readJson(req);
1817
+ const entry = typeof body.id === "string" ? pending.get(body.id) : undefined;
1818
+ if (!entry)
1819
+ return send(res, 404, { error: "no such pending request" });
1820
+ pending.delete(entry.id);
1821
+ entry.resolve({
1822
+ allow: body.allow === true,
1823
+ ...(typeof body.remember === "string"
1824
+ ? { remember: body.remember }
1825
+ : {}),
1826
+ });
1827
+ return send(res, 200, { ok: true });
1828
+ }
1829
+ case "POST /interrupt":
1830
+ {
1831
+ const body = await readJson(req);
1832
+ runnerFor(body.session)?.running?.abort();
1833
+ }
1834
+ return send(res, 200, { ok: true });
1835
+ default:
1836
+ return send(res, 404, { error: "not found" });
1837
+ }
1838
+ }
1839
+ /**
1840
+ * The browser coming back from the site, session in hand.
1841
+ *
1842
+ * Answers with a page rather than JSON: a human is looking at this tab, and
1843
+ * what they need to be told is that they can close it. The app itself hears
1844
+ * about the sign-in over the event stream, which is where it was waiting.
1845
+ */
1846
+ async function browserSignInReturn(url, res) {
1847
+ const state = url.searchParams.get("state") ?? "";
1848
+ const pending = pendingBrowserSignIns.get(state);
1849
+ // Consumed on sight, whatever happens next. A nonce that survived a failed
1850
+ // attempt could be replayed by anything that saw it in the address bar.
1851
+ pendingBrowserSignIns.delete(state);
1852
+ if (!pending) {
1853
+ return closeTab(res, 400, "That sign-in has expired. Start it again from the app.");
1854
+ }
1855
+ const failure = url.searchParams.get("error");
1856
+ if (failure)
1857
+ return closeTab(res, 400, failure);
1858
+ const code = url.searchParams.get("code") ?? "";
1859
+ if (!code)
1860
+ return closeTab(res, 400, "The site sent no sign-in back.");
1861
+ const result = await exchangeHandoff(code, state);
1862
+ if (result.type !== "success") {
1863
+ const message = result.error ?? "that sign-in was not accepted";
1864
+ broadcast({ type: "notice", message: `sign-in failed: ${message}` });
1865
+ return closeTab(res, 400, message);
1866
+ }
1867
+ try {
1868
+ await current.harness.auth.complete(pending.provider, result);
1869
+ }
1870
+ catch (err) {
1871
+ return closeTab(res, 500, errorMessage(err));
1872
+ }
1873
+ closeTab(res, 200, `Signed in to ${pending.provider}.`);
1874
+ // Same as a saved key: the credential is stored, now go ask the gateway
1875
+ // what the account can actually call.
1876
+ void current.harness
1877
+ .refreshModels(pending.provider)
1878
+ .then((found) => {
1879
+ broadcast({ type: "models_changed", provider: pending.provider, discovered: found.length });
1880
+ })
1881
+ .catch((err) => {
1882
+ broadcast({
1883
+ type: "notice",
1884
+ message: `signed in to ${pending.provider}, but discovering its models failed: ${errorMessage(err)}`,
1885
+ });
1886
+ });
1887
+ }
1888
+ /**
1889
+ * The provider's sign-in method, if it has one.
1890
+ *
1891
+ * Reported by index because that is what `authorize` takes, and a provider
1892
+ * may declare several methods — the official plan takes either an account
1893
+ * sign-in or a key.
1894
+ */
1895
+ function signInMethod(providerId) {
1896
+ const provider = current.harness.auth.get(providerId);
1897
+ if (!provider)
1898
+ return undefined;
1899
+ const index = provider.methods.findIndex((method) => method.type === "oauth");
1900
+ if (index === -1)
1901
+ return undefined;
1902
+ return { index, label: provider.methods[index].label };
1903
+ }
1904
+ /**
1905
+ * The connectors and plugins this harness actually has.
1906
+ *
1907
+ * Read from the live harness rather than from configuration: a server that
1908
+ * is declared but failed to start is worth showing as exactly that, and a
1909
+ * menu built from the config file would claim it was there.
1910
+ */
1911
+ function contextOffer() {
1912
+ const harness = current.harness;
1913
+ const connectors = (harness.mcpStatus?.() ?? []).map((status) => ({
1914
+ name: status.name,
1915
+ connected: status.connected,
1916
+ transport: status.transport,
1917
+ tools: status.toolCount,
1918
+ ...(status.error ? { error: status.error } : {}),
1919
+ ...(status.needsAuth ? { needsAuth: true } : {}),
1920
+ }));
1921
+ const plugins = (harness.plugins ?? []).map((plugin) => ({
1922
+ name: plugin.name,
1923
+ ...(plugin.description ? { description: plugin.description } : {}),
1924
+ source: plugin.source,
1925
+ servers: Object.keys(plugin.mcpServers ?? {}),
1926
+ subagents: plugin.subagents?.length ?? 0,
1927
+ }));
1928
+ return { connectors, plugins };
1929
+ }
1930
+ /**
1931
+ * What the composer attached to one message, as a block above the prompt.
1932
+ *
1933
+ * Paths are named, not inlined: the agent has tools for reading a file and a
1934
+ * folder, the user can watch it use them, and a folder someone picked out of
1935
+ * curiosity should not spend a hundred thousand tokens before the first
1936
+ * word of the answer. Connectors and plugins are described from live state
1937
+ * for the same reason the menu is.
1938
+ */
1939
+ function referenceNote(references) {
1940
+ if (!references.length)
1941
+ return "";
1942
+ const offer = contextOffer();
1943
+ const lines = references.map((ref) => {
1944
+ if (ref.kind === "file")
1945
+ return `- file: ${ref.value}`;
1946
+ if (ref.kind === "folder")
1947
+ return `- folder: ${ref.value}`;
1948
+ if (ref.kind === "connector") {
1949
+ const found = offer.connectors.find((connector) => connector.name === ref.value);
1950
+ const detail = !found
1951
+ ? "not connected"
1952
+ : found.connected
1953
+ ? `${found.tools} tools, named mcp__${found.name}__*`
1954
+ : `not connected${found.error ? ` — ${found.error}` : ""}`;
1955
+ return `- MCP connector: ${ref.value} (${detail})`;
1956
+ }
1957
+ const plugin = offer.plugins.find((entry) => entry.name === ref.value);
1958
+ const detail = plugin?.description ? ` — ${plugin.description}` : "";
1959
+ return `- plugin: ${ref.value}${detail}`;
1960
+ });
1961
+ return [
1962
+ "<referenced>",
1963
+ "The user attached these to this message. Read what you need before answering.",
1964
+ ...lines,
1965
+ "</referenced>",
1966
+ ].join("\n");
1967
+ }
1968
+ function state(runner = runners.get(focused)) {
1969
+ const { session: sess } = runner;
1970
+ const model = sess.model;
1971
+ const used = sess.usage.inputTokens + sess.usage.outputTokens;
1972
+ return {
1973
+ sessionId: sess.id,
1974
+ // The page labels its tabs from this. Without it every tab read
1975
+ // "Untitled", including sessions the store had a title for all along.
1976
+ title: sess.title,
1977
+ cwd: sess.cwd,
1978
+ model: model.id,
1979
+ provider: model.provider,
1980
+ mode: sess.permissions.getMode(),
1981
+ costUsd: sess.costUsd,
1982
+ // Beside the cost, because the line under the composer says three things
1983
+ // about one turn: what it cost, how many tokens that took, and how much
1984
+ // of the prompt was served from cache — and the last is the only one of
1985
+ // the three that says whether the first could have been lower.
1986
+ usage: tokensOf(sess.usage),
1987
+ turns: sess.turns,
1988
+ contextWindow: model.contextWindow,
1989
+ contextUsed: model.contextWindow ? Math.min(1, used / model.contextWindow) : undefined,
1990
+ busy: Boolean(runner.running),
1991
+ };
1992
+ }
1993
+ /**
1994
+ * The transcript, flattened into what the page draws.
1995
+ *
1996
+ * Tool results are attached to the assistant turn that called them rather
1997
+ * than left as the user messages they are on the wire, because the page
1998
+ * shows a call and its result as one card.
1999
+ */
2000
+ function history(runner = runners.get(focused)) {
2001
+ const entries = [];
2002
+ let index = 0;
2003
+ for (const message of runner.session.messages) {
2004
+ index++;
2005
+ const tools = message.content.filter((block) => block.type === "tool_call");
2006
+ const results = message.content.filter((block) => block.type === "tool_result");
2007
+ const text = messageText(message).trim();
2008
+ // The bytes stay where they are; what travels is where to find them.
2009
+ // Without this a reloaded conversation showed "[image: shot.png]" where
2010
+ // the screenshot had been — the model still saw it, the person did not.
2011
+ const images = message.content
2012
+ .map((block, position) => ({ block, position }))
2013
+ .filter((entry) => entry.block.type === "image")
2014
+ .map((entry) => ({
2015
+ src: `/attachment?session=${encodeURIComponent(runner.session.id)}` +
2016
+ `&message=${index}&block=${entry.position}`,
2017
+ }));
2018
+ // messageText drops thinking blocks by design, so they are read directly.
2019
+ const thinking = message.content
2020
+ .filter((block) => block.type === "thinking")
2021
+ .map((block) => block.thinking)
2022
+ .join("")
2023
+ .trim();
2024
+ if (results.length) {
2025
+ const target = entries.at(-1);
2026
+ if (target) {
2027
+ // The results arrived in a later message, so a fork "after this
2028
+ // reply" has to include it or the branch reopens with tool calls
2029
+ // that have no answers — a request every provider rejects.
2030
+ target.keepMessages = index;
2031
+ target.results = results.map((block) => ({
2032
+ id: block.toolCallId,
2033
+ text: block.content
2034
+ .map((part) => (part.type === "text" ? part.text : ""))
2035
+ .join("")
2036
+ .slice(0, 4000),
2037
+ ...(block.isError ? { isError: true } : {}),
2038
+ }));
2039
+ }
2040
+ if (!text && !thinking && !images.length)
2041
+ continue;
2042
+ }
2043
+ if (!text && !tools.length && !thinking && !images.length)
2044
+ continue;
2045
+ entries.push({
2046
+ role: message.role === "assistant" ? "assistant" : "user",
2047
+ keepMessages: index,
2048
+ ...(images.length ? { images } : {}),
2049
+ ...(thinking ? { thinking } : {}),
2050
+ ...(text ? { text } : {}),
2051
+ ...(tools.length
2052
+ ? { tools: tools.map((block) => ({ id: block.id, name: block.name, input: block.input })) }
2053
+ : {}),
2054
+ });
2055
+ }
2056
+ return entries;
2057
+ }
2058
+ /**
2059
+ * The harness a directory's sessions run on, built the first time one asks.
2060
+ *
2061
+ * A session names the tree it works in, so starting or resuming one
2062
+ * elsewhere reaches for that project's harness rather than moving the
2063
+ * current one onto a new cwd: every runner already carries its own, and
2064
+ * rebuilding the open ones to follow would reconnect their MCP servers for
2065
+ * a change that is none of their business.
2066
+ *
2067
+ * Choosing a directory here is also a statement about what the user is
2068
+ * working on, so it is what the next launch opens.
2069
+ */
2070
+ async function harnessFor(dir) {
2071
+ const project = await addProject(dir);
2072
+ void writePrefs({ project: project.path });
2073
+ const existing = harnesses.get(project.path);
2074
+ if (existing)
2075
+ return existing;
2076
+ const harness = await (opts.openProject
2077
+ ? opts.openProject(project.path)
2078
+ : Harness.create({
2079
+ cwd: project.path,
2080
+ permissionPrompt: (request) => permissionPrompt(request),
2081
+ }));
2082
+ harnesses.set(project.path, harness);
2083
+ return harness;
2084
+ }
2085
+ /**
2086
+ * Open a different project.
2087
+ *
2088
+ * Everything scoped to a directory is rebuilt: the harness, its session, and
2089
+ * the change ledger. Carrying the ledger across would attribute one
2090
+ * project's edits to another.
2091
+ */
2092
+ async function switchProject(dir, resume) {
2093
+ const harness = await harnessFor(dir);
2094
+ // Opening one of that project's own sessions is the common reason to
2095
+ // switch, and doing it in one step avoids stranding an empty session in
2096
+ // the list for every switch made on the way to an existing transcript.
2097
+ const resumed = resume ? await harness.resumeSession(resume) : undefined;
2098
+ const session = resumed ?? freshSession(harness);
2099
+ if (!resumed)
2100
+ await session.init();
2101
+ const runner = makeRunner(session, harness);
2102
+ runners.set(session.id, runner);
2103
+ focused = session.id;
2104
+ current = { session, agent: runner.agent, harness };
2105
+ const snapshot = state(runner);
2106
+ broadcast({
2107
+ type: "session_changed",
2108
+ session: session.id,
2109
+ state: snapshot,
2110
+ entries: history(runner),
2111
+ });
2112
+ return snapshot;
2113
+ }
2114
+ async function switchSession(body) {
2115
+ const action = String(body.action ?? "");
2116
+ let next;
2117
+ /**
2118
+ * The tree this session works in.
2119
+ *
2120
+ * Named by the caller, or the project already on screen. A session belongs
2121
+ * to one directory — the page asks which before starting one, and resuming
2122
+ * a transcript recorded elsewhere has to reach for the tree it mentions
2123
+ * rather than point its tools at whatever project is open now.
2124
+ */
2125
+ const where = typeof body.cwd === "string" ? body.cwd.trim() : "";
2126
+ let harness = where && where !== current.session.cwd ? await harnessFor(where) : current.harness;
2127
+ if (action === "new") {
2128
+ next = freshSession(harness);
2129
+ await next.init();
2130
+ }
2131
+ else if (action === "resume" && typeof body.id === "string") {
2132
+ next = await harness.resumeSession(body.id);
2133
+ }
2134
+ else if (action === "fork") {
2135
+ harness = current.harness;
2136
+ const forked = await harness.forkSession(current.session.id, {
2137
+ ...(typeof body.keepMessages === "number" ? { keepMessages: body.keepMessages } : {}),
2138
+ });
2139
+ next = forked;
2140
+ }
2141
+ else {
2142
+ return { error: `unknown action: ${action}` };
2143
+ }
2144
+ if (!next)
2145
+ return { error: "could not open that session" };
2146
+ // A tab per session, with its own agent and ledger. Reusing the previous
2147
+ // agent would append this session's turns to the old transcript, and
2148
+ // sharing a ledger would attribute one session's edits to another.
2149
+ let runner = runners.get(next.id);
2150
+ if (!runner) {
2151
+ runner = makeRunner(next, harness);
2152
+ runners.set(next.id, runner);
2153
+ }
2154
+ focused = next.id;
2155
+ // A reopened tab keeps the harness it was built on, which is the one its
2156
+ // own cwd belongs to.
2157
+ current = { session: next, agent: runner.agent, harness: runner.harness };
2158
+ const snapshot = state(runner);
2159
+ broadcast({
2160
+ type: "session_changed",
2161
+ session: next.id,
2162
+ state: snapshot,
2163
+ entries: history(runner),
2164
+ });
2165
+ return snapshot;
2166
+ }
2167
+ async function runTurn(runner, prompt, staged = [], references = []) {
2168
+ const controller = new AbortController();
2169
+ runner.running = controller;
2170
+ const id = runner.session.id;
2171
+ // Every event carries its session so the page can route it to the tab it
2172
+ // belongs to; without it a reply from a background tab would stream into
2173
+ // whichever one happens to be on screen.
2174
+ broadcast({
2175
+ type: "prompt",
2176
+ session: id,
2177
+ text: prompt,
2178
+ ...(staged.length ? { attachments: staged.map((file) => file.name) } : {}),
2179
+ // The transcript shows what was referenced, not just what was typed:
2180
+ // otherwise a turn that reads three files looks like it went and found
2181
+ // them on its own.
2182
+ ...(references.length ? { references } : {}),
2183
+ });
2184
+ // The note goes above the prompt rather than after it, so the model has
2185
+ // the context before it reads the request that depends on it.
2186
+ const note = referenceNote(references);
2187
+ const text = note ? `${note}\n\n${prompt}` : prompt;
2188
+ const input = staged.length ? buildMessage(text, staged) : text;
2189
+ try {
2190
+ for await (const event of runner.agent.run(input, { signal: controller.signal })) {
2191
+ broadcast({ ...event, session: id });
2192
+ }
2193
+ }
2194
+ catch (err) {
2195
+ broadcast({
2196
+ type: "error",
2197
+ session: id,
2198
+ error: errorMessage(err),
2199
+ // Carried as a number rather than left for the page to read out of the
2200
+ // sentence. A 402 is not an error to print — it is a screen to open —
2201
+ // and a frontend that finds that out by matching "HTTP 402" in a
2202
+ // string is a frontend that breaks the day the string is reworded.
2203
+ ...(typeof err?.status === "number"
2204
+ ? { status: err.status }
2205
+ : {}),
2206
+ // Which vendor refused, for the same reason the status travels: a 401
2207
+ // is the one failure the user can fix, and fixing it means opening
2208
+ // that provider's dialog. The page knows the session's model but not
2209
+ // which provider a mid-turn fallback had ended up on.
2210
+ ...(typeof err?.provider === "string"
2211
+ ? { provider: err.provider }
2212
+ : {}),
2213
+ fatal: true,
2214
+ });
2215
+ }
2216
+ finally {
2217
+ runner.running = undefined;
2218
+ broadcast({
2219
+ type: "idle",
2220
+ session: id,
2221
+ state: state(runner),
2222
+ changes: runner.ledger.list(),
2223
+ });
2224
+ }
2225
+ }
2226
+ function stream(res) {
2227
+ res.writeHead(200, {
2228
+ "content-type": "text/event-stream",
2229
+ "cache-control": "no-cache",
2230
+ connection: "keep-alive",
2231
+ // Proxies that buffer would hold the whole reply until the turn ends,
2232
+ // which defeats streaming entirely.
2233
+ "x-accel-buffering": "no",
2234
+ });
2235
+ res.write(": connected\n\n");
2236
+ clients.add(res);
2237
+ // A heartbeat keeps intermediaries from closing an idle stream while the
2238
+ // model is thinking.
2239
+ const beat = setInterval(() => res.write(": ping\n\n"), 15_000);
2240
+ beat.unref?.();
2241
+ res.on("close", () => {
2242
+ clearInterval(beat);
2243
+ clients.delete(res);
2244
+ });
2245
+ }
2246
+ /**
2247
+ * How many sessions one listing folds from disk.
2248
+ *
2249
+ * Bounded because the panel is opened casually and each session past this is
2250
+ * a file read. The newest are kept, and the response says how many were left
2251
+ * out rather than presenting a cut list as the whole set.
2252
+ */
2253
+ const CATALOGUE_SESSIONS = 200;
2254
+ /**
2255
+ * How much of one artifact log a listing reads.
2256
+ *
2257
+ * The tail, because the newest revisions are the ones a listing shows. A
2258
+ * session that rewrote a page fifty times has fifty full copies in its log
2259
+ * and exactly one of them on screen.
2260
+ */
2261
+ const CATALOGUE_TAIL_BYTES = 4 * 1024 * 1024;
2262
+ /**
2263
+ * How long a fold of the logs is reused.
2264
+ *
2265
+ * The catalogue is rebuilt on every artifact event — a model rewriting a
2266
+ * page does that a dozen times a turn — and the loaded half is recomputed
2267
+ * each time regardless, because it is a map lookup. The disk half is the
2268
+ * expensive one and it can only change under this process when *another*
2269
+ * process writes a session this one does not have open, so a few seconds of
2270
+ * staleness costs nothing anybody can see.
2271
+ */
2272
+ const CATALOGUE_TTL_MS = 15_000;
2273
+ let cachedLogs;
2274
+ /**
2275
+ * Every artifact this machine holds, newest session first.
2276
+ *
2277
+ * Two sources, and the order between them matters. A loaded session's store
2278
+ * is authoritative for its own artifacts: it holds the canvas revision a
2279
+ * person made a second ago and the publication link, neither of which the
2280
+ * log carries. Everything else is folded from the revision logs, which is
2281
+ * what makes the panel's answer independent of which tabs are open.
2282
+ */
2283
+ async function artifactCatalogue() {
2284
+ const groups = [];
2285
+ for (const runner of runners.values()) {
2286
+ const artifacts = describeArtifacts(getArtifactStore(runner.session.state));
2287
+ if (!artifacts.length)
2288
+ continue;
2289
+ groups.push({
2290
+ session: runner.session.id,
2291
+ title: runner.session.title,
2292
+ current: runner.session.id === focused,
2293
+ updatedAt: Math.max(...artifacts.map((artifact) => artifact.at), 0),
2294
+ artifacts,
2295
+ });
2296
+ }
2297
+ const fromLogs = await foldedLogs();
2298
+ // A loaded session overrides its own log, so a session that appears in
2299
+ // both is only listed once — from memory, which is the newer of the two.
2300
+ for (const group of fromLogs.groups) {
2301
+ if (!runners.has(group.session))
2302
+ groups.push(group);
2303
+ }
2304
+ // The session in front of you first, whatever its dates say: its artifacts
2305
+ // are the ones this turn just made. The rest by when they were last
2306
+ // touched, which is the order the sidebar puts sessions in.
2307
+ groups.sort((a, b) => Number(b.current) - Number(a.current) || b.updatedAt - a.updatedAt);
2308
+ return { sessions: groups, ...(fromLogs.more ? { more: fromLogs.more } : {}) };
2309
+ }
2310
+ /** The durable half of the catalogue, folded from the logs and reused briefly. */
2311
+ async function foldedLogs() {
2312
+ if (cachedLogs && Date.now() - cachedLogs.at < CATALOGUE_TTL_MS)
2313
+ return cachedLogs;
2314
+ const store = current.harness.store;
2315
+ const logs = await store.artifactSessions();
2316
+ // Read for their titles and their dates, and to put the newest first — a
2317
+ // cap that kept an arbitrary two hundred would be worse than no cap. The
2318
+ // limit is on the listing, not on the read: it bounds the title backfill,
2319
+ // which is a transcript read for every session that never got one.
2320
+ const metas = new Map((await current.harness.listSessions(500)).map((meta) => [meta.id, meta]));
2321
+ const ordered = logs
2322
+ .map((id) => ({ id, updatedAt: metas.get(id)?.updatedAt ?? 0 }))
2323
+ .sort((a, b) => b.updatedAt - a.updatedAt);
2324
+ const groups = [];
2325
+ for (const { id, updatedAt } of ordered.slice(0, CATALOGUE_SESSIONS)) {
2326
+ const revisions = await store.artifactRevisions(id, CATALOGUE_TAIL_BYTES);
2327
+ if (!revisions.length)
2328
+ continue;
2329
+ const folded = new ArtifactStore();
2330
+ restoreArtifacts(folded, revisions);
2331
+ const artifacts = describeArtifacts(folded);
2332
+ if (!artifacts.length)
2333
+ continue;
2334
+ const meta = metas.get(id);
2335
+ groups.push({
2336
+ session: id,
2337
+ ...(meta?.title ? { title: meta.title } : {}),
2338
+ current: false,
2339
+ updatedAt: updatedAt || Math.max(...artifacts.map((artifact) => artifact.at), 0),
2340
+ artifacts,
2341
+ });
2342
+ }
2343
+ cachedLogs = {
2344
+ at: Date.now(),
2345
+ groups,
2346
+ more: Math.max(0, ordered.length - CATALOGUE_SESSIONS),
2347
+ };
2348
+ return cachedLogs;
2349
+ }
2350
+ /**
2351
+ * A store's artifacts, each with the date of the revision on show.
2352
+ *
2353
+ * The timestamp is not on the descriptor — it belongs to a revision, not to
2354
+ * the artifact — but a list that spans weeks is unreadable without it.
2355
+ */
2356
+ function describeArtifacts(store) {
2357
+ return store.list().map((artifact) => ({
2358
+ ...artifact,
2359
+ at: store.get(artifact.id)?.revision.createdAt ?? 0,
2360
+ }));
2361
+ }
2362
+ /**
2363
+ * The body of one artifact, isolated. Resolves against whichever session the
2364
+ * caller names, so an artifact stays viewable after the page switches tabs.
2365
+ */
2366
+ function serveArtifactBody(url, res) {
2367
+ const runner = runnerFor(url.searchParams.get("session"));
2368
+ if (!runner)
2369
+ return send(res, 404, { error: "no such session" });
2370
+ const id = url.searchParams.get("id") ?? "";
2371
+ if (!isValidArtifactId(id))
2372
+ return send(res, 400, { error: "bad artifact id" });
2373
+ const versionParam = url.searchParams.get("version");
2374
+ const found = getArtifactStore(runner.session.state).get(id, versionParam ? Number(versionParam) : undefined);
2375
+ if (!found)
2376
+ return send(res, 404, { error: "no such artifact" });
2377
+ sendArtifact(res, found.artifact, found.revision);
2378
+ }
2379
+ function constantTimeEqual(supplied, expected) {
2380
+ if (!supplied)
2381
+ return false;
2382
+ const a = Buffer.from(supplied);
2383
+ const b = Buffer.from(expected);
2384
+ return a.length === b.length && timingSafeEqual(a, b);
2385
+ }
2386
+ function authorized(url, req) {
2387
+ const supplied = url.searchParams.get("token") ??
2388
+ (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
2389
+ if (!supplied)
2390
+ return false;
2391
+ // Constant-time comparison: a plain !== leaks the token a character at a
2392
+ // time to anything that can measure the response.
2393
+ const a = Buffer.from(supplied);
2394
+ const b = Buffer.from(token);
2395
+ return a.length === b.length && timingSafeEqual(a, b);
2396
+ }
2397
+ await listenPreferring(server, opts.port, host);
2398
+ const address = server.address();
2399
+ const port = typeof address === "object" && address ? address.port : opts.port;
2400
+ return {
2401
+ url: `http://${host}:${port}/?token=${token}`,
2402
+ token,
2403
+ permissionPrompt,
2404
+ async close() {
2405
+ for (const client of clients)
2406
+ client.end();
2407
+ clients.clear();
2408
+ // Any prompt still waiting has to be answered, or the agent's turn hangs
2409
+ // forever holding the process open. Shutting down denies.
2410
+ for (const entry of pending.values())
2411
+ entry.resolve({ allow: false });
2412
+ pending.clear();
2413
+ for (const runner of runners.values())
2414
+ runner.running?.abort();
2415
+ await new Promise((resolve) => server.close(() => resolve()));
2416
+ },
2417
+ };
2418
+ }
2419
+ /**
2420
+ * The port used when nobody names one.
2421
+ *
2422
+ * A stable number so the URL is the same every run — bookmarkable, and the
2423
+ * same one a second terminal can be told about. It is not a secret and is not
2424
+ * asked to be: what protects this server is the loopback bind and the
2425
+ * per-run token, and the comment at the top of this file is careful to credit
2426
+ * those rather than an unguessable port.
2427
+ */
2428
+ const DEFAULT_PORT = 4310;
2429
+ /**
2430
+ * Listen on the preferred port, or anywhere if it is taken.
2431
+ *
2432
+ * Two rules, because the two cases mean different things:
2433
+ *
2434
+ * - No `--port`: try DEFAULT_PORT, and on EADDRINUSE fall back to an
2435
+ * OS-assigned one. Running a second instance is ordinary — a second
2436
+ * project, a second terminal — and refusing to start would be a worse
2437
+ * answer than a different port. The URL is printed either way.
2438
+ * - An explicit `--port`: that number or nothing. Someone who names a port
2439
+ * has something pointed at it, and quietly listening somewhere else would
2440
+ * leave them staring at a dead bookmark with the server apparently up.
2441
+ */
2442
+ async function listenPreferring(server, port, host) {
2443
+ if (port !== undefined)
2444
+ return listen(server, port, host);
2445
+ try {
2446
+ await listen(server, DEFAULT_PORT, host);
2447
+ }
2448
+ catch (err) {
2449
+ if (err.code !== "EADDRINUSE")
2450
+ throw err;
2451
+ await listen(server, 0, host);
2452
+ }
2453
+ }
2454
+ function listen(server, port, host) {
2455
+ return new Promise((resolve, reject) => {
2456
+ server.once("error", reject);
2457
+ server.listen(port, host, () => {
2458
+ server.off("error", reject);
2459
+ resolve();
2460
+ });
2461
+ });
2462
+ }
2463
+ function send(res, status, body) {
2464
+ const payload = JSON.stringify(body);
2465
+ res.writeHead(status, {
2466
+ "content-type": "application/json; charset=utf-8",
2467
+ "content-length": Buffer.byteLength(payload),
2468
+ });
2469
+ res.end(payload);
2470
+ }
2471
+ /**
2472
+ * Who an access token says it belongs to, as far as it is worth displaying.
2473
+ *
2474
+ * Read out of the JWT's own payload rather than asked of the auth server: this
2475
+ * is display text beside a menu, and a panel repaint should not depend on a
2476
+ * third party being reachable. Nothing is trusted on the strength of it — the
2477
+ * signature is checked by whoever the token is sent to.
2478
+ *
2479
+ * The name and the picture live in `user_metadata`, which is where the site's
2480
+ * auth provider puts whatever the sign-in method knew: a Google sign-in fills
2481
+ * both, an email and password fills neither. Every field is optional here
2482
+ * because every one of them genuinely can be missing.
2483
+ */
2484
+ function accountIdentity(token) {
2485
+ const payload = token.split(".")[1];
2486
+ if (!payload)
2487
+ return {};
2488
+ try {
2489
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
2490
+ const meta = claims.user_metadata ?? {};
2491
+ const text = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
2492
+ return {
2493
+ ...(text(claims.email) ? { email: text(claims.email) } : {}),
2494
+ ...(text(meta["full_name"]) ?? text(meta["name"])
2495
+ ? { name: (text(meta["full_name"]) ?? text(meta["name"])) }
2496
+ : {}),
2497
+ // Only over https: this address is handed to an <img> in a page that
2498
+ // holds the session token, and a plaintext one would be a request any
2499
+ // network between here and there could answer.
2500
+ ...(avatarUrl(text(meta["avatar_url"]) ?? text(meta["picture"]))
2501
+ ? { avatar: avatarUrl(text(meta["avatar_url"]) ?? text(meta["picture"])) }
2502
+ : {}),
2503
+ };
2504
+ }
2505
+ catch {
2506
+ // Not a JWT, or not one with a readable payload — an API key stored as an
2507
+ // OAuth credential by some other provider, most likely.
2508
+ return {};
2509
+ }
2510
+ }
2511
+ /** The address of a picture, if it is one the page may safely be given. */
2512
+ function avatarUrl(value) {
2513
+ if (!value)
2514
+ return undefined;
2515
+ try {
2516
+ return new URL(value).protocol === "https:" ? value : undefined;
2517
+ }
2518
+ catch {
2519
+ return undefined;
2520
+ }
2521
+ }
2522
+ /** The address on an access token, if it says one. */
2523
+ function accountEmail(token) {
2524
+ return accountIdentity(token).email;
2525
+ }
2526
+ /**
2527
+ * The last page of a sign-in that happened in a browser tab.
2528
+ *
2529
+ * Self-contained: one inline stylesheet, no script, no font and no image
2530
+ * fetched from anywhere. The tab has done its job and the only useful thing it
2531
+ * can say is whether that job worked — but it is also the last thing a new
2532
+ * account sees of us, and a line of unstyled 16px text in the corner of a
2533
+ * white page reads as something that crashed rather than something that
2534
+ * finished. The mark is drawn in SVG for the same reason the icons in the app
2535
+ * are: it is the one asset that cannot fail to load.
2536
+ *
2537
+ * Both outcomes use this. Success is stated plainly and failure keeps the
2538
+ * sentence it was given, in the colour that says which one happened — the tab
2539
+ * is not where anything can be fixed, so it explains rather than offers.
2540
+ */
2541
+ /**
2542
+ * One call to the metering gateway, in the shape the caller needs back.
2543
+ *
2544
+ * A failure is a value rather than a throw: everything that calls this is
2545
+ * rendering a screen about money, and "the shop is closed" and "the shop did
2546
+ * not answer" are two different sentences to show a person who cannot pay.
2547
+ */
2548
+ async function gatewayJson(path, opts = {}) {
2549
+ const base = officialBaseUrl();
2550
+ let response;
2551
+ try {
2552
+ response = await fetch(`${base}${path}`, {
2553
+ method: opts.body === undefined ? "GET" : "POST",
2554
+ headers: {
2555
+ accept: "application/json",
2556
+ ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),
2557
+ ...(opts.body === undefined ? {} : { "content-type": "application/json" }),
2558
+ },
2559
+ ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }),
2560
+ });
2561
+ }
2562
+ catch (err) {
2563
+ return { ok: false, status: 0, error: `could not reach ${base}: ${errorMessage(err)}` };
2564
+ }
2565
+ const text = await response.text();
2566
+ let parsed = {};
2567
+ try {
2568
+ parsed = text ? JSON.parse(text) : {};
2569
+ }
2570
+ catch {
2571
+ // A gateway answering HTML is a gateway behind something that is not the
2572
+ // gateway. The status is still worth reporting; the body is not.
2573
+ }
2574
+ if (!response.ok) {
2575
+ // The gateway nests its failures as { error: { code, message } }; other
2576
+ // things in front of it may not, so both shapes are read.
2577
+ const detail = parsed["error"];
2578
+ const message = detail && typeof detail === "object" && "message" in detail
2579
+ ? String(detail.message)
2580
+ : typeof detail === "string"
2581
+ ? detail
2582
+ : `HTTP ${response.status}`;
2583
+ return { ok: false, status: response.status, error: message };
2584
+ }
2585
+ return { ok: true, value: parsed };
2586
+ }
2587
+ /**
2588
+ * Whether the browser could be sent back to this address with a session.
2589
+ *
2590
+ * The same question `loopbackTarget` asks on the website, kept deliberately as
2591
+ * strict: the two lists have to agree, and the one that matters is the site's.
2592
+ * A hostname that resolves to loopback but is not spelled as one — a tunnel, a
2593
+ * LAN name pointed here — is refused for exactly the reason the site refuses
2594
+ * it, and the emailed code is what serves that case.
2595
+ */
2596
+ function isLoopbackHost(host) {
2597
+ let hostname;
2598
+ try {
2599
+ // The IPv6 literal keeps its brackets in `hostname`, which is what the URL
2600
+ // parser hands back and not what anyone writing this list expects.
2601
+ hostname = new URL(`http://${host}`).hostname;
2602
+ }
2603
+ catch {
2604
+ return false;
2605
+ }
2606
+ return hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "localhost";
2607
+ }
2608
+ function closeTab(res, status, message) {
2609
+ const escaped = message.replace(/[&<>]/g, (char) => char === "&" ? "&amp;" : char === "<" ? "&lt;" : "&gt;");
2610
+ const ok = status < 400;
2611
+ const page = `<!doctype html><meta charset="utf-8"><title>OHarness</title>` +
2612
+ `<meta name="viewport" content="width=device-width,initial-scale=1">` +
2613
+ `<style>` +
2614
+ `:root{color-scheme:light dark;` +
2615
+ `--bg:#fff;--card:#fff;--fg:#1a1a1a;--dim:#6b7280;--line:#00000012;--mark:#111827;--bad:#b91c1c}` +
2616
+ `@media(prefers-color-scheme:dark){:root{` +
2617
+ `--bg:#0e1013;--card:#15171c;--fg:#e6e6e6;--dim:#8b93a1;--line:#ffffff14;--mark:#e5e7eb;--bad:#f87171}}` +
2618
+ `*{box-sizing:border-box}` +
2619
+ `body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;` +
2620
+ `background:var(--bg);color:var(--fg);` +
2621
+ `font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Inter,sans-serif;` +
2622
+ `-webkit-font-smoothing:antialiased}` +
2623
+ `main{width:100%;max-width:26rem;padding:28px;border-radius:16px;background:var(--card);` +
2624
+ `border:1px solid var(--line);box-shadow:0 1px 2px #0000000f,0 20px 60px #00000014;text-align:center}` +
2625
+ `svg{width:34px;height:34px;color:var(--mark)}` +
2626
+ `h1{margin:14px 0 0;font-size:16px;font-weight:600;letter-spacing:-.01em}` +
2627
+ `h1.bad{color:var(--bad)}` +
2628
+ `p{margin:8px 0 0;font-size:13px;color:var(--dim)}` +
2629
+ `</style>` +
2630
+ `<body><main>` +
2631
+ `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.2" ` +
2632
+ `stroke-linecap="round" aria-hidden="true">` +
2633
+ `<path d="M20 6h-8a6 6 0 0 0-6 6v8"/><path d="M12 26h8a6 6 0 0 0 6-6v-8"/>` +
2634
+ `<circle cx="16" cy="16" r="3.5" fill="currentColor" stroke="none"/></svg>` +
2635
+ `<h1${ok ? "" : ' class="bad"'}>${escaped}</h1>` +
2636
+ (ok ? `<p>You can close this tab and go back to OHarness.</p>` : "") +
2637
+ `</main>`;
2638
+ res.writeHead(status, {
2639
+ "content-type": "text/html; charset=utf-8",
2640
+ "content-length": Buffer.byteLength(page),
2641
+ });
2642
+ res.end(page);
2643
+ }
2644
+ /** The extensions a picked file is carried as an image under. */
2645
+ const PICKED_IMAGE_TYPES = {
2646
+ ".png": "image/png",
2647
+ ".jpg": "image/jpeg",
2648
+ ".jpeg": "image/jpeg",
2649
+ ".gif": "image/gif",
2650
+ ".webp": "image/webp",
2651
+ };
2652
+ /**
2653
+ * What `/attachment` will hand back to the page.
2654
+ *
2655
+ * Raster only: an `image/svg+xml` block would be a script running on the
2656
+ * frontend's own origin, and the frontend holds the session token.
2657
+ */
2658
+ const VIEWABLE_IMAGE_TYPES = new Set([
2659
+ "image/png",
2660
+ "image/jpeg",
2661
+ "image/gif",
2662
+ "image/webp",
2663
+ ]);
2664
+ /** Images are worth a few megabytes; anything larger is a mistake. */
2665
+ const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
2666
+ /** How long an unsent upload is kept before the sweep reclaims it. */
2667
+ const STAGED_TTL_MS = 30 * 60 * 1000;
2668
+ /** The ceiling on everything staged at once, across every tab. */
2669
+ const MAX_STAGED_BYTES = 64 * 1024 * 1024;
2670
+ /** How much of a text attachment is inlined into the prompt. */
2671
+ const MAX_INLINE_CHARS = 200_000;
2672
+ async function readBody(req, limit) {
2673
+ const chunks = [];
2674
+ let size = 0;
2675
+ for await (const chunk of req) {
2676
+ size += chunk.length;
2677
+ // Checked while reading, not after: buffering the whole body first is
2678
+ // exactly the thing the limit exists to prevent.
2679
+ if (size > limit)
2680
+ throw new Error("request body too large");
2681
+ chunks.push(chunk);
2682
+ }
2683
+ return Buffer.concat(chunks);
2684
+ }
2685
+ /**
2686
+ * A prompt carrying attachments.
2687
+ *
2688
+ * Images become image blocks, which every adapter renders into its vendor's
2689
+ * shape. Anything else is inlined as text under its filename — a base64 blob
2690
+ * of a zip would burn context and tell the model nothing.
2691
+ */
2692
+ /** Whether a chosen path is a folder; anything unreadable is treated as a file. */
2693
+ async function isDirectory(target) {
2694
+ try {
2695
+ return (await stat(target)).isDirectory();
2696
+ }
2697
+ catch {
2698
+ return false;
2699
+ }
2700
+ }
2701
+ const REFERENCE_KINDS = new Set(["file", "folder", "connector", "plugin"]);
2702
+ /** Enough to reference a directory of files, far short of a whole tree. */
2703
+ const MAX_REFERENCES = 32;
2704
+ /**
2705
+ * References as they arrive from the page.
2706
+ *
2707
+ * A reference ends up as a line inside a block the model reads, so a value
2708
+ * carrying a newline or a closing tag could forge the rest of that block — and
2709
+ * a file name is allowed to contain both. Flattening whitespace and dropping
2710
+ * angle brackets closes that; neither costs anything a real path needs, and
2711
+ * Windows forbids the brackets in a name outright.
2712
+ */
2713
+ function readReferences(value) {
2714
+ if (!Array.isArray(value))
2715
+ return [];
2716
+ const seen = new Set();
2717
+ const out = [];
2718
+ for (const item of value) {
2719
+ if (!item || typeof item !== "object")
2720
+ continue;
2721
+ const kind = item["kind"];
2722
+ const raw = item["value"];
2723
+ if (typeof kind !== "string" || !REFERENCE_KINDS.has(kind))
2724
+ continue;
2725
+ if (typeof raw !== "string")
2726
+ continue;
2727
+ const cleaned = raw.replace(/[<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 300);
2728
+ if (!cleaned)
2729
+ continue;
2730
+ const key = `${kind}:${cleaned}`;
2731
+ if (seen.has(key))
2732
+ continue;
2733
+ seen.add(key);
2734
+ out.push({ kind: kind, value: cleaned });
2735
+ if (out.length >= MAX_REFERENCES)
2736
+ break;
2737
+ }
2738
+ return out;
2739
+ }
2740
+ function buildMessage(prompt, staged) {
2741
+ const content = [];
2742
+ const notes = [];
2743
+ for (const file of staged) {
2744
+ if (file.mediaType.startsWith("image/")) {
2745
+ content.push({
2746
+ type: "image",
2747
+ mimeType: file.mediaType,
2748
+ data: file.data.toString("base64"),
2749
+ });
2750
+ notes.push(`[image: ${file.name}]`);
2751
+ continue;
2752
+ }
2753
+ const text = file.data.toString("utf8");
2754
+ // A NUL early on means binary, and pasting its bytes into the prompt
2755
+ // wastes context without conveying anything.
2756
+ if (file.data.subarray(0, 8000).includes(0)) {
2757
+ notes.push(`[skipped ${file.name}: binary, ${file.data.byteLength} bytes]`);
2758
+ continue;
2759
+ }
2760
+ const clipped = text.length > MAX_INLINE_CHARS
2761
+ ? `${text.slice(0, MAX_INLINE_CHARS)}\n… truncated`
2762
+ : text;
2763
+ notes.push(`--- ${file.name} ---\n${clipped}`);
2764
+ }
2765
+ const body = [prompt, ...notes].filter(Boolean).join("\n\n");
2766
+ content.unshift({ type: "text", text: body });
2767
+ return [{ role: "user", content }];
2768
+ }
2769
+ async function readJson(req) {
2770
+ const chunks = [];
2771
+ let size = 0;
2772
+ for await (const chunk of req) {
2773
+ size += chunk.length;
2774
+ // A prompt is text; anything this large is a mistake or an attempt to
2775
+ // exhaust memory, and reading it all first would be the vulnerability.
2776
+ if (size > 1_000_000)
2777
+ throw new Error("request body too large");
2778
+ chunks.push(chunk);
2779
+ }
2780
+ if (!chunks.length)
2781
+ return {};
2782
+ try {
2783
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
2784
+ }
2785
+ catch {
2786
+ throw new Error("invalid JSON body");
2787
+ }
2788
+ }
2789
+ //# sourceMappingURL=server.js.map