teapot-coding-agent 0.37.59 → 0.37.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
@@ -1,6 +1,3 @@
1
- /**
2
- * REST + SSE API on Hono, plus static file serving for the web UI.
3
- */
4
1
  import { Hono } from "hono";
5
2
  import { serve, upgradeWebSocket } from "@hono/node-server";
6
3
  import { WebSocketServer } from "ws";
@@ -20,19 +17,16 @@ import { SUB_PERSONAS, resolveWorkspace } from "../master.js";
20
17
  import { ConfigPatchSchema, formatZodError } from "../config-schema.js";
21
18
  import { providerHeaders } from "../agent/llm.js";
22
19
  import { safeJoin } from "../agent/tools.js";
23
- /** GET <baseUrl>/models on any OpenAI-compatible endpoint (id + ctx window + pricing). */
24
20
  async function listModels(baseUrl, apiKey) {
25
21
  const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/models`, {
26
22
  headers: {
27
23
  ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
28
- ...providerHeaders(baseUrl), // OpenRouter app attribution
24
+ ...providerHeaders(baseUrl),
29
25
  },
30
26
  signal: AbortSignal.timeout(15_000),
31
27
  });
32
28
  if (!res.ok)
33
29
  throw new Error(`upstream ${res.status}`);
34
- // OpenRouter-style entries carry context_length + per-token pricing —
35
- // surface them so the UI can show what each model offers
36
30
  const j = (await res.json());
37
31
  return (j.data ?? [])
38
32
  .map((m) => ({
@@ -58,7 +52,6 @@ import path from "node:path";
58
52
  import { bus } from "../bus.js";
59
53
  import { readEvents, readEventsTail, filterByBranch } from "../log/events.js";
60
54
  import { mcp } from "../agent/mcp-registry.js";
61
- /** media categories the file tree can preview inline */
62
55
  const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]);
63
56
  const VIDEO_EXT = new Set(["mp4", "webm", "mov", "mkv", "avi", "m4v"]);
64
57
  const AUDIO_EXT = new Set(["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"]);
@@ -72,7 +65,6 @@ function mediaKind(p) {
72
65
  return "audio";
73
66
  return null;
74
67
  }
75
- /** extension → MIME (used for the media preview endpoint) */
76
68
  export const MEDIA_MIME = (() => {
77
69
  const m = {};
78
70
  for (const e of IMAGE_EXT)
@@ -85,7 +77,6 @@ export const MEDIA_MIME = (() => {
85
77
  Object.assign(m, { m4a: "audio/mp4", mp3: "audio/mpeg" });
86
78
  return m;
87
79
  })();
88
- /** category → generic fallback when no specific extension match exists */
89
80
  const KIND_MIME = {
90
81
  image: "application/octet-stream",
91
82
  video: "application/octet-stream",
@@ -97,96 +88,18 @@ function mimeFor(rel, kind) {
97
88
  }
98
89
  export function buildApp(master) {
99
90
  const app = new Hono();
100
- /**
101
- * Cap on concurrent `/api/ws` event-stream clients.
102
- *
103
- * Each one holds a listener on the global bus and receives every agent's
104
- * events, so this bounds real fan-out. 64 is far above any plausible number of
105
- * open tabs on a LAN and low enough that a runaway client cannot exhaust memory.
106
- */
107
91
  const MAX_WS_CLIENTS = 64;
108
- // Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
109
- // the config's `password` field. Static files stay public; only /api/* is
110
- // gated.
111
- // Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
112
- // the config's `password` field. Static files stay public; only /api/* is
113
- // gated.
114
- //
115
- // Read DYNAMICALLY: `config.password` changes while this app is running — the
116
- // settings panel writes it through `PUT /api/config`. A const captured here once
117
- // meant a password set after boot did not take effect until a process restart,
118
- // leaving the server open while the operator believed it was closed.
119
92
  const apiToken = () => process.env.TEAPOT_API_TOKEN || master.config.password || "";
120
- /**
121
- * Routes where `?token=` is accepted.
122
- *
123
- * A browser WebSocket handshake cannot send an Authorization header, so the
124
- * query parameter is the ONLY way for the UI to authenticate those two
125
- * sockets. It used to be accepted on EVERY `/api/*` route, which put the
126
- * secret into proxy access logs, `Referer` headers and browser history for
127
- * requests that never needed it — `DELETE /api/agents/:id`, `PUT
128
- * /api/config`, `POST /api/update/restart`.
129
- *
130
- * So this narrows where the secret can LEAK rather than changing who is
131
- * authenticated: every route still accepts the header, and only these two
132
- * also accept the query form.
133
- */
134
93
  const tokenInQueryOk = (path) => path === "/api/ws" || /^\/api\/agents\/[^/]+\/term$/.test(path);
135
- /**
136
- * Requests that a PAGE ON ANOTHER ORIGIN must not be able to make.
137
- *
138
- * This is the defence that does not depend on the operator configuring a token, and it closes
139
- * the browser half of the OpenCode advisory (CVE-2026-22812 / GHSA-vxw4-wv6m-9hhh): their server
140
- * was unauthenticated AND sent permissive CORS, so any website could drive it. teapot sends no
141
- * CORS headers — a cross-origin READ is already blocked by the browser — but a cross-origin
142
- * WRITE is still DELIVERED, and these routes have side effects a website must not trigger:
143
- *
144
- * POST /api/agents/:id/prompt → the agent runs the model with tool access
145
- * POST /api/agents/:id/restart → restart onto a different build
146
- * PUT /api/config → rewrite the config, including API keys
147
- * POST /api/update/restart → restart the process
148
- * POST /api/setup → set the password on a first-run server
149
- *
150
- * The check is deliberately structural rather than a header-name list: a browser always sends
151
- * `Origin` on a cross-origin request (and on every `fetch` it sends `Sec-Fetch-Site`), while a
152
- * local process — which the OS already isolates from other users — sends neither. So "an Origin
153
- * that is not this server" is rejected, and a request with no Origin at all (curl, the CLI, the
154
- * desktop shell) is unaffected.
155
- *
156
- * on the same machine, and it is not meant to — that is what the token and the loopback bind are
157
- * for. See `docs/security-advisories.md`.
158
- */
159
94
  const sameOrigin = (c) => {
160
95
  const origin = c.req.header("origin");
161
- // no Origin at all: a non-browser client. A browser ALWAYS sets it cross-origin.
162
96
  if (origin === undefined)
163
97
  return true;
164
- /**
165
- * `Sec-Fetch-Site` FIRST — the browser computes it and NOTHING in the request path can rewrite
166
- * it, unlike `Host`.
167
- *
168
- * (`http-proxy`, `changeOrigin: true`) from :7788 to :7789, so the browser's `Origin` says
169
- * `:7788` while the `Host` reaching teapot says `:7789` — and comparing those two refused every
170
- * prompt sent through the proxy ("リバースプロキシした先からプロンプトを送信できない").
171
- *
172
- * `Sec-Fetch-Site` is `same-origin` on the operator's own page and `cross-site` on an attacker's,
173
- * regardless of how many proxies sit in between, because it describes the INITIATOR, not the
174
- * URL. Modern browsers always send it on a state-changing request.
175
- */
176
98
  const site = c.req.header("sec-fetch-site");
177
99
  if (site === "cross-site")
178
100
  return false;
179
101
  if (site === "same-origin" || site === "none")
180
102
  return true;
181
- /**
182
- * Older browsers do not send `Sec-Fetch-Site`, so fall back to comparing hosts — using
183
- * `X-Forwarded-Host` when a proxy supplied one.
184
- *
185
- * That is not a bypass: `X-Forwarded-Host` is not a CORS-safelisted header, so a cross-origin
186
- * page cannot set it without a preflight, and this server approves no preflight. A LOCAL process
187
- * could forge it — and a local process can already read the config, so it is not the threat this
188
- * guards.
189
- */
190
103
  const host = c.req.header("x-forwarded-host") ?? c.req.header("host") ?? "";
191
104
  try {
192
105
  const u = new URL(origin);
@@ -194,33 +107,16 @@ export function buildApp(master) {
194
107
  return true;
195
108
  }
196
109
  catch {
197
- /* "null" (a sandboxed iframe or a redirect) is never acceptable */
198
110
  return false;
199
111
  }
200
112
  return false;
201
113
  };
202
114
  app.use("/api/*", async (c, next) => {
203
- /**
204
- * A cross-origin WRITE is refused BEFORE the token check.
205
- *
206
- * Order matters: a token-less server (`!want` below) would otherwise hand every cross-origin
207
- * request straight through, which is exactly the state a first-run install is in.
208
- */
209
115
  const method = c.req.method.toUpperCase();
210
116
  if (method !== "GET" && method !== "HEAD" && !sameOrigin(c)) {
211
117
  return c.json({ error: "cross-origin request refused — set an API token in settings, or call the API from a local client" }, 403);
212
118
  }
213
119
  const want = apiToken();
214
- /**
215
- * With no token configured the API is still open to LOCAL clients, but it now says so loudly.
216
- *
217
- * Opening it is a real decision: a fresh install has no password, and on loopback that is the
218
- * safe default — only this machine can reach it. (It IS settable now, in settings → live update;
219
- * before that the only path was `POST /api/setup`, which refuses once a config file exists, so
220
- * the wizard's removal had left no way at all.) Leaving the state silent is how the OpenCode
221
- * advisory happened — their server was unauthenticated by default and nobody noticed. The header
222
- * makes it visible to every client, and the startup banner says it once in plain words.
223
- */
224
120
  if (!want) {
225
121
  c.header("x-teapot-auth", "none");
226
122
  return next();
@@ -236,95 +132,40 @@ export function buildApp(master) {
236
132
  }
237
133
  return c.json({ error: "unauthorized" }, 401);
238
134
  });
239
- // The per-agent terminal cap lives in `terminals.ts` now, next to the registry that owns the
240
- // shells — a counter here could drift from the shells it was meant to bound (it did once:
241
- // #70, where a refused connection decremented a slot it never took).
242
- /**
243
- * Live `/api/ws` event-stream connections.
244
- *
245
- * Every one of these receives EVERY agent's events, so the count is a direct
246
- * multiplier on fan-out, and `bus.setMaxListeners(1000)` only silences a
247
- * warning — it does not bound anything. Capped so a stuck or hostile client
248
- * cannot pin unbounded memory, and paired with a liveness check below so a
249
- * client that stops reading is reaped rather than buffered for ever.
250
- */
251
135
  let wsClients = 0;
252
- /**
253
- * Agent ids reserved by an in-flight create.
254
- *
255
- * The uniqueness loop is a check, not a reservation, and `addAgent` only
256
- * publishes the id after awaiting — so concurrent creates all computed the
257
- * same "unique" id and all persisted an entry. Reserving before the first
258
- * await closes the window. Entries are released as soon as the agent is
259
- * published or the create fails, so this never grows.
260
- */
261
136
  const idReservations = new Set();
262
- // keyed by CHILD, not agent: one agent may have up to 10 concurrent
263
- // terminals, each with its own size and its own foreground program
264
137
  const termSizes = new TermSizeTracker();
265
- // #20 follow-up: apply the size with an ioctl on the pty DEVICE
266
- // (`stty -F <slave>`) rather than writing `stty …` into the child's stdin.
267
- //
268
- // Writing to stdin is only safe while a SHELL is reading it: when an
269
- // interactive program owns the tty (vim, less, a REPL) it is the PROGRAM that
270
- // reads, so the command arrived as keystrokes and landed in the operator's
271
- // document. MEASURED: `stty -F /dev/pts/0 rows 55 cols 190` sets the size
272
- // (readback 55 190), delivers a real SIGWINCH to the foreground process, and
273
- // emits no bytes into the terminal stream at all — the same mechanism VS Code
274
- // (node-pty) and Alacritty use.
275
- //
276
- // The slave is resolved per call rather than cached: `script` may have exited
277
- // and been replaced, and a stale device path would resize someone else's pty.
278
- //
279
- // There is deliberately NO stdin fallback. Writing `stty rows R cols C` into the child's
280
- // stdin is the bug that was reported twice: when a full-screen program owns the tty it
281
- // receives that line as KEYSTROKES, so it lands in the operator's document. A resize that
282
- // cannot be applied is better than one that types into vim — so this does nothing instead.
283
- // Hosts without /proc are covered by `resolvePtySlave`'s `ps` path.
284
138
  const setTermSize = (child, rows, cols) => termSizes.request(child, rows, cols, (r, c) => {
285
139
  applyPtySize(child, r, c);
286
140
  });
287
- // ---- realtime events over WebSocket (replaces SSE for the web UI) ----
288
141
  app.get("/api/ws", upgradeWebSocket(() => {
289
142
  let onUpdate = null;
290
143
  let ka = null;
291
- // set by onOpen, cleared by a pong. Declared at the connection's scope
292
- // because onMessage is a SIBLING handler and cannot see a local in onOpen.
293
144
  let awaitingPong = false;
294
145
  let counted = false;
295
146
  return {
296
147
  onOpen(_evt, ws) {
297
- // refuse beyond the cap rather than accepting and buffering.
298
- // Each connection costs a listener on the global bus and receives
299
- // every agent's events, so this bounds real memory, not just a count.
300
148
  if (wsClients >= MAX_WS_CLIENTS) {
301
149
  try {
302
150
  ws.send(JSON.stringify({ kind: "error", error: `too many event-stream clients (max ${MAX_WS_CLIENTS})` }));
303
151
  ws.close(1013, "too many clients");
304
152
  }
305
153
  catch {
306
- /* already gone */
307
154
  }
308
155
  return;
309
156
  }
310
157
  wsClients++;
311
158
  counted = true;
312
- // a client that stops reading makes ws.send buffer without bound; the
313
- // app-level ping doubles as a liveness probe — if a ping goes
314
- // unanswered the socket is reaped instead of accumulating
315
159
  const send = (data) => {
316
160
  try {
317
161
  ws.send(JSON.stringify(data));
318
162
  }
319
163
  catch {
320
- /* client gone */
321
164
  }
322
165
  };
323
166
  send({ kind: "hello", agents: [...master.agents.values()].map((a) => a.snapshot()) });
324
167
  onUpdate = (raw) => {
325
168
  const ev = raw;
326
- // attach the fresh snapshot to agent-update events so clients can
327
- // update their state WITHOUT a follow-up GET /api/agents poll
328
169
  if (ev?.kind === "agent-update" && ev.agentId) {
329
170
  const a = master.agents.get(ev.agentId);
330
171
  send({ ...ev, snapshot: a?.snapshot() });
@@ -333,17 +174,12 @@ export function buildApp(master) {
333
174
  send(ev);
334
175
  };
335
176
  bus.on("update", onUpdate);
336
- // app-level liveness ping every 30s, and a reaper for a client that
337
- // has stopped answering
338
177
  ka = setInterval(() => {
339
178
  if (awaitingPong) {
340
- // the previous ping went unanswered: the client is not reading,
341
- // so its send buffer is growing. Reap it.
342
179
  try {
343
180
  ws.close(1011, "unresponsive");
344
181
  }
345
182
  catch {
346
- /* already gone */
347
183
  }
348
184
  return;
349
185
  }
@@ -352,7 +188,6 @@ export function buildApp(master) {
352
188
  }, 30_000);
353
189
  },
354
190
  onMessage(evt, ws) {
355
- // clients may send {"kind":"ping"} — nothing else to do today
356
191
  try {
357
192
  const m = JSON.parse(String(evt.data));
358
193
  if (m?.kind === "ping")
@@ -361,7 +196,6 @@ export function buildApp(master) {
361
196
  awaitingPong = false;
362
197
  }
363
198
  catch {
364
- /* ignore junk */
365
199
  }
366
200
  },
367
201
  onClose() {
@@ -369,24 +203,12 @@ export function buildApp(master) {
369
203
  clearInterval(ka);
370
204
  if (onUpdate)
371
205
  bus.off("update", onUpdate);
372
- // only a connection that was actually admitted owns a slot — a
373
- // refused one must not decrement one it never took (#70's lesson)
374
206
  if (counted)
375
207
  wsClients = Math.max(0, wsClients - 1);
376
208
  },
377
209
  };
378
210
  }));
379
- // ---- human terminal: interactive shell in the agent's workspace ----
380
- // Uses util-linux `script` as a zero-dependency PTY when available (colors,
381
- // line editing, ctrl+c); falls back to plain pipes otherwise.
382
- /*
383
- * Terminals are SERVER state now (#term-shared): the list survives a reload and several
384
- * clients see the same shells. These REST routes own creation and closing; the WebSocket
385
- * below only ATTACHES to an existing terminal (or creates one when asked).
386
- */
387
211
  setTerminalResize(setTermSize, resolvePtySlave);
388
- // Terminals outlive a disconnect on purpose, but a CLOSED tab never re-attaches. Sweep the
389
- // ones nobody has held for hours so an abandoned shell does not live as long as the process.
390
212
  {
391
213
  const t = setInterval(() => {
392
214
  const n = reapIdleTerminals();
@@ -395,7 +217,6 @@ export function buildApp(master) {
395
217
  }, 30 * 60_000);
396
218
  t.unref?.();
397
219
  }
398
- /** every live terminal, so a fresh page can rebuild the tab strip */
399
220
  app.get("/api/terminals", (c) => c.json({ terminals: listTerminals() }));
400
221
  app.post("/api/terminals", async (c) => {
401
222
  const body = (await c.req.json().catch(() => null));
@@ -414,30 +235,19 @@ export function buildApp(master) {
414
235
  const closed = closeTerminal(c.req.param("tid") ?? "");
415
236
  return c.json({ closed });
416
237
  });
417
- // every terminal of one agent (the UI groups tabs by agent)
418
238
  app.delete("/api/agents/:id/terminals", (c) => {
419
239
  const n = closeTerminalsFor(c.req.param("id") ?? "");
420
240
  return c.json({ closed: n });
421
241
  });
422
242
  app.get("/api/agents/:id/term", upgradeWebSocket((c) => {
423
243
  const agentId = c.req.param("id") ?? "";
424
- /**
425
- * `?terminal=<id>` ATTACHES to an existing shell; without it a new one is created.
426
- *
427
- * The distinction is the whole point of moving terminals server-side: a reload sends the
428
- * id it already knows and gets its screen back, while a new tab sends nothing and gets a
429
- * fresh shell.
430
- */
431
244
  const wanted = c.req.query("terminal") ?? "";
432
245
  let attachedId = "";
433
- // the sink is a stable identity for THIS connection, so the registry can key per-client
434
- // sizes by it and detach exactly this one
435
246
  const sink = (msg) => {
436
247
  try {
437
248
  ws.send(JSON.stringify(msg));
438
249
  }
439
250
  catch {
440
- /* client gone; onClose detaches */
441
251
  }
442
252
  };
443
253
  let ws;
@@ -452,31 +262,15 @@ export function buildApp(master) {
452
262
  socket.send(JSON.stringify({ kind: "exit", error: `no such agent: ${agentId}` }));
453
263
  }
454
264
  catch {
455
- /* ignore */
456
265
  }
457
266
  return;
458
267
  }
459
268
  let id = wanted;
460
- /**
461
- * A keep-alive ping, because an idle terminal socket is killed silently.
462
- *
463
- * The main socket has done this since it was written (30s ping, reaping a client that
464
- * stops answering), and the terminal socket did not. A terminal is idle by nature — the
465
- * operator opens a shell, walks away, comes back — so it is exactly the connection a proxy
466
- * or an OS idle timeout drops. MEASURED on the operator's own server: three terminals
467
- * open, two of them `clients: 0` with the panes still on screen and dead, while the one
468
- * being used stayed attached.
469
- *
470
- * The ping also gives the CLIENT a liveness signal to notice a half-open socket: TCP can
471
- * stay "open" locally after the peer is gone, and without traffic there is nothing to
472
- * fail on.
473
- */
474
269
  ka = setInterval(() => {
475
270
  try {
476
271
  socket.send(JSON.stringify({ kind: "ping" }));
477
272
  }
478
273
  catch {
479
- /* onClose detaches */
480
274
  }
481
275
  }, 30_000);
482
276
  if (!id) {
@@ -486,7 +280,6 @@ export function buildApp(master) {
486
280
  socket.send(JSON.stringify({ kind: "exit", error: created.error }));
487
281
  }
488
282
  catch {
489
- /* ignore */
490
283
  }
491
284
  return;
492
285
  }
@@ -498,20 +291,16 @@ export function buildApp(master) {
498
291
  socket.send(JSON.stringify({ kind: "exit", error: r.error }));
499
292
  }
500
293
  catch {
501
- /* ignore */
502
294
  }
503
295
  return;
504
296
  }
505
297
  attachedId = id;
506
- // tell the client WHICH terminal this connection is for, then REPLAY the backlog so
507
- // the screen is correct before live output starts arriving
508
298
  try {
509
299
  socket.send(JSON.stringify({ kind: "attach", terminal: r.terminal }));
510
300
  if (r.replay)
511
301
  socket.send(JSON.stringify({ kind: "data", data: r.replay }));
512
302
  }
513
303
  catch {
514
- /* ignore */
515
304
  }
516
305
  },
517
306
  onMessage(evt) {
@@ -536,23 +325,15 @@ export function buildApp(master) {
536
325
  onClose() {
537
326
  if (ka)
538
327
  clearInterval(ka);
539
- // DETACH, do not kill: a reload must not destroy the shell (#term-shared)
540
328
  if (!attachedId)
541
329
  return;
542
330
  detachTerminal(attachedId, sink);
543
- // the pty outlives us; drop this client's size so it stops constraining the others
544
331
  forgetTerminalSize(attachedId, sink);
545
332
  },
546
333
  };
547
334
  }));
548
- // ---- agents ----
549
335
  app.get("/api/agents", (c) => c.json({ agents: [...master.agents.values()].map((a) => a.snapshot()) }));
550
- // create + start an agent on an arbitrary directory
551
336
  app.post("/api/agents", async (c) => {
552
- // `.catch(() => null)` like every other body read in this file. An
553
- // unparseable body used to throw inside the handler and surface as a bare
554
- // 500 — a client bug reported to the operator as a server fault, and it made
555
- // the route's own `400 "workspace required"` unreachable.
556
337
  const body = await c.req
557
338
  .json()
558
339
  .catch(() => null);
@@ -570,58 +351,24 @@ export function buildApp(master) {
570
351
  return c.json({ error: `directory not found: ${ws}` }, 400);
571
352
  }
572
353
  const base = (body.id?.trim() || path.basename(ws)).replace(/[^\w.-]/g, "-").slice(0, 40);
573
- // agent ids are unique among running agents (they key the URL) — on a
574
- // collision, auto-suffix so creating ~/a/proj and ~/b/proj just works
575
354
  let id = base;
576
355
  let n = 2;
577
- // ids must be unique among RUNNING agents AND persisted config entries —
578
- // a config-only agent (not yet loaded) would otherwise collide on addAgent
579
- //
580
- // the loop below is a CHECK, not a RESERVATION, and addAgent only
581
- // publishes the id after several awaits (mkdirSync, agent.init()). Every
582
- // concurrent request therefore passed the check, computed the same "unique"
583
- // id, and all persisted an entry — measured: 3 concurrent creates → 3 config
584
- // entries, and with 5 concurrent spawns each minting its own session dir,
585
- // `resolveSessionDir` binds to the first entry on restart and the rest are
586
- // orphaned.
587
- //
588
- // So the id is RESERVED synchronously, before the first await. The set is
589
- // per-process and only guards the window; a real agent arriving through
590
- // another path still collides correctly via addAgent's own check.
591
356
  while ((master.agents.has(id) || master.config.agents.some((a) => a.id === id) || idReservations.has(id)))
592
357
  id = `${base.slice(0, 38)}-${n++}`;
593
358
  idReservations.add(id);
594
359
  const releaseId = () => idReservations.delete(id);
595
360
  try {
596
- // the reservation covers the awaits inside addAgent; once the agent is
597
- // published the reservation is redundant and must be released
598
361
  const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, { persist: true, fresh: true });
599
362
  releaseId();
600
- // NOTE: creation no longer auto-starts an LLM loop — the agent sits in
601
- // "stopped" (a lazy, zero-cost session) until the operator sends the
602
- // first prompt or presses ▶ start. The web UI relies on this.
603
363
  if (body.start === true)
604
364
  agent.start("created via web");
605
365
  return c.json({ ok: true, agent: agent.snapshot() });
606
366
  }
607
367
  catch (err) {
608
- releaseId(); // never leak a reservation on failure
368
+ releaseId();
609
369
  return c.json({ error: err.message }, 400);
610
370
  }
611
371
  });
612
- // archive / restore / purge. "remove" used to mean "delete the config entry and
613
- // orphan the history on disk"; these make the three intents distinct — put it
614
- // away (reversible), bring it back, or destroy it for real.
615
- /**
616
- * Background shells for an agent's workspace.
617
- *
618
- * Scoped by AGENT rather than by workspace path: the operator thinks in agents, and a workspace is
619
- * a shared thing several of them use. The registry keys by workspace (that is what needs killing),
620
- * so the lookup is `agent.workspace` here and the response carries `owner` for each job.
621
- *
622
- * Read-only apart from the DELETE. There is deliberately no "start a job" route: background shells
623
- * are an AGENT's tool, and a job the operator started would have no agent to notify.
624
- */
625
372
  app.get("/api/agents/:id/bg-jobs", (c) => {
626
373
  const agent = master.agents.get(c.req.param("id"));
627
374
  if (!agent)
@@ -638,13 +385,6 @@ export function buildApp(master) {
638
385
  return c.json({ ok }, ok ? 200 : 404);
639
386
  });
640
387
  app.get("/api/archived", (c) => c.json({ agents: master.archivedAgents() }));
641
- /**
642
- * Rename the session the operator is looking at.
643
- *
644
- * `?session=` is required rather than inferred: a title names a SPECIFIC session, and an agent can
645
- * own several, so defaulting to the agent's current one would rename the wrong thing whenever the
646
- * UI was showing a past session of it.
647
- */
648
388
  app.put("/api/agents/:id/session-title", async (c) => {
649
389
  const sessionId = c.req.query("session") ?? "";
650
390
  if (!sessionId)
@@ -677,7 +417,6 @@ export function buildApp(master) {
677
417
  return c.json({ error: err.message }, 404);
678
418
  }
679
419
  });
680
- // DESTRUCTIVE: config entry AND session directories
681
420
  app.delete("/api/agents/:id/purge", async (c) => {
682
421
  try {
683
422
  return c.json(await master.purgeAgent(c.req.param("id")));
@@ -686,7 +425,6 @@ export function buildApp(master) {
686
425
  return c.json({ error: err.message }, 404);
687
426
  }
688
427
  });
689
- // remove agent (log file is kept)
690
428
  app.delete("/api/agents/:id", async (c) => {
691
429
  try {
692
430
  await master.removeAgent(c.req.param("id"));
@@ -696,11 +434,7 @@ export function buildApp(master) {
696
434
  return c.json({ error: err.message }, 404);
697
435
  }
698
436
  });
699
- // ---- filesystem browsing (read-only, for the workspace picker) ----
700
437
  app.get("/api/fs", async (c) => {
701
- // read the home directory through the shared helper. `process.env.HOME`
702
- // is unset on Windows, which made the old fallback yield the literal "~" —
703
- // and /api/fs would then browse a directory named ~.
704
438
  let p = c.req.query("path") || homeDir() || path.parse(process.cwd()).root;
705
439
  p = path.resolve(expandTilde(p));
706
440
  try {
@@ -719,28 +453,9 @@ export function buildApp(master) {
719
453
  return c.json({ error: "cannot read" }, 400);
720
454
  }
721
455
  });
722
- /**
723
- * Batched ignore lookup: runs `git check-ignore --stdin` once per directory
724
- * and returns the subset of names that are gitignored. Never throws — a
725
- * non-git workspace simply yields an empty set.
726
- */
727
456
  async function checkIgnoredBatch(dirAbs, dirents) {
728
457
  const out = new Set();
729
458
  try {
730
- /**
731
- * Run git FROM THE DIRECTORY BEING LISTED, and pass bare names.
732
- *
733
- * `git -C <workspace> check-ignore` with workspace-relative paths fails when the workspace
734
- * itself is not a repository — and a workspace is very often just a PARENT FOLDER holding
735
- * several checkouts. MEASURED: with `~/work/tyb2` as the workspace, listing
736
- * `mt6589-emulator/` (its own repo, whose `.gitignore` line 1 is `renode/`) returned nothing
737
- * ignored, because `git -C ~/work/tyb2` exits with "not a git repository" and the empty
738
- * result was read as "nothing is ignored".
739
- *
740
- * `git -C <dirAbs>` finds the enclosing repo by walking UP, which is exactly the behaviour a
741
- * file tree wants: each directory reports against whichever repository actually contains it,
742
- * including a nested `.gitignore`. Bare names keep the parse below simple.
743
- */
744
459
  const relPaths = dirents.filter((d) => !d.name.startsWith(".")).map((d) => d.name);
745
460
  if (relPaths.length === 0)
746
461
  return out;
@@ -752,36 +467,22 @@ export function buildApp(master) {
752
467
  clearTimeout(timer);
753
468
  resolve(v);
754
469
  };
755
- const p = execFile("git",
756
- // NOTE: no --quiet — it suppresses the stdout we parse
757
- // `-C dirAbs`, not the workspace: git then finds the enclosing repo by walking up, so a
758
- // workspace that is merely a PARENT of several checkouts still reports each one correctly
759
- ["-C", dirAbs, "check-ignore", "--stdin", "-z", "--verbose"], { encoding: "utf8", maxBuffer: 1 << 20 }, (err, stdout) => finished(err || !stdout ? "" : stdout));
760
- // git can exit before we finish writing (nothing to ignore, or the
761
- // cap below killed it). Without this handler the stdin "error"
762
- // becomes an UNCAUGHT EXCEPTION that kills the request — seen as
763
- // "write EPIPE" right after boot when several trees load at once.
470
+ const p = execFile("git", ["-C", dirAbs, "check-ignore", "--stdin", "-z", "--verbose"], { encoding: "utf8", maxBuffer: 1 << 20 }, (err, stdout) => finished(err || !stdout ? "" : stdout));
764
471
  p.stdin?.on("error", () => { });
765
- // end stdin only AFTER the write flushes (ending immediately raced
766
- // the write and git saw an empty list), and hard-cap in case git
767
- // wedges — a tree listing must never hang
768
472
  p.stdin?.write(relPaths.join("\0") + "\0", () => {
769
473
  try {
770
474
  p.stdin?.end();
771
475
  }
772
- catch { /* already gone */ }
476
+ catch { }
773
477
  });
774
478
  timer = setTimeout(() => {
775
479
  try {
776
480
  p.kill("SIGKILL");
777
481
  }
778
- catch { /* */ }
482
+ catch { }
779
483
  finished("");
780
484
  }, 5_000);
781
485
  });
782
- // -z --verbose emits records as
783
- // "<source> NUL <line> NUL <pattern> NUL <path> NUL"
784
- // so the ignored path is every 4th field starting at index 3
785
486
  const fields = res.split("\0");
786
487
  for (let i = 3; i < fields.length; i += 4) {
787
488
  const p = fields[i];
@@ -790,11 +491,9 @@ export function buildApp(master) {
790
491
  }
791
492
  }
792
493
  catch {
793
- /* no git, not a repo, or spawn failed → treat all as unignored */
794
494
  }
795
495
  return out;
796
496
  }
797
- // ---- workspace file tree (read-only, powers the 🗂 files panel) ----
798
497
  app.get("/api/agents/:id/tree", async (c) => {
799
498
  const a = master.agents.get(c.req.param("id"));
800
499
  if (!a)
@@ -805,10 +504,6 @@ export function buildApp(master) {
805
504
  abs = safeJoin(a.workspace, rel);
806
505
  }
807
506
  catch (err) {
808
- // safeJoin's message embeds the RESOLVED absolute path
809
- // ("path escapes workspace: /home/user/secret/project/…"), so returning it
810
- // verbatim hands the caller the server's directory layout. The rejection is
811
- // correct; the disclosure was not. Echo back the path AS GIVEN.
812
507
  return c.json({ error: `path is outside the workspace: ${rel}` }, 400);
813
508
  }
814
509
  let dirents;
@@ -820,13 +515,10 @@ export function buildApp(master) {
820
515
  }
821
516
  const showHidden = c.req.query("hidden") === "1";
822
517
  const entries = [];
823
- // one batched `git check-ignore` for the whole directory (respects
824
- // nested .gitignore files); absent repo / no git → nothing is "ignored"
825
518
  const ignoredNames = await checkIgnoredBatch(abs, dirents);
826
519
  for (const d of dirents) {
827
520
  if (d.name.startsWith(".") && !(showHidden && d.name !== "." && d.name !== ".."))
828
- continue; // hidden files stay out unless the caller opted in
829
- // .git behaves like an ignored entry (dimmed/hidden with the rest)
521
+ continue;
830
522
  const forceIgnored = !d.isDirectory() ? false : d.name === ".git";
831
523
  const isDir = d.isDirectory();
832
524
  let size;
@@ -835,7 +527,6 @@ export function buildApp(master) {
835
527
  size = (await fs.stat(path.join(abs, d.name))).size;
836
528
  }
837
529
  catch {
838
- /* vanished mid-scan — fine */
839
530
  }
840
531
  }
841
532
  const gitIg = ignoredNames.has(d.name);
@@ -853,7 +544,6 @@ export function buildApp(master) {
853
544
  entries,
854
545
  });
855
546
  });
856
- // small text-file preview for the tree (binary-safe guard, hard cap)
857
547
  app.get("/api/agents/:id/file", async (c) => {
858
548
  const a = master.agents.get(c.req.param("id"));
859
549
  if (!a)
@@ -883,8 +573,6 @@ export function buildApp(master) {
883
573
  return c.json({ error: err.message }, 400);
884
574
  }
885
575
  });
886
- // raw bytes for media previews (images / video / audio) — auth-gated like
887
- // every other agent route, workspace-confined, served with the right MIME
888
576
  app.get("/api/agents/:id/raw", async (c) => {
889
577
  const a = master.agents.get(c.req.param("id"));
890
578
  if (!a)
@@ -906,12 +594,8 @@ export function buildApp(master) {
906
594
  return c.json({ error: "not a previewable media file" }, 400);
907
595
  if (buf.length > 200 * 1024 * 1024)
908
596
  return c.json({ error: "file too large to preview (>200MB)" }, 413);
909
- // hono-native body API — a raw `new Response(...)` returned here lost
910
- // its headers somewhere in the middleware chain (content-type arrived
911
- // as undefined), while c.body() passes them through correctly
912
597
  return c.body(new Uint8Array(buf), 200, {
913
598
  "content-type": mimeFor(rel, kind),
914
- // previews only; the browser shouldn't cache stale workspace files
915
599
  "cache-control": "no-store",
916
600
  "content-disposition": `inline; filename="${encodeURIComponent(path.basename(rel))}"`,
917
601
  });
@@ -920,9 +604,6 @@ export function buildApp(master) {
920
604
  return c.json({ error: err.message }, 400);
921
605
  }
922
606
  });
923
- // write a file from the web UI's file-tree editor (human action, not an
924
- // agent tool — allowed even for read-only personas, never notifies the
925
- // agent). Optional baseContent enables optimistic-concurrency checking.
926
607
  app.put("/api/agents/:id/file", async (c) => {
927
608
  const a = master.agents.get(c.req.param("id"));
928
609
  if (!a)
@@ -949,11 +630,8 @@ export function buildApp(master) {
949
630
  if (e.code !== "ENOENT")
950
631
  throw e;
951
632
  }
952
- // refuse to clobber binary files (same 8KB NUL probe as GET)
953
633
  if (existing && existing.subarray(0, 8192).includes(0))
954
634
  return c.json({ error: "binary file — edit refused" }, 400);
955
- // conflict check: the editor sends what it loaded; if disk moved on,
956
- // bounce with the fresh content instead of silently overwriting
957
635
  if (typeof body.baseContent === "string" &&
958
636
  existing &&
959
637
  existing.subarray(0, 100_000).toString("utf8") !== body.baseContent) {
@@ -971,16 +649,7 @@ export function buildApp(master) {
971
649
  return c.json({ error: err.message }, 400);
972
650
  }
973
651
  });
974
- // ---- config (view/edit from the web UI) ----
975
652
  app.get("/api/config", (c) => {
976
- /**
977
- * Mask EVERY credential, including each entry of the `apiKeys` pool.
978
- *
979
- * Only `apiKey` was masked, so a provider holding three keys answered with one masked key and
980
- * no `apiKeys` at all — the settings dialog could not show the pool it was editing, and saving
981
- * that view wrote the single key back. The rotation the operator configured existed nowhere
982
- * they could see.
983
- */
984
653
  const maskKey = (k) => (k ? "•••" + String(k).slice(-4) : undefined);
985
654
  const mask = (p) => Object.fromEntries(Object.entries(p ?? {}).map(([k, v]) => [
986
655
  k,
@@ -992,25 +661,7 @@ export function buildApp(master) {
992
661
  ]));
993
662
  return c.json({
994
663
  configPath: master.configPath,
995
- /**
996
- * "First run" now means NO AI PROVIDER IS REGISTERED, not "no config file".
997
- *
998
- * The wizard that used to gate on the config file is gone. A config file can exist with no
999
- * usable provider (the operator edited it by hand, or every provider was removed), and in
1000
- * that state the wizard did not appear and the app simply could not do anything. The
1001
- * question the UI actually needs answered is "can this thing talk to a model yet?".
1002
- *
1003
- * A provider counts as registered when it has an entry at all — a key may legitimately be
1004
- * absent for a local endpoint (ollama, a proxy that authenticates by network), so requiring
1005
- * one would nag about a working setup.
1006
- */
1007
664
  needsSetup: Object.keys(master.config.providers ?? {}).length === 0,
1008
- /**
1009
- * Whether an API password is set — the FACT only, never the value.
1010
- *
1011
- * The panel needs it to label the field honestly ("unchanged" vs "none"); sending the secret
1012
- * back would put it in the DOM and in any devtools capture of the response.
1013
- */
1014
665
  hasPassword: Boolean(master.config.password),
1015
666
  providers: mask(master.config.providers),
1016
667
  defaultProvider: master.config.defaultProvider,
@@ -1021,10 +672,6 @@ export function buildApp(master) {
1021
672
  maxSpawnDepth: master.config.maxSpawnDepth,
1022
673
  shortContext: master.config.shortContext,
1023
674
  tasks: master.config.tasks,
1024
- // the SAVED MCP servers. Without this the settings dialog opened with an
1025
- // empty list, so adding a server, saving, and reopening showed nothing — the save
1026
- // had worked, but nothing ever read it back. Headers are returned as saved: they
1027
- // are ordinary config, and the dialog needs them to round-trip.
1028
675
  mcpServers: master.config.mcpServers ?? [],
1029
676
  agents: master.config.agents.map((a) => ({ id: a.id, workspace: a.workspace, provider: a.provider, model: a.model })),
1030
677
  version: master.getVersion(),
@@ -1034,7 +681,6 @@ export function buildApp(master) {
1034
681
  const body = await c.req.json().catch(() => null);
1035
682
  if (!body)
1036
683
  return c.json({ error: "invalid JSON" }, 400);
1037
- // schema gate first: reject malformed edits with actionable messages
1038
684
  const parsed = ConfigPatchSchema.safeParse(body);
1039
685
  if (!parsed.success)
1040
686
  return c.json({ error: formatZodError(parsed.error) }, 400);
@@ -1042,19 +688,8 @@ export function buildApp(master) {
1042
688
  try {
1043
689
  for (const t of patch.tasks ?? [])
1044
690
  parseSchedule(t.schedule);
1045
- // keep masked keys intact: "•••1234" means "unchanged"
1046
691
  const prev = master.config.providers ?? {};
1047
692
  const providers = {};
1048
- /**
1049
- * Persist the WHOLE `apiKeys` pool, which is what the settings dialog edits.
1050
- *
1051
- * This built only `apiKey`, so every key after the first was discarded on save: the operator
1052
- * could add three and the config kept one. `apiKey` is still written as `apiKeys[0]` because
1053
- * other readers expect the legacy field (see `migrateProviderKeys`).
1054
- *
1055
- * A key arrives masked as "•••1234" when untouched, meaning "the stored value" — resolved
1056
- * POSITIONALLY against the previous list, so editing key 2 does not overwrite key 1.
1057
- */
1058
693
  for (const [name, p] of Object.entries(patch.providers ?? {})) {
1059
694
  const stored = prev[name]?.apiKeys ?? (prev[name]?.apiKey ? [prev[name].apiKey] : []);
1060
695
  const resolve = (k, i) => {
@@ -1066,7 +701,6 @@ export function buildApp(master) {
1066
701
  const keys = incoming
1067
702
  ? incoming.map((k, i) => resolve(k, i)).filter((k) => !!k)
1068
703
  : [];
1069
- // a provider with NO pool keeps the legacy single-key path, including "unchanged"
1070
704
  const single = !incoming
1071
705
  ? !p.apiKey || p.apiKey.startsWith("•••")
1072
706
  ? stored[0]
@@ -1085,8 +719,6 @@ export function buildApp(master) {
1085
719
  progressIntervalMs: patch.progressIntervalMs,
1086
720
  progressMinChars: patch.progressMinChars,
1087
721
  contextTokenBudget: patch.contextTokenBudget,
1088
- // these four were validated (200 ok) and then DROPPED — the settings
1089
- // modal's context window never reached the config at all
1090
722
  contextWindowTokens: patch.contextWindowTokens,
1091
723
  maxTurnsPerRound: patch.maxTurnsPerRound,
1092
724
  onError: patch.onError,
@@ -1097,9 +729,6 @@ export function buildApp(master) {
1097
729
  mcpServers: patch.mcpServers,
1098
730
  ...(patch.password !== undefined ? { password: patch.password } : {}),
1099
731
  });
1100
- // apply the new server list to the live registry and connect in the
1101
- // background — an added server's tools must appear on the next turn without a
1102
- // restart, and a removed one's connection must be closed now.
1103
732
  if (patch.mcpServers) {
1104
733
  mcp.apply(patch.mcpServers);
1105
734
  void mcp.connectAll();
@@ -1110,12 +739,6 @@ export function buildApp(master) {
1110
739
  return c.json({ error: err.message }, 400);
1111
740
  }
1112
741
  });
1113
- // ---- MCP servers ----
1114
- //
1115
- // `test` is the endpoint the settings panel uses to answer "does this work?" before
1116
- // saving: it connects, lists tools, and reports the count or the exact failure. Doing
1117
- // that through `PUT /api/config` would mean saving a broken server first, and the
1118
- // failure would then surface on an agent's turn instead of in the panel that caused it.
1119
742
  app.get("/api/mcp", (c) => c.json({
1120
743
  servers: master.config.mcpServers ?? [],
1121
744
  states: mcp.states().map((s) => ({
@@ -1126,9 +749,6 @@ export function buildApp(master) {
1126
749
  ...(s.lastChecked ? { lastChecked: s.lastChecked } : {}),
1127
750
  })),
1128
751
  }));
1129
- // Agent Plugins: what was loaded, and where from. Read-only on purpose —
1130
- // installing a plugin is a filesystem act, and a web endpoint that unpacks an
1131
- // archive is a much larger surface than this feature is worth.
1132
752
  app.get("/api/plugins", (c) => c.json({
1133
753
  pluginsDir: master.config.pluginsDir === false
1134
754
  ? null
@@ -1143,46 +763,23 @@ export function buildApp(master) {
1143
763
  warnings: p.warnings,
1144
764
  })),
1145
765
  }));
1146
- /**
1147
- * Start an OAuth flow for one server.
1148
- *
1149
- * Responds with the URL to open IMMEDIATELY; the exchange finishes when the browser
1150
- * comes back to the loopback listener. That split is what lets the panel show a
1151
- * clickable link instead of hanging the request for minutes.
1152
- */
1153
766
  app.post("/api/mcp/oauth/start", async (c) => {
1154
767
  const body = await c.req
1155
768
  .json()
1156
769
  .catch(() => null);
1157
770
  if (!body?.name)
1158
771
  return c.json({ error: "missing name" }, 400);
1159
- /**
1160
- * Accept a server that has not been SAVED yet.
1161
- *
1162
- * Requiring a saved entry forced the operator through save → dialog closes → reopen → authorize,
1163
- * and then a second save afterwards to keep the grant attached. Asked as "未保存のMCPサーバーに
1164
- * 対してOAuthまでした状態を後から保存するというちょっとめんどくさいフローが発生しちゃう".
1165
- *
1166
- * The URL from the request wins when present; otherwise the saved entry is used. Nothing is
1167
- * written here — the grant is stored per server NAME in the OAuth store, so saving afterwards
1168
- * keeps it.
1169
- */
1170
772
  const saved = (master.config.mcpServers ?? []).find((m) => m.name === body.name);
1171
773
  const targetUrl = String(body.url ?? saved?.url ?? "").trim();
1172
774
  if (!targetUrl)
1173
775
  return c.json({ error: "this server has no url — OAuth needs an http(s) endpoint" }, 400);
1174
776
  try {
1175
- // register the candidate so the flow has a config to drive; replaced by the real one on save
1176
777
  mcp.registerUnsaved({ name: body.name, url: targetUrl, transport: body.transport ?? "http", enabled: true });
1177
- // `startOAuth` drives the SDK's own flow: discovery, dynamic registration, and the
1178
- // PKCE challenge all happen inside `connectMcp`, so this returns as soon as there
1179
- // is a URL to open. The exchange finishes when the browser hits the callback.
1180
778
  const { url } = await mcp.startOAuth(body.name, {
1181
779
  ...(body.scopes?.length ? { scopes: body.scopes } : {}),
1182
780
  });
1183
781
  const st = mcp.oauthStatus(body.name);
1184
782
  if (!url && st.authorized)
1185
- // a stored refresh token was still good — nothing for the operator to do
1186
783
  return c.json({ ok: true, alreadyAuthorized: true });
1187
784
  if (!url)
1188
785
  return c.json({ ok: false, error: st.lastError ?? "the authorization server produced no url" });
@@ -1196,9 +793,6 @@ export function buildApp(master) {
1196
793
  const name = c.req.query("name");
1197
794
  if (!name)
1198
795
  return c.json({ error: "missing name" }, 400);
1199
- // "needs OAuth" is now discovered by the SDK itself: an unauthorized connect leaves
1200
- // the server in `oauthRequired`, so there is no separate probe endpoint. The panel
1201
- // triggers that by calling `/api/mcp/connect` or by `test`.
1202
796
  const st = mcp.states().find((s) => s.config.name === name);
1203
797
  return c.json({ ...mcp.oauthStatus(name), required: Boolean(st?.oauthRequired) });
1204
798
  });
@@ -1214,27 +808,8 @@ export function buildApp(master) {
1214
808
  if (!body?.name)
1215
809
  return c.json({ error: "a server needs a name" }, 400);
1216
810
  try {
1217
- /**
1218
- * Uses the SAVED credentials, and still never starts a browser flow.
1219
- *
1220
- * The distinction matters: the point of `test` is "does this server work AS CONFIGURED", so it
1221
- * must present the grant the operator already obtained. It used to connect anonymously "on
1222
- * purpose" and report 401 — the row then said "needs a grant" for a server that was already
1223
- * `● connected`.
1224
- */
1225
811
  {
1226
812
  const { tools } = await mcp.testConfig(body);
1227
- /**
1228
- * A successful test CLEARS the row's `error`.
1229
- *
1230
- * The test used a throwaway connection and never touched the registry, so the saved
1231
- * server's state kept whatever the last boot produced: a server that had failed at boot
1232
- * (or while it was unauthorized) stayed `● error` even after a test listed its tools —
1233
- * reported as "起動時に error だったno authのMCPサーバーをtestしてうまく接続できたのに、ステータスが
1234
- * errorのまま". The test IS evidence about the same config, so it updates the same state.
1235
- *
1236
- * Only for an entry that already exists: `test` on an unsaved edit must not invent one.
1237
- */
1238
813
  mcp.recordSuccess(body.name, tools);
1239
814
  return c.json({
1240
815
  ok: true,
@@ -1244,8 +819,6 @@ export function buildApp(master) {
1244
819
  }
1245
820
  catch (err) {
1246
821
  const message = err.message;
1247
- // The SDK reports a missing grant as an HTTP error with a `status`. Reading that is
1248
- // reliable; matching the message is not (it is just "Error POSTing to endpoint").
1249
822
  const status = err.status;
1250
823
  const needsAuth = status === 401 || status === 403 || /unauthoriz|invalid_token|authorization required/i.test(message);
1251
824
  return c.json({ ok: false, error: message, needsOAuth: needsAuth }, 200);
@@ -1267,7 +840,6 @@ export function buildApp(master) {
1267
840
  const a = master.agents.get(c.req.param("id"));
1268
841
  return a ? c.json(a.snapshot()) : notFound(c, "agent", c.req.param("id"));
1269
842
  });
1270
- // lazy session load: stopped (not restored) → idle, history rebuilt from the log
1271
843
  app.post("/api/agents/:id/load", async (c) => {
1272
844
  const a = master.agents.get(c.req.param("id"));
1273
845
  if (!a)
@@ -1281,45 +853,29 @@ export function buildApp(master) {
1281
853
  return notFound(c, "agent", c.req.param("id"));
1282
854
  const body = await c.req
1283
855
  .json()
1284
- .catch(() => null); // #110
856
+ .catch(() => null);
1285
857
  if (!body)
1286
858
  return c.json({ error: "invalid JSON" }, 400);
1287
859
  if (!body.text?.trim() && !body.images?.length)
1288
860
  return c.json({ error: "text or images required" }, 400);
1289
- // validate images: data URLs or http(s), size-capped (a 20MB base64 blob
1290
- // in the log would hurt every future replay of this session)
1291
861
  const images = (body.images ?? []).filter((im) => {
1292
862
  const u = String(im?.url ?? "");
1293
863
  if (u.startsWith("data:image/"))
1294
- return u.length < 12_000_000; // ~9MB binary
864
+ return u.length < 12_000_000;
1295
865
  return /^https?:\/\//.test(u);
1296
866
  });
1297
867
  const text = body.text ?? "";
1298
- // returns immediately: the prompt is logged + broadcast now, delivered to
1299
- // the model at the next turn boundary (never blocks on a running agent).
1300
- // promptId ties the UI's pending echo to the later prompt-delivered note.
1301
- /**
1302
- * `source` is the CALLER's to state, defaulting to "user".
1303
- *
1304
- * A UI slash command that injects harness text (`/skill` sends
1305
- * `[harness] Force-load and follow …`) was arriving as a message "from you": the route hardcoded
1306
- * "user", so the timeline attributed the harness's own instruction to the operator. A prompt's
1307
- * source is data the client knows and the server cannot infer from the text.
1308
- *
1309
- * Only the two values that exist are accepted; anything else is the operator.
1310
- */
1311
868
  const src = body.source === "harness" ? "harness" : "user";
1312
869
  const promptId = a.enqueuePrompt(text || "(see attached image)", src, images.length ? images : undefined);
1313
870
  if (body.start !== false && a.status !== "running")
1314
871
  a.start("prompt");
1315
872
  return c.json({ ok: true, promptId, queued: a.snapshot().pendingPrompts });
1316
873
  });
1317
- // Withdraw a still-pending prompt; returns its text for the composer draft.
1318
874
  app.post("/api/agents/:id/prompt/cancel", async (c) => {
1319
875
  const a = master.agents.get(c.req.param("id"));
1320
876
  if (!a)
1321
877
  return notFound(c, "agent", c.req.param("id"));
1322
- const body = await c.req.json().catch(() => null); // #110
878
+ const body = await c.req.json().catch(() => null);
1323
879
  if (!body)
1324
880
  return c.json({ error: "invalid JSON" }, 400);
1325
881
  if (!body.promptId)
@@ -1336,7 +892,6 @@ export function buildApp(master) {
1336
892
  a.start("api start");
1337
893
  return c.json({ ok: true });
1338
894
  });
1339
- // ---- providers & models (for the session panel's model switcher) ----
1340
895
  app.get("/api/providers", (c) => c.json({
1341
896
  providers: Object.entries(master.config.providers ?? {}).map(([name, p]) => ({
1342
897
  name,
@@ -1346,7 +901,6 @@ export function buildApp(master) {
1346
901
  })),
1347
902
  defaultProvider: master.config.defaultProvider,
1348
903
  }));
1349
- // OpenAI-compatible upstream model listing (GET /v1/models)
1350
904
  app.get("/api/models", async (c) => {
1351
905
  const provName = c.req.query("provider") || master.config.defaultProvider || "";
1352
906
  const prov = master.config.providers?.[provName];
@@ -1359,9 +913,6 @@ export function buildApp(master) {
1359
913
  return c.json({ error: `model list failed: ${err.message}` }, 502);
1360
914
  }
1361
915
  });
1362
- // Model discovery DURING first-run setup — the config file doesn't exist
1363
- // yet, so there are no named providers to ask for. Takes the raw endpoint
1364
- // + key the operator is typing in the wizard and proxies GET /models.
1365
916
  app.get("/api/setup/models", async (c) => {
1366
917
  if (master.configFileExists)
1367
918
  return c.json({ error: "setup already completed" }, 409);
@@ -1377,7 +928,6 @@ export function buildApp(master) {
1377
928
  return c.json({ error: `model list failed: ${err.message}` }, 502);
1378
929
  }
1379
930
  });
1380
- // switch a running session's model/provider
1381
931
  app.post("/api/agents/:id/model", async (c) => {
1382
932
  const body = await c.req
1383
933
  .json()
@@ -1404,20 +954,15 @@ export function buildApp(master) {
1404
954
  if (!a)
1405
955
  return notFound(c, "agent", c.req.param("id"));
1406
956
  const body = await c.req.json()
1407
- .catch(() => null); // #110
957
+ .catch(() => null);
1408
958
  if (!body)
1409
959
  return c.json({ error: "invalid JSON" }, 400);
1410
960
  if (body.text) {
1411
961
  await a.setGoal(body.text);
1412
962
  if (typeof body.verify === "string" && body.verify.trim())
1413
963
  await a.setGoalVerify(body.verify.trim());
1414
- // goals live behind get_goal(), so a silent save would go unnoticed —
1415
- // queue a harness prompt unless the caller explicitly declines
1416
964
  if (body.notify !== false) {
1417
965
  a.enqueuePrompt(`[harness] The operator set a new goal:\n\n${body.text}\n\nAlign your work with it.`, "harness");
1418
- // an idle agent must actually START working on the new goal — the
1419
- // queued prompt alone sat there forever when nothing else started
1420
- // the loop (reported: goal save left the session idle indefinitely)
1421
966
  if (a.status !== "running")
1422
967
  a.start("goal set");
1423
968
  }
@@ -1428,7 +973,6 @@ export function buildApp(master) {
1428
973
  return c.json({ error: "text or status required" }, 400);
1429
974
  return c.json({ ok: true });
1430
975
  });
1431
- // skills visible to an agent (workspace + global + bundled roots)
1432
976
  app.get("/api/agents/:id/skills", async (c) => {
1433
977
  const a = master.agents.get(c.req.param("id"));
1434
978
  if (!a)
@@ -1443,17 +987,6 @@ export function buildApp(master) {
1443
987
  return c.json({ error: err.message }, 400);
1444
988
  }
1445
989
  });
1446
- /**
1447
- * The BODY of one skill, by name.
1448
- *
1449
- * The list endpoint returns name/description/source only, which is what the `/` palette and the
1450
- * settings list need — a skill body is up to tens of KB and no list should carry every one.
1451
- * Reading is separate and on demand, the same shape as the timeline's event paging.
1452
- *
1453
- * Resolved through `currentSkills` rather than from a path in the request: the client never
1454
- * names a file, so there is no traversal surface, and a skill that is not in this agent's roots
1455
- * simply does not exist here.
1456
- */
1457
990
  app.get("/api/agents/:id/skills/:name", async (c) => {
1458
991
  const a = master.agents.get(c.req.param("id"));
1459
992
  if (!a)
@@ -1465,7 +998,6 @@ export function buildApp(master) {
1465
998
  if (!def)
1466
999
  return c.json({ error: `no skill named ${want}` }, 404);
1467
1000
  const text = await readSkillFile(def);
1468
- // a hard cap, like the code preview: a pathological file must not fill the panel
1469
1001
  const MAX = 400_000;
1470
1002
  return c.json({
1471
1003
  name: def.name,
@@ -1480,7 +1012,6 @@ export function buildApp(master) {
1480
1012
  return c.json({ error: err.message }, 400);
1481
1013
  }
1482
1014
  });
1483
- // force a context compaction pass (slash command /compact)
1484
1015
  app.post("/api/agents/:id/compact", async (c) => {
1485
1016
  const a = master.agents.get(c.req.param("id"));
1486
1017
  if (!a)
@@ -1492,7 +1023,6 @@ export function buildApp(master) {
1492
1023
  return c.json({ error: err.message }, 409);
1493
1024
  }
1494
1025
  });
1495
- // first-run wizard bootstrap — only while no config file exists
1496
1026
  app.post("/api/setup", async (c) => {
1497
1027
  if (master.configFileExists)
1498
1028
  return c.json({ error: "setup already completed" }, 409);
@@ -1508,7 +1038,6 @@ export function buildApp(master) {
1508
1038
  return c.json({ error: err.message }, 400);
1509
1039
  }
1510
1040
  });
1511
- // per-agent auto-continue toggle (loops toward an active goal)
1512
1041
  app.post("/api/agents/:id/auto-continue", async (c) => {
1513
1042
  const a = master.agents.get(c.req.param("id"));
1514
1043
  if (!a)
@@ -1524,7 +1053,6 @@ export function buildApp(master) {
1524
1053
  bus.emit("update", { kind: "agent-update", agentId: c.req.param("id") });
1525
1054
  return c.json({ ok: true, value: body.value });
1526
1055
  });
1527
- // per-agent auto-compact toggle (auto-summarize when context exceeds budget)
1528
1056
  app.post("/api/agents/:id/auto-compact", async (c) => {
1529
1057
  const a = master.agents.get(c.req.param("id"));
1530
1058
  if (!a)
@@ -1540,22 +1068,9 @@ export function buildApp(master) {
1540
1068
  bus.emit("update", { kind: "agent-update", agentId: c.req.param("id") });
1541
1069
  return c.json({ ok: true, value: body.value });
1542
1070
  });
1543
- // default sub-agent personas for @mentions and spawn_agent
1544
1071
  app.get("/api/personas", (c) => c.json({
1545
1072
  personas: Object.entries(SUB_PERSONAS).map(([key, p]) => ({ key, label: p.label, directive: p.directive })),
1546
1073
  }));
1547
- /**
1548
- * Custom themes, SERVER-side, so they follow the config instead of one browser.
1549
- *
1550
- * They lived in `localStorage`: a theme made on the desktop did not exist on a phone or in a
1551
- * second browser, and clearing site data lost it. Asked for as "カスタムテーマをlocalStorageじゃ
1552
- * なくてサーバーで管理するようにしたい".
1553
- *
1554
- * Validation is deliberately shallow — `customThemes` is opaque to the server and the frontend
1555
- * already sanitises on read (`sanitizeTheme`), which is where the palette's required shape is
1556
- * known. What the server DOES enforce is that it is an array of objects and that the write lands
1557
- * in the config FILE: a theme that exists only in memory is the #67 failure.
1558
- */
1559
1074
  app.get("/api/themes", (c) => c.json({
1560
1075
  themes: Array.isArray(master.config.customThemes) ? master.config.customThemes : [],
1561
1076
  prefs: master.config.themePrefs ?? {},
@@ -1576,7 +1091,6 @@ export function buildApp(master) {
1576
1091
  master.saveConfig();
1577
1092
  return c.json({ ok: true, themes: master.config.customThemes });
1578
1093
  });
1579
- // spawn a sub-agent from the UI (@mention flow / manual)
1580
1094
  app.post("/api/agents/:id/spawn", async (c) => {
1581
1095
  const a = master.agents.get(c.req.param("id"));
1582
1096
  if (!a)
@@ -1599,7 +1113,6 @@ export function buildApp(master) {
1599
1113
  return c.json({ error: err.message }, 400);
1600
1114
  }
1601
1115
  });
1602
- // bulk-stop a parent's sub-agents (descendants included by default)
1603
1116
  app.post("/api/agents/:id/stop-children", async (c) => {
1604
1117
  const a = master.agents.get(c.req.param("id"));
1605
1118
  if (!a)
@@ -1613,7 +1126,6 @@ export function buildApp(master) {
1613
1126
  return c.json({ error: err.message }, 400);
1614
1127
  }
1615
1128
  });
1616
- // operator-maintained task list (todo.md) with optional agent notification
1617
1129
  app.post("/api/agents/:id/todo", async (c) => {
1618
1130
  const a = master.agents.get(c.req.param("id"));
1619
1131
  if (!a)
@@ -1622,14 +1134,6 @@ export function buildApp(master) {
1622
1134
  if (!body)
1623
1135
  return c.json({ error: "invalid JSON" }, 400);
1624
1136
  await a.setTodo(body.text ?? "");
1625
- // a notification-bearing save must actually WAKE the idle agent (the
1626
- // queued prompt used to sit forever).
1627
- //
1628
- // #123 residual: but the START follows the NOTIFICATION, not the save. The
1629
- // original fix left `a.start(...)` outside the gate — a brace-less if only
1630
- // gates the enqueue — so `notify:false` (whose entire meaning is "save
1631
- // silently") booted a stopped agent, and so did an EMPTY list. A todo save
1632
- // is note-taking; booting an incarnation is a side effect.
1633
1137
  const shouldNotify = body.notify !== false && !!body.text?.trim();
1634
1138
  if (shouldNotify) {
1635
1139
  a.enqueuePrompt(`[harness] The operator updated the task list:\n\n${body.text}\n\nWork through it (get_todo() always has the latest).`, "harness");
@@ -1638,7 +1142,6 @@ export function buildApp(master) {
1638
1142
  }
1639
1143
  return c.json({ ok: true });
1640
1144
  });
1641
- // edit a previously-sent prompt: forks there, optionally summarizes the tail
1642
1145
  app.post("/api/agents/:id/edit-prompt", async (c) => {
1643
1146
  const a = master.agents.get(c.req.param("id"));
1644
1147
  if (!a)
@@ -1650,14 +1153,6 @@ export function buildApp(master) {
1650
1153
  return c.json({ error: "eventId and text required" }, 400);
1651
1154
  try {
1652
1155
  const r = await a.editPromptAt(body.eventId, body.text, body.tail === "summarize" ? "summarize" : "discard");
1653
- // the UI button is labelled "fork & RESEND", but nothing resent.
1654
- // `editPromptAt` rewrites history and returns, leaving the agent idle —
1655
- // so the edited prompt sat in the timeline looking sent while the model
1656
- // had never seen it, and the operator had to notice and press start.
1657
- //
1658
- // start() here rather than in the client: the route already knows the edit
1659
- // succeeded, and a client that failed to call it would leave the agent
1660
- // silently idle with no error anywhere.
1661
1156
  a.start("edited prompt resent");
1662
1157
  return c.json({ ok: true, ...r });
1663
1158
  }
@@ -1673,11 +1168,6 @@ export function buildApp(master) {
1673
1168
  const r = await a.fork(body.fromEvent ?? null);
1674
1169
  return c.json({ ok: true, ...r });
1675
1170
  });
1676
- // ---- event log access (human/inspect friendly) ----
1677
- // mtime-keyed parse cache: the web UI polls this endpoint every ~400ms and
1678
- // re-reading + re-parsing the WHOLE chat.jsonl each time dominated CPU on
1679
- // multi-MB sessions. The file only ever appends, so an unchanged mtime lets
1680
- // us serve the previous parse verbatim.
1681
1171
  const eventsCache = new Map();
1682
1172
  async function readEventsCached(filePath) {
1683
1173
  try {
@@ -1698,24 +1188,9 @@ export function buildApp(master) {
1698
1188
  app.get("/api/agents/:id/events", async (c) => {
1699
1189
  const a = master.agents.get(c.req.param("id"));
1700
1190
  if (!a) {
1701
- /**
1702
- * An agent that FAILED TO LOAD still owns its history.
1703
- *
1704
- * `addAgent` throws for an unusable entry (no model configured, unknown provider) and boot
1705
- * skips it, but its session directories remain on disk and `/api/sessions` lists them — so the
1706
- * sidebar offered a timeline this endpoint answered with 404 and the UI sat on "loading
1707
- * timeline…". MEASURED: `teapot-9` and `mediapad-t5-root-attempt` on this machine, each with a
1708
- * 13–23MB log that could not be opened at all.
1709
- *
1710
- * Reading is safe: the log is an append-only file, the events are the same shape, and the
1711
- * caller only ever renders them. The response says WHY the agent is not live so the UI can
1712
- * explain it instead of showing a dead panel.
1713
- */
1714
1191
  const why = master.unavailableAgents.get(c.req.param("id"));
1715
1192
  if (why) {
1716
1193
  const sessionQ = c.req.query("session");
1717
- // the caller always names a session (the UI routes by internal id); fall back to the
1718
- // newest dir this agent owns so a bare request still answers something
1719
1194
  const dir = sessionQ ?? master.sessionsOf(c.req.param("id"))[0]?.id;
1720
1195
  const file = dir ? path.join(master.sessionsRoot(), dir, "chat.jsonl") : null;
1721
1196
  if (file) {
@@ -1725,25 +1200,14 @@ export function buildApp(master) {
1725
1200
  events: tail.events,
1726
1201
  total: tail.total,
1727
1202
  partial: tail.truncated,
1728
- // the agent cannot run, but its history is readable — say so plainly
1729
1203
  unavailable: why,
1730
1204
  });
1731
1205
  }
1732
1206
  }
1733
1207
  return notFound(c, "agent", c.req.param("id"));
1734
1208
  }
1735
- // the frontend pairs a tool_call with its tool_result by walking the
1736
- // events it holds, so a window that can contain a result WITHOUT its call
1737
- // strands the bash row at "waiting for output…" forever — the call scrolled
1738
- // out while the command was still running. 200 was small enough to hit this
1739
- // on any moderately long command. The default is now sized to hold a whole
1740
- // pair even when thousands of events land in between (a long stream logs one
1741
- // per delta); the server clamps hard, so a caller asking for more is cheap.
1742
1209
  const limit = Math.min(Number(c.req.query("limit") ?? 2000), 20000);
1743
1210
  let filePath = a.log.filePath;
1744
- // ?session=<internalId> reads ANY session owned by this agent (past
1745
- // incarnations too) — the internal id, not the agent id, is the stable
1746
- // timeline handle. Ownership is enforced via the manifest.
1747
1211
  const sessionId = c.req.query("session");
1748
1212
  if (sessionId) {
1749
1213
  const rec = master.sessionById(sessionId);
@@ -1751,31 +1215,12 @@ export function buildApp(master) {
1751
1215
  return notFound(c, "session (or it belongs to another agent)", sessionId);
1752
1216
  filePath = path.join(rec.dir, "chat.jsonl");
1753
1217
  }
1754
- // the plain "latest N" request is what the UI polls every ~120 ms for
1755
- // a RUNNING agent, and it is the one that does NOT need history — it serves
1756
- // it from a tail read that scans ~2% of the file. `?before` (older pages)
1757
- // and `?branch` genuinely need the whole log, so they keep the full parse.
1758
- // `total` is a lower bound on the tail path (see readEventsTail) and is
1759
- // only used for the "load older" affordance, where approximate is fine.
1760
1218
  const branch = c.req.query("branch");
1761
1219
  const before = c.req.query("before");
1762
- // `?after=<id>` returns only what landed AFTER that event.
1763
- //
1764
- // This is the cursor the reconnect path was missing. Without it a dropped
1765
- // socket is recovered by re-fetching the whole tail — 2,000 events by default
1766
- // — and throwing away all but the few that are new. That is what makes the
1767
- // architecture REST-authoritative: the frontend can only learn "something
1768
- // changed" and must re-read to find out what.
1769
- //
1770
- // It is also the only cursor that can be cheap here: the tail path scans
1771
- // backwards from EOF, so an `after` lookup that lands near the end reads a
1772
- // small window instead of parsing the whole log.
1773
1220
  const after = c.req.query("after");
1774
1221
  if (after && !before && !branch) {
1775
1222
  const all = await readEventsCached(filePath);
1776
1223
  const idx = all.findIndex((e) => e.id === after);
1777
- // an unknown cursor means the client's view is not a prefix of ours (a
1778
- // compacted or rotated log), so a full tail is the only safe answer
1779
1224
  if (idx === -1) {
1780
1225
  const tail = await readEventsTail(filePath, limit);
1781
1226
  return c.json({ events: tail.events, total: tail.total, partial: tail.truncated, gap: true });
@@ -1790,7 +1235,6 @@ export function buildApp(master) {
1790
1235
  let events = await readEventsCached(filePath);
1791
1236
  if (branch)
1792
1237
  events = filterByBranch(events, branch, c.req.query("lineage") === "true");
1793
- // cursor pagination for older pages: everything strictly BEFORE this id
1794
1238
  if (before) {
1795
1239
  const idx = events.findIndex((e) => e.id === before);
1796
1240
  events = idx === -1 ? [] : events.slice(0, idx);
@@ -1798,16 +1242,9 @@ export function buildApp(master) {
1798
1242
  }
1799
1243
  return c.json({ events: events.slice(-limit), total: events.length });
1800
1244
  });
1801
- // ONE readdir-backed snapshot of every session on disk, owner included.
1802
- // The UI fetches this once per agent-set change instead of fanning out one
1803
- // request per agent — /api/agents/:id/sessions stays for compatibility.
1804
1245
  app.get("/api/sessions", (c) => c.json({ sessions: master.allSessions() }));
1805
- // list the internal session ids (= stable timeline handles) an agent owns,
1806
- // newest first — the web UI routes /session/<internalId> by these
1807
1246
  app.get("/api/agents/:id/sessions", (c) => {
1808
1247
  const id = c.req.param("id");
1809
- // resolvable when live, configured, OR still owning sessions on disk
1810
- // (a stopped/lazy agent must keep its timeline reachable)
1811
1248
  const known = master.agents.has(id) || master.config.agents.some((a) => a.id === id);
1812
1249
  const owned = master.sessionsOf(id);
1813
1250
  if (!known && owned.length === 0)
@@ -1818,24 +1255,19 @@ export function buildApp(master) {
1818
1255
  const a = master.agents.get(c.req.param("id"));
1819
1256
  if (!a)
1820
1257
  return notFound(c, "agent", c.req.param("id"));
1821
- // cached like /events — this used to bypass it and re-read the whole log
1822
1258
  const events = await readEventsCached(a.log.filePath);
1823
1259
  const branches = new Map();
1824
1260
  for (const e of events) {
1825
1261
  const b = branches.get(e.branch) ?? { branch: e.branch, events: 0 };
1826
1262
  b.events++;
1827
1263
  branches.set(e.branch, b);
1828
- // A fork event is recorded on the branch it was forked FROM (so the
1829
- // parent chain stays linked), but it describes the branch it CREATED.
1830
- // Attribute it to the new branch, or the fork shows up as belonging to
1831
- // the old one and the fork picker mislabels it.
1832
1264
  if (e.type === "fork") {
1833
1265
  const created = String(e.data?.newBranch ?? "");
1834
1266
  if (created && created !== e.branch) {
1835
1267
  const nb = branches.get(created) ?? { branch: created, events: 0 };
1836
1268
  nb.forkedFrom = e.data;
1837
1269
  branches.set(created, nb);
1838
- b.events--; // it is not an event OF the branch it was forked from
1270
+ b.events--;
1839
1271
  if (b.events <= 0)
1840
1272
  branches.delete(e.branch);
1841
1273
  }
@@ -1846,43 +1278,9 @@ export function buildApp(master) {
1846
1278
  }
1847
1279
  return c.json({ branches: [...branches.values()] });
1848
1280
  });
1849
- // ---- metrics / SSE ----
1850
1281
  app.get("/api/metrics", (c) => c.json(master.metrics()));
1851
- // current server version (for live-update polling)
1852
- /**
1853
- * A 404 that says WHICH thing was missing.
1854
- *
1855
- * there were 24 hand-copied `c.json({ error: "not found" }, 404)` lines.
1856
- * From a client they are indistinguishable — a typo in an agent id, a deleted
1857
- * session, or a bad route all produce the same three words, and none carries
1858
- * the id that was looked up. The id is the first thing anyone debugging this
1859
- * needs, and it is already in hand.
1860
- */
1861
1282
  const notFound = (c, what, id) => c.json({ error: `${what} not found: ${id || "(no id given)"}` }, 404);
1862
- /**
1863
- * `uptimeSeconds` exists so the UI can tell "the server restarted" apart from "it is still the
1864
- * old one" even when the VERSION did not change.
1865
- *
1866
- * The reload after a live update used to key off `version !== __APP_VERSION__` alone. That is
1867
- * baked into the bundle at build time, so it works for a published update — but a restart that
1868
- * ships the same version number (a local build, or a fix released without a bump) left the client
1869
- * polling for the full timeout while a perfectly healthy server was already answering. Process
1870
- * uptime is the honest signal: it can only be small if the image was replaced.
1871
- */
1872
1283
  app.get("/api/version", (c) => c.json({ version: master.getVersion(), uptimeSeconds: Math.round(process.uptime()) }));
1873
- /**
1874
- * Live update: answer FIRST, then replace the process.
1875
- *
1876
- * `restartServer()` ends in `process.execve`, which does not return — so awaiting it here meant
1877
- * the response never flushed and the browser saw `SocketError: other side closed` instead of
1878
- * `{ok:true}` (MEASURED: "restart endpoint must answer: SocketError"). The browser then could not
1879
- * tell a successful update from a crashed one, which is exactly the state the reload logic below
1880
- * has to distinguish.
1881
- *
1882
- * So the handler returns immediately and the handover runs on the next tick. That also gives the
1883
- * pending wind-down (which can take minutes for a long build) room to finish before the image is
1884
- * replaced.
1885
- */
1886
1284
  app.post("/api/update/restart", (c) => {
1887
1285
  setImmediate(() => {
1888
1286
  void master.restartServer().catch((err) => {
@@ -1891,7 +1289,6 @@ export function buildApp(master) {
1891
1289
  });
1892
1290
  return c.json({ ok: true, restarting: true });
1893
1291
  });
1894
- // scheduled tasks with computed next-fire times (cron visibility for the UI)
1895
1292
  app.get("/api/tasks", (c) => c.json({ tasks: master.tasksView() }));
1896
1293
  app.get("/api/events", (c) => {
1897
1294
  c.header("content-type", "text/event-stream");
@@ -1913,48 +1310,23 @@ export function buildApp(master) {
1913
1310
  controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`));
1914
1311
  }
1915
1312
  catch {
1916
- // client vanished mid-write — never let this reach event emitters
1917
1313
  cleanup();
1918
1314
  }
1919
1315
  };
1920
1316
  send({ kind: "hello", agents: [...master.agents.values()].map((a) => a.snapshot()) });
1921
1317
  const onUpdate = (ev) => send(ev);
1922
1318
  bus.on("update", onUpdate);
1923
- // keep-alive ping every 30s so proxies don't close the stream
1924
1319
  const ka = setInterval(() => send({ kind: "ping" }), 30_000);
1925
1320
  c.req.raw.signal.addEventListener("abort", cleanup);
1926
1321
  },
1927
1322
  cancel() {
1928
- /* cleanup also runs via the abort listener above */
1929
1323
  },
1930
1324
  });
1931
1325
  return c.body(stream);
1932
1326
  });
1933
- // RFC 2324 / HTCPCP compliance
1934
1327
  app.on(["GET", "POST", "BREW"], "/brew", (c) => c.text("418 I'm a teapot \u{1FAD6}", 418));
1935
1328
  app.on(["GET", "POST", "BREW"], "/brew/coffee", (c) => c.text("418 I'm a teapot — coffee not supported (see RFC 2324 §2.3.2)", 418));
1936
- // ---- web ui (built by vite into ./public; no bundler needed to serve) ----
1937
- // works both from dist/server/api.js and src/server/api.ts: ../../public
1938
1329
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../public");
1939
- /**
1940
- * CACHE POLICY FOR THE APP SHELL — the two halves must be OPPOSITE.
1941
- *
1942
- * header at all. With no header a browser falls back to HEURISTIC freshness (typically
1943
- * `10% × (now − Last-Modified)`), and the shell is the one file that must never be stale: it is
1944
- * what names the hashed bundle. A cached `index.html` keeps pointing at the PREVIOUS
1945
- * `index-<hash>.js`, so an update is installed, the server serves the new build, and the browser
1946
- * silently keeps running the old frontend — reported as "もしかしてブラウザのキャッシュが効いてて
1947
- * アップデートされたフロントエンドが完全に適用されてない?" after two rounds of UI fixes that did
1948
- * not appear.
1949
- *
1950
- * `no-cache` does NOT mean "do not cache": it means "revalidate before use", so the shell costs
1951
- * one conditional request (answered 304 from ETag/Last-Modified, which `c.body`/`c.html` supply)
1952
- * and can never be the stale one.
1953
- *
1954
- * The hashed assets are the opposite case: their NAME changes with their content, so a year of
1955
- * `immutable` is correct and free. This mirrors what the theme-image route already does above,
1956
- * for the same reason.
1957
- */
1958
1330
  const SHELL_CACHE = "no-cache";
1959
1331
  const ASSET_CACHE = "public, max-age=31536000, immutable";
1960
1332
  const mime = {
@@ -1972,25 +1344,12 @@ export function buildApp(master) {
1972
1344
  return null;
1973
1345
  }
1974
1346
  };
1975
- /**
1976
- * The shell's ETag, derived from its own bytes.
1977
- *
1978
- * `cache-control: no-cache` says "revalidate", and revalidation is only cheap if there is
1979
- * something to revalidate AGAINST. MEASURED without one: the response carried neither `ETag` nor
1980
- * `Last-Modified`, so every load re-downloaded the whole shell instead of getting a 304 — correct
1981
- * but wasteful, and the shell is served on every page load and every deep link.
1982
- *
1983
- * Hashing the content (rather than a mtime) means a rebuilt-but-identical shell keeps its ETag,
1984
- * so the 304 survives restarts, and any real change flips it. Computed once per process: the
1985
- * build cannot change while a server is running, and a restart is what picks up a new build.
1986
- */
1987
1347
  let shellEtag;
1988
1348
  const shellTag = (html) => {
1989
1349
  if (shellEtag === undefined || shellEtag === null)
1990
1350
  shellEtag = `"${createHash("sha256").update(html).digest("base64url").slice(0, 27)}"`;
1991
1351
  return shellEtag;
1992
1352
  };
1993
- /** answer a shell request, honouring `If-None-Match` */
1994
1353
  const serveShell = (c) => {
1995
1354
  const html = indexHtml();
1996
1355
  if (!html)
@@ -2007,13 +1366,12 @@ export function buildApp(master) {
2007
1366
  app.get("/", (c) => {
2008
1367
  return serveShell(c);
2009
1368
  });
2010
- // SPA deep links: /session/<agentId> serves the app; the client routes it
2011
1369
  app.get("/session/*", (c) => {
2012
1370
  return serveShell(c);
2013
1371
  });
2014
1372
  app.get("/assets/*", (c) => {
2015
1373
  const rel = c.req.path.replace("/assets/", "");
2016
- const file = path.resolve(webRoot, "assets", path.basename(rel)); // basename: no traversal
1374
+ const file = path.resolve(webRoot, "assets", path.basename(rel));
2017
1375
  try {
2018
1376
  return c.body(readFileSync(file), 200, {
2019
1377
  "content-type": mime[path.extname(file)] ?? "application/octet-stream",
@@ -2024,24 +1382,8 @@ export function buildApp(master) {
2024
1382
  return c.notFound();
2025
1383
  }
2026
1384
  });
2027
- /**
2028
- * Theme background images, stored SERVER-side.
2029
- *
2030
- * These used to live in `localStorage` as a data URI, which forced a hard 1.5MB cap: the quota
2031
- * is ~5MB for the WHOLE origin, so one wallpaper could brick every other setting the UI keeps
2032
- * there (themes, panel state, session history). A 1.5MB limit is also absurdly small for a
2033
- * photograph — the operator hits it with anything from a phone camera.
2034
- *
2035
- * Now the file is a FILE: uploaded once, referenced by name from the theme, and served back.
2036
- * No quota, so no cap; the only limit is what the upload route is willing to read.
2037
- *
2038
- * Written by hand rather than with `serveStatic` because the API is awkward and this needs
2039
- * three things it does not obviously give: a traversal-proof path, an immutable cache header,
2040
- * and a name→file mapping that cannot escape the assets dir.
2041
- */
2042
1385
  const assetsDir = path.join(master.config.dataDir, "assets");
2043
1386
  const assetName = (raw) => {
2044
- // only the shapes we mint: hex + a known extension. Anything else cannot name a file here.
2045
1387
  const m = /^([a-f0-9]{32})\.(png|jpg|jpeg|gif|webp|avif)$/i.exec(raw);
2046
1388
  return m ? `${m[1].toLowerCase()}.${m[2].toLowerCase()}` : null;
2047
1389
  };
@@ -2053,27 +1395,7 @@ export function buildApp(master) {
2053
1395
  webp: "image/webp",
2054
1396
  avif: "image/avif",
2055
1397
  };
2056
- /**
2057
- * Serve an asset, optionally downscaled to a requested width.
2058
- *
2059
- * `?w=1920` returns a 1920-wide (long-edge) version, generated once and CACHED ON DISK next to
2060
- * the original. The client uses this through `<img srcset>`, so the browser picks the width it
2061
- * needs from the DISPLAY area and the device pixel ratio — no guessing on our side, which is the
2062
- * problem a fixed size at upload time cannot solve: the same server is viewed on a laptop and a
2063
- * 4K monitor, and a phone photo is 6000px wide regardless.
2064
- *
2065
- * Untrusted input is bounded three ways: the width must be one of the ALLOWED_WIDTHS ladder (a
2066
- * client cannot ask for 10000 distinct sizes and fill the disk), the name must pass `assetName`,
2067
- * and the derived file is written under a name that function still accepts.
2068
- */
2069
1398
  const ALLOWED_WIDTHS = [640, 1280, 1920, 2560, 3840];
2070
- /**
2071
- * The width ladder, for the client's `srcset`.
2072
- *
2073
- * Registered BEFORE `/api/assets/:name`: Hono matches in registration order, so the parameterised
2074
- * route would otherwise swallow `meta` and answer 404 — measured, and the reason this is a
2075
- * separate literal route rather than something the `:name` handler inspects.
2076
- */
2077
1399
  app.get("/api/assets/meta", (c) => c.json({ widths: ALLOWED_WIDTHS }));
2078
1400
  app.get("/api/assets/:name", async (c) => {
2079
1401
  const name = assetName(c.req.param("name") ?? "");
@@ -2085,12 +1407,9 @@ export function buildApp(master) {
2085
1407
  const rawW = c.req.query("w");
2086
1408
  const wanted = rawW ? Number(rawW) : 0;
2087
1409
  if (wanted && !ALLOWED_WIDTHS.includes(wanted)) {
2088
- // a bounded ladder, so a client cannot mint unbounded files
2089
1410
  return c.json({ error: `unsupported width ${rawW}` }, 400);
2090
1411
  }
2091
1412
  try {
2092
- // the cache header is immutable+1y, so a resized response must be cached on disk or every
2093
- // viewer regenerates it
2094
1413
  if (wanted) {
2095
1414
  const cached = path.join(assetsDir, widthName(name, wanted));
2096
1415
  if (existsSync(cached)) {
@@ -2110,13 +1429,10 @@ export function buildApp(master) {
2110
1429
  });
2111
1430
  }
2112
1431
  }
2113
- // no tool, or the resize did not help: serve the original rather than fail. `srcset` still
2114
- // works — the browser just gets more pixels than it asked for.
2115
1432
  }
2116
1433
  const buf = await fs.readFile(src);
2117
1434
  return c.body(buf, 200, {
2118
1435
  "content-type": mime,
2119
- // the name is a UUID and never reused, so it can be cached forever
2120
1436
  "cache-control": "public, max-age=31536000, immutable",
2121
1437
  });
2122
1438
  }
@@ -2124,13 +1440,6 @@ export function buildApp(master) {
2124
1440
  return c.notFound();
2125
1441
  }
2126
1442
  });
2127
- /**
2128
- * The image optimiser, probed at most ONCE.
2129
- *
2130
- * Booting is not async here, and probing runs the binaries, so it is resolved lazily on the
2131
- * first upload and the promise is kept — every later upload reuses the answer instead of
2132
- * re-probing. A missing tool caches as null, which is a valid result.
2133
- */
2134
1443
  let imageToolsProbe = null;
2135
1444
  const imageToolName = () => {
2136
1445
  imageToolsProbe ??= detectImageTools()
@@ -2139,13 +1448,6 @@ export function buildApp(master) {
2139
1448
  return imageToolsProbe;
2140
1449
  };
2141
1450
  app.post("/api/assets", async (c) => {
2142
- /**
2143
- * Accepts the raw image body, not a multipart form.
2144
- *
2145
- * The client already holds a `File`; `fetch(url, {body: file})` sends its bytes directly, so
2146
- * there is no parser, no boundary handling and no temp file. The extension comes from the
2147
- * content type, which keeps the stored name within `assetName`'s accepted set.
2148
- */
2149
1451
  const type = (c.req.header("content-type") ?? "").split(";")[0].trim().toLowerCase();
2150
1452
  const ext = Object.entries(assetMime).find(([, m]) => m === type)?.[0];
2151
1453
  if (!ext) {
@@ -2154,7 +1456,6 @@ export function buildApp(master) {
2154
1456
  const buf = Buffer.from(await c.req.arrayBuffer());
2155
1457
  if (!buf.length)
2156
1458
  return c.json({ error: "empty body" }, 400);
2157
- // a sanity ceiling, not a UX limit: it exists so one request cannot fill the disk
2158
1459
  const MAX_UPLOAD = 64 * 1024 * 1024;
2159
1460
  if (buf.length > MAX_UPLOAD) {
2160
1461
  return c.json({ error: `image is larger than ${MAX_UPLOAD / 1024 / 1024}MB` }, 413);
@@ -2163,40 +1464,12 @@ export function buildApp(master) {
2163
1464
  const name = `${crypto.randomUUID().replace(/-/g, "")}.${ext}`;
2164
1465
  const dest = path.join(assetsDir, name);
2165
1466
  await fs.writeFile(dest, buf);
2166
- /**
2167
- * Downscale with a native binary when one exists.
2168
- *
2169
- * A phone photo is 6000x4000 and is displayed with `background-size: cover` at window size, so
2170
- * the browser decodes and keeps decoded ~24 megapixels for nothing — reported as "背景画像が
2171
- * 大きいとブラウザがリサイズでかなり重くなっちゃう". Doing it once here makes every later repaint
2172
- * cheap.
2173
- *
2174
- * A MISSING OPTIMISER IS NOT AN ERROR: `downscaleImage` returns `resized:false` and the
2175
- * original stays. The response says what happened so the UI can be honest rather than
2176
- * silently claiming to have optimised something.
2177
- */
2178
- /**
2179
- * The ORIGINAL is stored untouched; sizes are derived on demand by `?w=`.
2180
- *
2181
- * Resizing once at upload looked simpler but cannot be right: the same asset is viewed on a
2182
- * laptop and a 4K monitor, and `background-size: cover` means the needed width depends on the
2183
- * DISPLAY area. Baking one size in either wastes bytes on the small screen or upscales on the
2184
- * large one. Storing the original and serving `?w=` lets the browser choose from `srcset`,
2185
- * which is the only place the real display size is known.
2186
- */
2187
1467
  return c.json({
2188
1468
  name,
2189
1469
  url: `/api/assets/${name}`,
2190
1470
  bytes: buf.length,
2191
1471
  });
2192
1472
  });
2193
- /**
2194
- * Which optional native tools were found.
2195
- *
2196
- * "設定ウィンドウにオプション機能がどれくらい見つかったかとかは入れると良い方" — a feature that
2197
- * silently does nothing when a binary is missing is indistinguishable from a broken one, so the
2198
- * panel reports what is actually available rather than leaving the operator to guess.
2199
- */
2200
1473
  app.get("/api/features", async (c) => {
2201
1474
  const [image, script] = await Promise.all([
2202
1475
  detectImageTools().catch(() => ({ tool: null, found: [] })),
@@ -2206,11 +1479,9 @@ export function buildApp(master) {
2206
1479
  imageResize: {
2207
1480
  available: !!image.tool,
2208
1481
  tool: image.tool?.name ?? null,
2209
- /** every candidate, so "ffmpeg missing but imagemagick present" is visible */
2210
1482
  found: image.found,
2211
1483
  maxEdge: MAX_EDGE,
2212
1484
  },
2213
- // a pty is what makes terminal resizing work at all; on Windows this is expected to be off
2214
1485
  pty: { available: script.present, provider: script.present ? "script" : null },
2215
1486
  });
2216
1487
  });
@@ -2223,41 +1494,9 @@ export function buildApp(master) {
2223
1494
  });
2224
1495
  return app;
2225
1496
  }
2226
- /**
2227
- * Translate a wildcard bind address into the one `node:http` treats as dual-stack.
2228
- *
2229
- *
2230
- * listen({host: "0.0.0.0"}) -> bound 0.0.0.0 | family IPv4 ← IPv4 ONLY
2231
- * listen({host: "::"}) -> bound :: | family IPv6 ← DUAL STACK
2232
- * listen({}) -> bound :: ← DUAL STACK
2233
- *
2234
- * Over Tailscale that difference is decisive. The tailnet hands the host BOTH an
2235
- * IPv4 address (100.x) and an IPv6 one (fd7a:…), and a client that resolves the
2236
- * host to its IPv6 address CANNOT connect to an IPv4-only listener. Reported as
2237
- * "0.0.0.0 にしても tailscale 越しに接続できない", with the tell that an IPv4-only
2238
- * reverse proxy in front worked — exactly the asymmetry this produces:
2239
- *
2240
- * 0.0.0.0:7789 teapot LISTEN 0.0.0.0:7789 <- IPv4 only ✗ IPv6
2241
- * *:7788 proxy LISTEN *:7788 <- dual stack ✓
2242
- *
2243
- * So `0.0.0.0` — the spelling every operator reaches for, and what `--help`
2244
- * advertises — is translated to "no hostname" rather than passed through, because
2245
- * a missing hostname is what makes Node bind `::` with `IPV6_V6ONLY` off.
2246
- * `::` is passed through for the same reason.
2247
- */
2248
1497
  export function dualStackHost(host) {
2249
1498
  return host === "0.0.0.0" || host === "::" ? undefined : host;
2250
1499
  }
2251
- /**
2252
- * Serve the app, returning a handle that can release the listening socket.
2253
- *
2254
- * the live-update handover needs to give the port up WITHOUT exiting —
2255
- * the outgoing process stays attached to the operator's terminal and supervises
2256
- * its replacement, so it must not still be holding the port the replacement is
2257
- * waiting to bind. Returning the server is what makes "stop serving, keep
2258
- * running" expressible; previously the handle was dropped here and the only way
2259
- * to release the port was process.exit().
2260
- */
2261
1500
  export function serveApp(app, port, host) {
2262
1501
  const wss = new WebSocketServer({ noServer: true });
2263
1502
  const bindHost = dualStackHost(host);
@@ -2269,15 +1508,10 @@ export function serveApp(app, port, host) {
2269
1508
  });
2270
1509
  return {
2271
1510
  close: async () => {
2272
- // WebSocket clients hold the server open; without this the close would
2273
- // hang on a live terminal and the handover would stall.
2274
1511
  for (const c of wss.clients)
2275
1512
  c.terminate();
2276
1513
  await new Promise((resolve) => {
2277
1514
  server.close(() => resolve());
2278
- // A keep-alive connection can keep the socket open indefinitely; the
2279
- // handover must not wait on that, so force it and report the port free
2280
- // rather than blocking the replacement forever.
2281
1515
  setTimeout(resolve, 2_000);
2282
1516
  });
2283
1517
  },