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.
- package/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +61 -2247
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +9 -310
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1180
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +166 -142
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- package/public/assets/shiki-BwDjoZdu.js.map +0 -1
package/dist/server/api.js
CHANGED
|
@@ -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),
|
|
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();
|
|
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 {
|
|
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;
|
|
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);
|
|
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;
|
|
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);
|
|
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);
|
|
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--;
|
|
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));
|
|
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
|
},
|