@lelouchhe/webagent 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +58 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +96 -3
  4. package/dist/index.html +64 -41
  5. package/dist/js/app.GSAIYHML.js +4 -0
  6. package/dist/js/chunk.AJZBJBMO.js +1 -0
  7. package/dist/js/chunk.CGWFHJI2.js +76 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.VZXGXFNN.js +5 -0
  10. package/dist/js/login.PYIK52HN.js +1 -0
  11. package/dist/js/viewer.6DT53STL.js +1 -0
  12. package/dist/login.html +49 -0
  13. package/dist/share-viewer.00gubshk.css +114 -0
  14. package/dist/share-viewer.html +53 -0
  15. package/dist/styles.012p32dz.css +1443 -0
  16. package/dist/sw.js +79 -27
  17. package/dist/theme-init.js +6 -0
  18. package/lib/agent-detect.js +110 -0
  19. package/lib/atomic-write.js +50 -0
  20. package/lib/attachment-dispatch.js +86 -0
  21. package/lib/attachment-interceptor.js +130 -0
  22. package/lib/attachment-labels.js +139 -0
  23. package/lib/attachments.js +154 -0
  24. package/lib/auth-middleware.js +102 -0
  25. package/lib/auth-store.js +269 -0
  26. package/lib/auth.js +89 -0
  27. package/lib/bootstrap.js +70 -0
  28. package/lib/bridge.js +244 -93
  29. package/lib/client-registry.js +60 -0
  30. package/lib/config.js +123 -9
  31. package/lib/daemon.js +175 -41
  32. package/lib/event-handler.js +209 -91
  33. package/lib/log-fmt.js +67 -0
  34. package/lib/log.js +83 -0
  35. package/lib/message-cleanup.js +48 -0
  36. package/lib/mode-bucket.js +62 -0
  37. package/lib/preflight.js +195 -0
  38. package/lib/push-service.js +338 -45
  39. package/lib/routes.js +1202 -144
  40. package/lib/server.js +149 -33
  41. package/lib/session-manager.js +164 -18
  42. package/lib/session-state.js +160 -0
  43. package/lib/sessions-anchor.js +28 -0
  44. package/lib/share/cleanup.js +45 -0
  45. package/lib/share/routes.js +972 -0
  46. package/lib/share/sanitize.js +179 -0
  47. package/lib/sse-manager.js +94 -8
  48. package/lib/sse-ticket.js +45 -0
  49. package/lib/startup-checks.js +94 -0
  50. package/lib/store.js +624 -30
  51. package/lib/title-service.js +42 -9
  52. package/lib/tokens.js +50 -0
  53. package/lib/types.js +23 -0
  54. package/package.json +38 -4
  55. package/dist/js/app.2562YGRO.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. package/lib/shared/constants.js +0 -17
package/lib/routes.js CHANGED
@@ -1,10 +1,22 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
1
+ import { readFile, mkdir, rename, unlink, realpath } from "node:fs/promises";
2
2
  import { spawn } from "node:child_process";
3
- import { join, extname } from "node:path";
3
+ import { join, extname, basename } from "node:path";
4
4
  import { gzipSync } from "node:zlib";
5
- import { Store } from "./store.js";
6
- import { errorMessage } from "./types.js";
5
+ import busboy from "busboy";
6
+ import { errorMessage, MessageIngressSchema } from "./types.js";
7
7
  import { interruptBashProc } from "./session-manager.js";
8
+ import { randomUUID } from "node:crypto";
9
+ import { createWriteStream } from "node:fs";
10
+ import { handleShareRoutes } from "./share/routes.js";
11
+ import { authenticate, isWhitelistedPath } from "./auth-middleware.js";
12
+ import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
13
+ import { log } from "./log.js";
14
+ const rlog = log.scope("routes");
15
+ const plog = rlog.scope("prompt");
16
+ const slog = rlog.scope("session");
17
+ const mlog = rlog.scope("msg");
18
+ import { signAttachmentUrl, verifyAttachmentSig, reSignAttachmentUrlsInJson, } from "./auth.js";
19
+ import { buildContentDisposition, classifyKind, isInlineMime, mimeToExt, normalizeDisplayName, sniffMime, } from "./attachments.js";
8
20
  const IS_WIN = process.platform === "win32";
9
21
  const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
10
22
  const MIME = {
@@ -19,19 +31,91 @@ const MIME = {
19
31
  ".gif": "image/gif",
20
32
  ".webp": "image/webp",
21
33
  };
34
+ /**
35
+ * HTML entrypoints served by this app. Any new HTML page MUST be registered
36
+ * here. Tests in `test/html-entrypoints.test.ts`, `test/csp.test.ts` and
37
+ * `test/inline-assets.test.ts` rely on this list to enforce security
38
+ * invariants (CSP header, no inline scripts/styles).
39
+ */
40
+ export const HTML_ENTRYPOINTS = [
41
+ { urlPath: "/", file: "index.html" },
42
+ { urlPath: "/login", file: "login.html" },
43
+ // Share viewer — served by share's own dispatcher at /s/:token
44
+ // (see src/share/routes.ts → handleViewerHtml). The urlPath here is a
45
+ // pseudo-path used only by the test invariants (CSP/inline checks); the
46
+ // real route is /s/:token. Keep both views in sync if either changes.
47
+ { urlPath: "/s", file: "share-viewer.html" },
48
+ ];
49
+ /**
50
+ * Strict Content-Security-Policy for HTML responses.
51
+ *
52
+ * - default-src 'self': everything same-origin only
53
+ * - img-src adds data: + blob: for image-upload preview
54
+ * - script-src 'self' (no inline; theme bootstrap is /theme-init.js)
55
+ * - style-src 'self' (no inline; login styles live in /styles.css)
56
+ * - object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self'
57
+ * - connect-src 'self' for fetch + EventSource
58
+ */
59
+ export const CSP_POLICY = [
60
+ "default-src 'self'",
61
+ "img-src 'self' data: blob:",
62
+ "script-src 'self'",
63
+ "style-src 'self'",
64
+ "connect-src 'self'",
65
+ "object-src 'none'",
66
+ "frame-ancestors 'none'",
67
+ "base-uri 'self'",
68
+ "form-action 'self'",
69
+ ].join("; ");
22
70
  /** Read the full request body as a string. */
23
71
  function readBody(req) {
24
72
  return new Promise((resolve, reject) => {
25
73
  const chunks = [];
26
74
  req.on("data", (chunk) => chunks.push(chunk));
27
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
75
+ req.on("end", () => {
76
+ resolve(Buffer.concat(chunks).toString());
77
+ });
28
78
  req.on("error", reject);
29
79
  });
30
80
  }
81
+ /**
82
+ * client-server-split M2: idempotency helpers for mutating REST.
83
+ * Frontend generates a UUID per user-initiated action and sends it as
84
+ * `X-Client-Op-Id`. Replays (after SSE/network reconnect) return the
85
+ * cached response instead of re-executing side effects. Missing header →
86
+ * non-idempotent path (back-compat for curl / older clients).
87
+ */
88
+ function getClientOpId(req) {
89
+ const v = req.headers["x-client-op-id"];
90
+ if (typeof v === "string" && v.length > 0 && v.length <= 128)
91
+ return v;
92
+ return null;
93
+ }
94
+ function tryReplayClientOp(req, res, store, sessionId) {
95
+ const opId = getClientOpId(req);
96
+ if (!opId)
97
+ return { opId: null, replayed: false };
98
+ const cached = store.getClientOp(sessionId, opId);
99
+ if (cached &&
100
+ typeof cached === "object" &&
101
+ "status" in cached &&
102
+ "body" in cached) {
103
+ json(res, cached.status, cached.body, req);
104
+ return { opId, replayed: true };
105
+ }
106
+ return { opId, replayed: false };
107
+ }
108
+ function saveClientOpResult(store, opId, sessionId, status, body) {
109
+ if (!opId)
110
+ return;
111
+ store.saveClientOp(sessionId, opId, { status, body });
112
+ }
31
113
  /** Send a JSON response, gzip-compressed when the client supports it. */
32
114
  function json(res, status, data, req) {
33
115
  const body = JSON.stringify(data);
34
- if (req && body.length > 1024 && (req.headers["accept-encoding"] || "").includes("gzip")) {
116
+ if (req &&
117
+ body.length > 1024 &&
118
+ (req.headers["accept-encoding"] ?? "").includes("gzip")) {
35
119
  const compressed = gzipSync(body);
36
120
  res.writeHead(status, {
37
121
  "Content-Type": "application/json",
@@ -45,10 +129,274 @@ function json(res, status, data, req) {
45
129
  res.end(body);
46
130
  }
47
131
  }
132
+ /** Per-request principal storage. WeakMap keeps it tied to the request lifetime
133
+ * without monkey-patching IncomingMessage or relying on `any`. */
134
+ const principalByRequest = new WeakMap();
135
+ export function getPrincipal(req) {
136
+ return principalByRequest.get(req);
137
+ }
138
+ /**
139
+ * Multipart upload handler. Streams the `file` field straight to disk under
140
+ * <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp, atomic-renames on
141
+ * success, deletes on any failure path. Inserts an attachments row with the
142
+ * resolved realpath so the bridge / permission interceptor can match it
143
+ * later.
144
+ *
145
+ * Wire format expected:
146
+ * - Content-Type: multipart/form-data; boundary=...
147
+ * - One file field named `file` (additional file fields are rejected).
148
+ * - Optional text fields are ignored — displayName comes from the file
149
+ * part's filename header, classification comes from its content-type.
150
+ */
151
+ async function handleAttachmentUpload(req, res, sessionId, deps) {
152
+ const { store, dataDir, limits, sessions } = deps;
153
+ const fileUploadLimit = limits.file_upload ?? 52_428_800;
154
+ if (!store.getSession(sessionId)) {
155
+ json(res, 404, { error: "Session not found" });
156
+ return;
157
+ }
158
+ const dir = join(dataDir, "sessions", sessionId, "attachments");
159
+ await mkdir(dir, { recursive: true });
160
+ const uploadId = randomUUID();
161
+ let tmpPath = null;
162
+ let finalPath = null;
163
+ let bytesWritten = 0;
164
+ let fileMime = "";
165
+ let fileExt = "bin";
166
+ let displayName = null;
167
+ let kind = "file";
168
+ let limit = Math.max(limits.image_upload, fileUploadLimit);
169
+ let limitExceeded = false;
170
+ let sawFile = false;
171
+ let aborted = false;
172
+ let writeError = null;
173
+ let resolved = false;
174
+ // Resolves when the write stream backing the file part has flushed and
175
+ // closed. busboy's `close` can fire before fs has finished writing the
176
+ // last chunk to disk, so we must await this before renaming the .tmp.
177
+ let writeDone = Promise.resolve();
178
+ // Single-shot response. We only ever respond to the request once even
179
+ // though multiple busboy callbacks could converge on the same outcome
180
+ // (e.g. file-too-large + close-after-finish).
181
+ const respond = (status, body) => {
182
+ if (resolved)
183
+ return;
184
+ resolved = true;
185
+ json(res, status, body);
186
+ };
187
+ const cleanupTmp = async () => {
188
+ if (tmpPath) {
189
+ await unlink(tmpPath).catch(() => { });
190
+ tmpPath = null;
191
+ }
192
+ };
193
+ return new Promise((resolveOuter) => {
194
+ const finish = async (status, body) => {
195
+ respond(status, body);
196
+ resolveOuter();
197
+ };
198
+ let bb;
199
+ try {
200
+ bb = busboy({
201
+ headers: req.headers,
202
+ defParamCharset: "utf8",
203
+ limits: {
204
+ // We enforce the size cap manually via per-file byte tracking
205
+ // (so we can pick the right cap based on classified kind and
206
+ // emit a 413 the moment we cross the line). Cap files = 1
207
+ // and field count = 16 as belt-and-suspenders.
208
+ files: 1,
209
+ fields: 16,
210
+ fieldNameSize: 100,
211
+ fieldSize: 1024,
212
+ },
213
+ });
214
+ }
215
+ catch {
216
+ void finish(400, { error: "Invalid multipart" });
217
+ return;
218
+ }
219
+ bb.on("file", (fieldName, stream, info) => {
220
+ if (sawFile) {
221
+ // Extra file part — drain and ignore (busboy `files: 1` should
222
+ // already prevent this, but be defensive).
223
+ stream.resume();
224
+ return;
225
+ }
226
+ sawFile = true;
227
+ if (fieldName !== "file") {
228
+ stream.resume();
229
+ void finish(400, { error: "Unexpected field name" });
230
+ return;
231
+ }
232
+ fileMime = (info.mimeType || "application/octet-stream").toLowerCase();
233
+ kind = classifyKind(fileMime);
234
+ limit = kind === "image" ? limits.image_upload : fileUploadLimit;
235
+ fileExt = mimeToExt(fileMime);
236
+ displayName = info.filename ? normalizeDisplayName(info.filename) : null;
237
+ displayName ??= kind === "image" ? "image" : "file";
238
+ tmpPath = join(dir, `${uploadId}.${fileExt}.tmp`);
239
+ finalPath = join(dir, `${uploadId}.${fileExt}`);
240
+ const ws = createWriteStream(tmpPath);
241
+ writeDone = new Promise((resolveWs) => {
242
+ ws.on("close", () => {
243
+ resolveWs();
244
+ });
245
+ });
246
+ stream.on("data", (chunk) => {
247
+ bytesWritten += chunk.length;
248
+ if (bytesWritten > limit) {
249
+ limitExceeded = true;
250
+ stream.unpipe(ws);
251
+ ws.destroy();
252
+ stream.resume();
253
+ // Abort the whole busboy pipeline. We respond on `close`.
254
+ req.unpipe(bb);
255
+ req.resume();
256
+ }
257
+ });
258
+ stream.on("error", (err) => {
259
+ writeError = err;
260
+ });
261
+ ws.on("error", (err) => {
262
+ writeError = err;
263
+ });
264
+ stream.pipe(ws);
265
+ });
266
+ bb.on("error", (err) => {
267
+ writeError = err instanceof Error ? err : new Error(String(err));
268
+ });
269
+ req.on("aborted", () => {
270
+ aborted = true;
271
+ });
272
+ bb.on("close", () => {
273
+ void (async () => {
274
+ try {
275
+ await writeDone;
276
+ if (aborted) {
277
+ await cleanupTmp();
278
+ await finish(400, { error: "Upload aborted" });
279
+ return;
280
+ }
281
+ if (limitExceeded) {
282
+ await cleanupTmp();
283
+ await finish(413, { error: "Upload too large" });
284
+ return;
285
+ }
286
+ if (writeError) {
287
+ await cleanupTmp();
288
+ await finish(500, { error: "Upload failed" });
289
+ return;
290
+ }
291
+ if (!sawFile || !tmpPath || !finalPath || !displayName) {
292
+ await cleanupTmp();
293
+ await finish(400, { error: "Missing file part" });
294
+ return;
295
+ }
296
+ // Sniff the actual mime from file content (magic bytes + UTF-8
297
+ // text fallback). Clients lie about Content-Type — browsers send
298
+ // application/octet-stream for any extension the OS doesn't know
299
+ // (.clj, .lua, .rs, ...), and ACP agents (Copilot CLI) refuse to
300
+ // read attachments tagged octet-stream. The sniffed mime wins
301
+ // silently; we override fileMime / kind / extension / final path
302
+ // before insertAttachment so DB and disk reflect reality.
303
+ const head = await readFile(tmpPath).catch(() => Buffer.alloc(0));
304
+ const headSlice = head.subarray(0, 4096);
305
+ const sniffed = await sniffMime(headSlice);
306
+ if (sniffed !== fileMime) {
307
+ fileMime = sniffed;
308
+ kind = classifyKind(fileMime);
309
+ const newExt = mimeToExt(fileMime);
310
+ if (newExt !== fileExt) {
311
+ fileExt = newExt;
312
+ const newTmp = join(dir, `${uploadId}.${fileExt}.tmp`);
313
+ if (newTmp !== tmpPath) {
314
+ await rename(tmpPath, newTmp);
315
+ tmpPath = newTmp;
316
+ }
317
+ finalPath = join(dir, `${uploadId}.${fileExt}`);
318
+ }
319
+ }
320
+ await rename(tmpPath, finalPath);
321
+ const rp = await realpath(finalPath);
322
+ const row = store.insertAttachment({
323
+ id: uploadId,
324
+ sessionId,
325
+ kind,
326
+ name: displayName,
327
+ mime: fileMime,
328
+ size: bytesWritten,
329
+ realpath: rp,
330
+ });
331
+ // Invalidate the per-session attachment label cache so the
332
+ // next egress (SSE broadcast or replay) sees this new row.
333
+ sessions?.invalidateLabelCache(sessionId);
334
+ const fileName = `${row.id}.${fileExt}`;
335
+ const basePath = `/api/v1/sessions/${sessionId}/attachments/${fileName}`;
336
+ // 1h signed URL — long enough that the browser holds the rendered
337
+ // image in <img> cache for the full session lifetime, short enough
338
+ // that a leaked URL (screenshot, link share) expires within the day.
339
+ const fileUrl = deps.attachmentSecret
340
+ ? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
341
+ : basePath;
342
+ await finish(200, {
343
+ attachmentId: row.id,
344
+ displayName: row.name,
345
+ mimeType: row.mime,
346
+ size: row.size,
347
+ kind: row.kind,
348
+ path: `sessions/${sessionId}/attachments/${fileName}`,
349
+ url: fileUrl,
350
+ });
351
+ }
352
+ catch (err) {
353
+ await cleanupTmp();
354
+ await finish(500, { error: errorMessage(err) });
355
+ }
356
+ })();
357
+ });
358
+ req.pipe(bb);
359
+ });
360
+ }
48
361
  export function createRequestHandler(deps) {
49
362
  const { store, sessions, getBridge, sseManager, titleService } = deps;
363
+ // eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
50
364
  return async (req, res) => {
51
365
  const url = req.url ?? "/";
366
+ // --- Auth gate: any /api/** outside whitelist requires Bearer ---
367
+ if (deps.authStore && url.startsWith("/api/")) {
368
+ const path = url.split("?")[0] ?? url;
369
+ const method = req.method ?? "GET";
370
+ if (!isWhitelistedPath(method, path)) {
371
+ const result = authenticate(req.headers, deps.authStore);
372
+ if (!result.ok) {
373
+ res.writeHead(401, {
374
+ "Content-Type": "application/json",
375
+ "WWW-Authenticate": "Bearer",
376
+ });
377
+ res.end(JSON.stringify({ error: "Unauthorized", reason: result.reason }));
378
+ return;
379
+ }
380
+ principalByRequest.set(req, result.principal);
381
+ }
382
+ }
383
+ // Share routes — early dispatch so /s/* and /api/v1/shares* claim
384
+ // their URL space before the generic /api/v1 branch. When
385
+ // `shareConfig.enabled === false` handleShareRoutes is a no-op.
386
+ // The auth gate above has already enforced Bearer on owner endpoints
387
+ // (/api/v1/sessions/:id/share*, /api/v1/shares); viewer endpoints
388
+ // (/s/:token, /api/v1/shared/:token/events) must be whitelisted in
389
+ // auth-middleware.ts so they remain public.
390
+ if (deps.shareConfig &&
391
+ (await handleShareRoutes(req, res, {
392
+ store,
393
+ sessions,
394
+ config: deps.shareConfig,
395
+ dataDir: deps.dataDir,
396
+ publicDir: deps.publicDir,
397
+ }))) {
398
+ return;
399
+ }
52
400
  // --- API routes ---
53
401
  if (url === "/api/v1" || url.startsWith("/api/v1/")) {
54
402
  res.setHeader("Content-Type", "application/json");
@@ -69,7 +417,9 @@ export function createRequestHandler(deps) {
69
417
  return;
70
418
  }
71
419
  // GET /api/v1/sessions
72
- if (url.startsWith("/api/v1/sessions") && !url.slice("/api/v1/sessions".length).match(/^\//) && req.method === "GET") {
420
+ if (url.startsWith("/api/v1/sessions") &&
421
+ !url.slice("/api/v1/sessions".length).match(/^\//) &&
422
+ req.method === "GET") {
73
423
  const params = new URLSearchParams(url.split("?")[1] ?? "");
74
424
  const source = params.get("source") ?? undefined;
75
425
  res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
@@ -105,6 +455,136 @@ export function createRequestHandler(deps) {
105
455
  });
106
456
  return;
107
457
  }
458
+ // GET /api/v1/auth/verify — token validation probe
459
+ if (url === "/api/v1/auth/verify" && req.method === "GET") {
460
+ const principal = principalByRequest.get(req);
461
+ if (!principal) {
462
+ json(res, 401, { error: "Unauthorized" });
463
+ return;
464
+ }
465
+ json(res, 200, {
466
+ ok: true,
467
+ name: principal.name,
468
+ scope: principal.scope,
469
+ });
470
+ return;
471
+ }
472
+ // POST /api/v1/sse-ticket — mint short-lived ticket for EventSource
473
+ if (url === "/api/v1/sse-ticket" && req.method === "POST") {
474
+ const principal = principalByRequest.get(req);
475
+ if (!principal) {
476
+ json(res, 401, { error: "Unauthorized" });
477
+ return;
478
+ }
479
+ if (!deps.ticketStore) {
480
+ json(res, 501, { error: "SSE not available" });
481
+ return;
482
+ }
483
+ const ticket = deps.ticketStore.mint({
484
+ tokenName: principal.name,
485
+ scope: principal.scope,
486
+ });
487
+ json(res, 200, { ticket, expiresIn: 60 });
488
+ return;
489
+ }
490
+ // --- Token management (admin scope) ---
491
+ // GET /api/v1/tokens — list tokens.
492
+ // admin: full list. api: own token only (self-row, so the slash
493
+ // menu can show the user's own metadata without leaking peers).
494
+ if (url === "/api/v1/tokens" && req.method === "GET") {
495
+ const principal = principalByRequest.get(req);
496
+ if (!deps.authStore || !principal) {
497
+ json(res, 401, { error: "Unauthorized" });
498
+ return;
499
+ }
500
+ const all = deps.authStore.list();
501
+ const visible = principal.scope === "admin"
502
+ ? all
503
+ : all.filter((t) => t.name === principal.name);
504
+ const list = visible.map((t) => ({
505
+ name: t.name,
506
+ scope: t.scope,
507
+ createdAt: t.createdAt,
508
+ lastUsedAt: t.lastUsedAt,
509
+ isSelf: t.name === principal.name,
510
+ }));
511
+ json(res, 200, list);
512
+ return;
513
+ }
514
+ // POST /api/v1/tokens — create new api-scope token, return raw value once
515
+ if (url === "/api/v1/tokens" && req.method === "POST") {
516
+ const principal = principalByRequest.get(req);
517
+ if (!deps.authStore || !principal) {
518
+ json(res, 401, { error: "Unauthorized" });
519
+ return;
520
+ }
521
+ if (principal.scope !== "admin") {
522
+ json(res, 403, { error: "Forbidden" });
523
+ return;
524
+ }
525
+ let body;
526
+ try {
527
+ body = JSON.parse(await readBody(req));
528
+ }
529
+ catch {
530
+ json(res, 400, { error: "Invalid JSON" });
531
+ return;
532
+ }
533
+ const name = typeof body.name === "string" ? body.name : "";
534
+ try {
535
+ const created = await deps.authStore.addToken(name, "api");
536
+ json(res, 201, {
537
+ token: created.token,
538
+ name: created.record.name,
539
+ scope: created.record.scope,
540
+ });
541
+ }
542
+ catch (err) {
543
+ const msg = errorMessage(err);
544
+ if (/already exists|duplicate/i.test(msg)) {
545
+ json(res, 409, { error: msg });
546
+ }
547
+ else {
548
+ json(res, 400, { error: msg });
549
+ }
550
+ }
551
+ return;
552
+ }
553
+ // DELETE /api/v1/tokens/:name — revoke
554
+ const tokenDelMatch = url.match(/^\/api\/v1\/tokens\/([^/?]+)\/?$/);
555
+ if (tokenDelMatch && req.method === "DELETE") {
556
+ const principal = principalByRequest.get(req);
557
+ if (!deps.authStore || !principal) {
558
+ json(res, 401, { error: "Unauthorized" });
559
+ return;
560
+ }
561
+ if (principal.scope !== "admin") {
562
+ json(res, 403, { error: "Forbidden" });
563
+ return;
564
+ }
565
+ const name = decodeURIComponent(tokenDelMatch[1]);
566
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
567
+ json(res, 400, { error: "Invalid token name" });
568
+ return;
569
+ }
570
+ if (name === principal.name) {
571
+ json(res, 400, { error: "Cannot revoke the token you are using" });
572
+ return;
573
+ }
574
+ try {
575
+ const ok = await deps.authStore.revokeToken(name);
576
+ if (!ok) {
577
+ json(res, 404, { error: "Token not found" });
578
+ return;
579
+ }
580
+ res.writeHead(204);
581
+ res.end();
582
+ }
583
+ catch (err) {
584
+ json(res, 400, { error: errorMessage(err) });
585
+ }
586
+ return;
587
+ }
108
588
  // --- POST /api/v1/bridge/reload ---
109
589
  if (url === "/api/v1/bridge/reload" && req.method === "POST") {
110
590
  const bridge = getBridge?.();
@@ -139,6 +619,9 @@ export function createRequestHandler(deps) {
139
619
  if (permActionMatch && req.method === "POST") {
140
620
  const sessionId = decodeURIComponent(permActionMatch[1]);
141
621
  const requestId = decodeURIComponent(permActionMatch[2]);
622
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
623
+ if (replayed)
624
+ return;
142
625
  const perm = sessions?.pendingPermissions.get(requestId);
143
626
  if (!perm) {
144
627
  json(res, 404, { error: "Permission not found" });
@@ -165,25 +648,34 @@ export function createRequestHandler(deps) {
165
648
  json(res, 400, { error: "Provide optionId or denied:true" });
166
649
  return;
167
650
  }
168
- const denied = !!body.denied;
651
+ const denied = Boolean(body.denied);
169
652
  const optionId = body.optionId ?? "deny";
170
- const optionName = perm.options.find(o => o.optionId === optionId)?.label ?? optionId;
653
+ const optionName = perm.options.find((o) => o.optionId === optionId)?.label ?? optionId;
171
654
  if (denied) {
172
- await bridge.denyPermission(requestId);
655
+ bridge.denyPermission(requestId);
173
656
  }
174
657
  else {
175
- await bridge.resolvePermission(requestId, optionId);
658
+ bridge.resolvePermission(requestId, optionId);
176
659
  }
177
660
  sessions.pendingPermissions.delete(requestId);
661
+ sessions.syncPendingPermissions(sessionId);
178
662
  // Store event and broadcast (same type so SSE drops are recoverable via sync)
179
663
  const permEventData = { requestId, optionName, denied };
180
- store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId });
664
+ store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
181
665
  sseManager.broadcast({
182
666
  type: "permission_response",
183
667
  sessionId: perm.sessionId,
184
668
  ...permEventData,
185
669
  });
186
- json(res, 200, { ok: true });
670
+ // Cross-device banner recall: close the permission banner on
671
+ // every subscribed endpoint now that the permission has been
672
+ // handled by this client.
673
+ if (deps.pushService) {
674
+ void deps.pushService.sendClose(`sess-${perm.sessionId}-perm-${requestId}`);
675
+ }
676
+ const okBody = { ok: true };
677
+ saveClientOpResult(store, opId, sessionId, 200, okBody);
678
+ json(res, 200, okBody);
187
679
  return;
188
680
  }
189
681
  // --- POST /api/v1/sessions/:id/cancel ---
@@ -200,6 +692,9 @@ export function createRequestHandler(deps) {
200
692
  json(res, 503, { error: "Agent not ready yet" });
201
693
  return;
202
694
  }
695
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
696
+ if (replayed)
697
+ return;
203
698
  // Kill running bash process if any
204
699
  const proc = sessions?.runningBashProcs.get(sessionId);
205
700
  if (proc) {
@@ -211,7 +706,16 @@ export function createRequestHandler(deps) {
211
706
  await bridge.cancel(sessionId);
212
707
  sessions.activePrompts.delete(sessionId);
213
708
  }
214
- json(res, 200, { ok: true });
709
+ // Arm backend safety net: if prompt_done doesn't arrive within the
710
+ // configured timeout, force-clear busy so the UI unstalls. Replaces
711
+ // the old frontend-side cancel timer.
712
+ const cancelTimeout = deps.limits.cancel_timeout ?? 0;
713
+ if (sessions && cancelTimeout > 0)
714
+ sessions.state.armCancelSafety(sessionId, cancelTimeout);
715
+ sessions?.syncBusy(sessionId);
716
+ const okBody = { ok: true };
717
+ saveClientOpResult(store, opId, sessionId, 200, okBody);
718
+ json(res, 200, okBody);
215
719
  return;
216
720
  }
217
721
  // --- GET /api/v1/sessions/:id/status ---
@@ -232,6 +736,45 @@ export function createRequestHandler(deps) {
232
736
  });
233
737
  return;
234
738
  }
739
+ // --- GET /api/v1/sessions/:id/snapshot ---
740
+ // client-server-split M1: single source of truth for "what state is
741
+ // this session in right now". Frontend calls this on connect / reconnect
742
+ // / after long backgrounding, then applies incremental `state_patch`
743
+ // SSE events.
744
+ const snapshotMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/snapshot\/?$/);
745
+ if (snapshotMatch && req.method === "GET") {
746
+ const sessionId = decodeURIComponent(snapshotMatch[1]);
747
+ const session = store.getSession(sessionId);
748
+ if (!session) {
749
+ json(res, 404, { error: "Session not found" });
750
+ return;
751
+ }
752
+ if (!sessions) {
753
+ json(res, 503, { error: "Session manager not available" });
754
+ return;
755
+ }
756
+ // Make sure runtime reflects the current activePrompts/bash state even
757
+ // if no patch has been emitted yet for this session.
758
+ sessions.syncBusy(sessionId);
759
+ sessions.syncPendingPermissions(sessionId);
760
+ const runtimeState = sessions.state.getState(sessionId);
761
+ const lastEventSeq = store.getLastEventSeq(sessionId);
762
+ json(res, 200, {
763
+ version: 1,
764
+ seq: runtimeState.seq,
765
+ session: {
766
+ id: session.id,
767
+ title: session.title,
768
+ cwd: session.cwd,
769
+ model: session.model,
770
+ mode: session.mode,
771
+ createdAt: session.created_at,
772
+ lastEventSeq,
773
+ },
774
+ runtime: runtimeState.runtime,
775
+ }, req);
776
+ return;
777
+ }
235
778
  // --- POST /api/v1/sessions/:id/prompt ---
236
779
  const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
237
780
  if (promptMatch && req.method === "POST") {
@@ -250,12 +793,17 @@ export function createRequestHandler(deps) {
250
793
  json(res, 503, { error: "Session manager not available" });
251
794
  return;
252
795
  }
796
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
797
+ if (replayed)
798
+ return;
253
799
  // Ensure session is live in ACP before prompting (awaits in-flight resume)
254
800
  try {
255
801
  await sessions.ensureResumed(bridge, sessionId);
256
802
  }
257
803
  catch (err) {
258
- json(res, 500, { error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}` });
804
+ json(res, 500, {
805
+ error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
806
+ });
259
807
  return;
260
808
  }
261
809
  // Check if session is busy
@@ -276,28 +824,101 @@ export function createRequestHandler(deps) {
276
824
  json(res, 400, { error: "Missing required field: text" });
277
825
  return;
278
826
  }
279
- // Store user_message event (strip base64 data, keep only path + mimeType)
280
- const storedImages = body.images?.map(i => ({ path: i.path, mimeType: i.mimeType }));
281
- store.saveEvent(sessionId, "user_message", { text: body.text, ...(storedImages?.length && { images: storedImages }) });
827
+ // Validate attachment shape: client must NEVER supply uri/data/path,
828
+ // only the four canonical fields. Anything else 400 immediately so
829
+ // we don't even hand it to the dispatcher fallback.
830
+ const attachments = body.attachments;
831
+ if (attachments) {
832
+ if (!Array.isArray(attachments)) {
833
+ json(res, 400, { error: "attachments must be an array" });
834
+ return;
835
+ }
836
+ for (const raw of attachments) {
837
+ const att = raw;
838
+ if (!att ||
839
+ typeof att !== "object" ||
840
+ (att.kind !== "image" && att.kind !== "file") ||
841
+ typeof att.attachmentId !== "string" ||
842
+ typeof att.displayName !== "string" ||
843
+ typeof att.mimeType !== "string") {
844
+ json(res, 400, { error: "Invalid attachment entry" });
845
+ return;
846
+ }
847
+ if (typeof att.uri === "string" ||
848
+ typeof att.data === "string" ||
849
+ typeof att.path === "string") {
850
+ json(res, 400, {
851
+ error: "Client must not supply uri/data/path",
852
+ });
853
+ return;
854
+ }
855
+ }
856
+ }
857
+ // Stored shape mirrors the wire shape PLUS a server-derived `path`
858
+ // for renderers. The path is the unsigned base URL
859
+ // (`/api/v1/sessions/<sid>/attachments/<filename>`); reSign on
860
+ // egress (history GET + SSE broadcast) appends a fresh `?sig=&exp=`.
861
+ // Renderers use it to mount `<img>` (kind=image) or `<a>` (kind=file).
862
+ // Refs whose attachment row is missing are dropped (defense — the
863
+ // dispatcher's [attachment removed] fallback covers that case).
864
+ const storedAttachments = attachments?.flatMap((a) => {
865
+ const row = store.getAttachment(sessionId, a.attachmentId);
866
+ if (!row)
867
+ return [];
868
+ const fileName = basename(row.realpath);
869
+ return [
870
+ {
871
+ kind: a.kind,
872
+ attachmentId: a.attachmentId,
873
+ displayName: a.displayName,
874
+ mimeType: a.mimeType,
875
+ path: `/api/v1/sessions/${sessionId}/attachments/${fileName}`,
876
+ },
877
+ ];
878
+ });
879
+ store.saveEvent(sessionId, "user_message", {
880
+ text: body.text,
881
+ ...(storedAttachments?.length
882
+ ? { attachments: storedAttachments }
883
+ : {}),
884
+ }, { from_ref: "user" });
282
885
  store.updateSessionLastActive(sessionId);
283
886
  store.touchRecentPath(session.cwd);
284
- const userMsgEvent = { type: "user_message", sessionId, text: body.text, images: storedImages };
887
+ const userMsgEvent = {
888
+ type: "user_message",
889
+ sessionId,
890
+ text: body.text,
891
+ attachments: storedAttachments,
892
+ };
285
893
  sseManager.broadcast(userMsgEvent);
286
894
  // Generate title (fire-and-forget)
287
- if (titleService && sessions && !sessions.sessionHasTitle.has(sessionId)) {
895
+ if (titleService &&
896
+ sessions && // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- optional dep
897
+ !sessions.sessionHasTitle.has(sessionId)) {
288
898
  titleService.generate(bridge, body.text, sessionId, (title) => {
289
- const titleEvent = { type: "session_title_updated", sessionId, title };
899
+ const titleEvent = {
900
+ type: "session_title_updated",
901
+ sessionId,
902
+ title,
903
+ };
290
904
  sseManager.broadcast(titleEvent);
291
905
  });
292
906
  }
293
907
  // Fire prompt asynchronously (don't await — response is 202)
294
908
  sessions.activePrompts.add(sessionId);
295
- bridge.prompt(sessionId, body.text, body.images).catch((err) => {
296
- console.error(`[prompt] error for ${sessionId}:`, err);
297
- }).finally(() => {
909
+ sessions.syncBusy(sessionId);
910
+ bridge
911
+ .prompt(sessionId, body.text, attachments)
912
+ .catch((err) => {
913
+ plog.error("error", { sessionId, error: err });
914
+ })
915
+ .finally(() => {
298
916
  sessions.activePrompts.delete(sessionId);
917
+ sessions.syncBusy(sessionId);
299
918
  });
300
- json(res, 202, { status: "accepted" });
919
+ const acceptedBody = { status: "accepted" };
920
+ saveClientOpResult(store, opId, sessionId, 202, acceptedBody);
921
+ json(res, 202, acceptedBody);
301
922
  return;
302
923
  }
303
924
  // --- POST /api/v1/sessions/:id/bash ---
@@ -314,7 +935,9 @@ export function createRequestHandler(deps) {
314
935
  return;
315
936
  }
316
937
  if (sessions.runningBashProcs.has(sessionId)) {
317
- json(res, 409, { error: "A bash command is already running in this session" });
938
+ json(res, 409, {
939
+ error: "A bash command is already running in this session",
940
+ });
318
941
  return;
319
942
  }
320
943
  let body;
@@ -330,11 +953,19 @@ export function createRequestHandler(deps) {
330
953
  return;
331
954
  }
332
955
  const cwd = sessions.getSessionCwd(sessionId);
333
- store.saveEvent(sessionId, "bash_command", { command: body.command });
334
- const bashCmdEvent = { type: "bash_command", sessionId, command: body.command };
956
+ store.saveEvent(sessionId, "bash_command", { command: body.command }, { from_ref: "user" });
957
+ const bashCmdEvent = {
958
+ type: "bash_command",
959
+ sessionId,
960
+ command: body.command,
961
+ };
335
962
  sseManager.broadcast(bashCmdEvent);
336
- const shell = IS_WIN ? (process.env.COMSPEC || "cmd.exe") : (process.env.SHELL || "bash");
337
- const shellArgs = IS_WIN ? ["/s", "/c", body.command] : ["-c", body.command];
963
+ const shell = IS_WIN
964
+ ? (process.env.COMSPEC ?? "cmd.exe")
965
+ : (process.env.SHELL ?? "bash");
966
+ const shellArgs = IS_WIN
967
+ ? ["/s", "/c", body.command]
968
+ : ["-c", body.command];
338
969
  const child = spawn(shell, shellArgs, {
339
970
  cwd,
340
971
  detached: !IS_WIN,
@@ -342,6 +973,7 @@ export function createRequestHandler(deps) {
342
973
  stdio: ["ignore", "pipe", "pipe"],
343
974
  });
344
975
  sessions.runningBashProcs.set(sessionId, child);
976
+ sessions.syncBusy(sessionId);
345
977
  let output = "";
346
978
  let outputTruncated = false;
347
979
  const limit = deps.limits.bash_output;
@@ -357,23 +989,41 @@ export function createRequestHandler(deps) {
357
989
  else {
358
990
  output = (output + text).slice(-limit);
359
991
  }
360
- const bashOutEvent = { type: "bash_output", sessionId, text, stream };
992
+ const bashOutEvent = {
993
+ type: "bash_output",
994
+ sessionId,
995
+ text,
996
+ stream,
997
+ };
361
998
  sseManager.broadcast(bashOutEvent);
362
999
  };
363
1000
  child.stdout.on("data", onData("stdout"));
364
1001
  child.stderr.on("data", onData("stderr"));
365
1002
  child.on("close", (code, signal) => {
366
1003
  sessions.runningBashProcs.delete(sessionId);
1004
+ sessions.syncBusy(sessionId);
367
1005
  const stored = outputTruncated ? "[truncated]\n" + output : output;
368
- store.saveEvent(sessionId, "bash_result", { output: stored, code, signal });
369
- const bashDoneEvent = { type: "bash_done", sessionId, code, signal };
1006
+ store.saveEvent(sessionId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
1007
+ const bashDoneEvent = {
1008
+ type: "bash_done",
1009
+ sessionId,
1010
+ code,
1011
+ signal,
1012
+ };
370
1013
  sseManager.broadcast(bashDoneEvent);
371
1014
  });
372
1015
  child.on("error", (err) => {
373
1016
  sessions.runningBashProcs.delete(sessionId);
1017
+ sessions.syncBusy(sessionId);
374
1018
  const errMsg = errorMessage(err);
375
- store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
376
- const bashErrEvent = { type: "bash_done", sessionId, code: -1, signal: null, error: errMsg };
1019
+ store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null }, { from_ref: "system" });
1020
+ const bashErrEvent = {
1021
+ type: "bash_done",
1022
+ sessionId,
1023
+ code: -1,
1024
+ signal: null,
1025
+ error: errMsg,
1026
+ };
377
1027
  sseManager.broadcast(bashErrEvent);
378
1028
  });
379
1029
  json(res, 202, { status: "accepted" });
@@ -425,12 +1075,23 @@ export function createRequestHandler(deps) {
425
1075
  for (const opt of configOptions) {
426
1076
  store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
427
1077
  }
428
- sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
429
- sseManager.broadcast({ type: "config_set", sessionId, configId, value: body.value });
1078
+ sseManager.broadcast({
1079
+ type: "config_option_update",
1080
+ sessionId,
1081
+ configOptions,
1082
+ });
1083
+ sseManager.broadcast({
1084
+ type: "config_set",
1085
+ sessionId,
1086
+ configId,
1087
+ value: body.value,
1088
+ });
430
1089
  json(res, 200, { configOptions });
431
1090
  }
432
1091
  catch (err) {
433
- json(res, 500, { error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}` });
1092
+ json(res, 500, {
1093
+ error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}`,
1094
+ });
434
1095
  }
435
1096
  return;
436
1097
  }
@@ -460,8 +1121,12 @@ export function createRequestHandler(deps) {
460
1121
  sessions.sessionHasTitle.add(sessionId);
461
1122
  const bridge = getBridge?.();
462
1123
  if (titleService && bridge)
463
- titleService.cancel(sessionId, bridge);
464
- const titleEvent = { type: "session_title_updated", sessionId, title: body.value };
1124
+ void titleService.cancel(sessionId, bridge);
1125
+ const titleEvent = {
1126
+ type: "session_title_updated",
1127
+ sessionId,
1128
+ title: body.value,
1129
+ };
465
1130
  sseManager.broadcast(titleEvent);
466
1131
  json(res, 200, { title: body.value });
467
1132
  return;
@@ -479,53 +1144,112 @@ export function createRequestHandler(deps) {
479
1144
  json(res, 404, { error: "Session not found" });
480
1145
  return;
481
1146
  }
482
- // Start resume in background (non-blocking) so the client gets metadata fast
1147
+ // Start resume in background (non-blocking) so the client gets metadata fast.
1148
+ // BUT: if cache is cold (cold-restart case), block on resume up to 8s so
1149
+ // configOptions returns inline. This eliminates the race where the
1150
+ // post-resume `config_option_update` broadcast can arrive before the
1151
+ // client's SSE is fully wired up — a flake observed on slow CI runners.
483
1152
  const wasLive = sessions?.liveSessions.has(sessionId) ?? true;
1153
+ let resumePromise = null;
484
1154
  if (sessions && getBridge && !wasLive) {
485
1155
  const bridge = getBridge();
486
1156
  if (bridge) {
487
- const resumePromise = sessions.ensureResumed(bridge, sessionId);
1157
+ resumePromise = sessions.ensureResumed(bridge, sessionId);
1158
+ // Broadcast config_option_update too, so any OTHER client viewing
1159
+ // the same session (whose own GET may have raced and returned cold)
1160
+ // also picks up the warm values.
1161
+ resumePromise
1162
+ .then(() => {
1163
+ const cur = store.getSession(sessionId);
1164
+ if (!cur || !sessions.cachedConfigOptions.length)
1165
+ return;
1166
+ const opts = sessions.cachedConfigOptions.map((opt) => {
1167
+ const stored = {
1168
+ model: cur.model,
1169
+ mode: cur.mode,
1170
+ reasoning_effort: cur.reasoning_effort,
1171
+ };
1172
+ const override = stored[opt.id];
1173
+ return override ? { ...opt, currentValue: override } : opt;
1174
+ });
1175
+ sseManager.broadcast({
1176
+ type: "config_option_update",
1177
+ sessionId,
1178
+ configOptions: opts,
1179
+ });
1180
+ })
1181
+ .catch(() => { });
488
1182
  // Auto-retry if the last turn was interrupted (must wait for resume)
489
1183
  const hasInterrupted = store.hasInterruptedTurn(sessionId);
490
1184
  if (hasInterrupted) {
491
1185
  // Optimistically mark busy so concurrent POST sees the session as active
492
1186
  sessions.activePrompts.add(sessionId);
493
- resumePromise.then(() => {
1187
+ sessions.syncBusy(sessionId);
1188
+ void resumePromise
1189
+ .then(() => {
494
1190
  if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
495
1191
  // Retry not needed after all — release the optimistic lock
496
1192
  sessions.activePrompts.delete(sessionId);
1193
+ sessions.syncBusy(sessionId);
497
1194
  }
498
- }).catch(() => {
1195
+ })
1196
+ .catch(() => {
499
1197
  sessions.activePrompts.delete(sessionId);
1198
+ sessions.syncBusy(sessionId);
500
1199
  });
501
1200
  }
502
1201
  else {
503
1202
  resumePromise.catch((err) => {
504
- console.error(`[session] background resume failed for ${sessionId.slice(0, 8)}…:`, err);
1203
+ slog.error("background resume failed", {
1204
+ sessionId: sessionId.slice(0, 8) + "…",
1205
+ error: err,
1206
+ });
505
1207
  });
506
1208
  }
507
1209
  }
508
1210
  }
509
- const configOptions = sessions ? (() => {
510
- // Build configOptions from cached + stored overrides
511
- const opts = sessions.cachedConfigOptions.map(opt => {
512
- const stored = { model: session.model, mode: session.mode, reasoning_effort: session.reasoning_effort };
513
- const override = stored[opt.id];
514
- return override ? { ...opt, currentValue: override } : opt;
1211
+ // If cache is cold and we kicked off a resume, wait briefly so the
1212
+ // GET response includes warm configOptions. Bounded to 8s past that
1213
+ // we fall back to returning empty configOptions and rely on the
1214
+ // broadcast above. The frontend retries (re-fetches) on broadcast.
1215
+ if (resumePromise && !sessions?.cachedConfigOptions.length) {
1216
+ let timer = null;
1217
+ const timeoutPromise = new Promise((resolve) => {
1218
+ timer = setTimeout(resolve, 8000);
515
1219
  });
516
- return opts;
517
- })() : [];
518
- const busyKind = sessions?.getBusyKind(sessionId) ?? null;
1220
+ await Promise.race([
1221
+ resumePromise.finally(() => {
1222
+ if (timer)
1223
+ clearTimeout(timer);
1224
+ }),
1225
+ timeoutPromise,
1226
+ ]).catch(() => { });
1227
+ }
1228
+ // Re-read session in case resume mutated stored config
1229
+ const freshSession = store.getSession(sessionId) ?? session;
1230
+ const configOptions = sessions
1231
+ ? (() => {
1232
+ // Build configOptions from cached + stored overrides
1233
+ const opts = sessions.cachedConfigOptions.map((opt) => {
1234
+ const stored = {
1235
+ model: freshSession.model,
1236
+ mode: freshSession.mode,
1237
+ reasoning_effort: freshSession.reasoning_effort,
1238
+ };
1239
+ const override = stored[opt.id];
1240
+ return override ? { ...opt, currentValue: override } : opt;
1241
+ });
1242
+ return opts;
1243
+ })()
1244
+ : [];
519
1245
  json(res, 200, {
520
- id: session.id,
521
- cwd: session.cwd,
522
- title: session.title,
523
- source: session.source,
524
- model: session.model,
525
- mode: session.mode,
1246
+ id: freshSession.id,
1247
+ cwd: freshSession.cwd,
1248
+ title: freshSession.title,
1249
+ source: freshSession.source,
1250
+ model: freshSession.model,
1251
+ mode: freshSession.mode,
526
1252
  configOptions,
527
- busy: busyKind != null,
528
- busyKind,
529
1253
  }, req);
530
1254
  return;
531
1255
  }
@@ -582,7 +1306,11 @@ export function createRequestHandler(deps) {
582
1306
  // ACP's session_created event fires before inheritance runs, so
583
1307
  // broadcast final configOptions so SSE clients get the inherited values.
584
1308
  if (configOptions.length) {
585
- sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
1309
+ sseManager.broadcast({
1310
+ type: "config_option_update",
1311
+ sessionId,
1312
+ configOptions,
1313
+ });
586
1314
  }
587
1315
  json(res, 201, {
588
1316
  id: sessionId,
@@ -607,14 +1335,17 @@ export function createRequestHandler(deps) {
607
1335
  const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
608
1336
  if (eventsMatch && req.method === "GET") {
609
1337
  const sessionId = decodeURIComponent(eventsMatch[1]);
610
- const params = new URLSearchParams(eventsMatch[2]?.slice(1) ?? "");
1338
+ const queryPart = eventsMatch[2];
1339
+ const params = new URLSearchParams(queryPart ? queryPart.slice(1) : "");
611
1340
  const excludeThinking = params.get("thinking") === "0";
612
1341
  const afterRaw = params.get("after");
613
1342
  const afterSeq = afterRaw != null ? Number(afterRaw) : undefined;
614
1343
  const beforeRaw = params.get("before");
615
1344
  const beforeSeq = beforeRaw != null ? Number(beforeRaw) : undefined;
616
1345
  const limitRaw = params.get("limit");
617
- const limit = limitRaw != null ? Math.max(1, Math.min(10000, Number(limitRaw))) : undefined;
1346
+ const limit = limitRaw != null
1347
+ ? Math.max(1, Math.min(10000, Number(limitRaw)))
1348
+ : undefined;
618
1349
  const session = store.getSession(sessionId);
619
1350
  if (!session) {
620
1351
  json(res, 404, { error: "Session not found" });
@@ -635,15 +1366,45 @@ export function createRequestHandler(deps) {
635
1366
  sessions.flushAssistantBuffer(sessionId);
636
1367
  }
637
1368
  }
638
- const events = store.getEvents(sessionId, { excludeThinking, afterSeq, beforeSeq, limit });
1369
+ const events = store.getEvents(sessionId, {
1370
+ excludeThinking,
1371
+ afterSeq,
1372
+ beforeSeq,
1373
+ limit,
1374
+ });
1375
+ // Replace internal uuid attachment paths with `<name> [#<id4>]`
1376
+ // labels at egress (CLAUDE.md "Attachment label egress
1377
+ // rewrite"). DB rows still hold raw paths.
1378
+ if (sessions) {
1379
+ enrichStoredEventsForDisplay(events, sessions.getLabelMap(sessionId));
1380
+ }
1381
+ // Re-sign image URLs at egress so 1h-old stored URLs become valid
1382
+ // again — the user can reload history days later and images still
1383
+ // resolve. Mutates `data` in-place; safe because store.getEvents
1384
+ // returns fresh records.
1385
+ if (deps.attachmentSecret) {
1386
+ for (const ev of events) {
1387
+ if (typeof ev.data === "string" &&
1388
+ ev.data.includes("/attachments/")) {
1389
+ ev.data = reSignAttachmentUrlsInJson(ev.data, deps.attachmentSecret);
1390
+ }
1391
+ }
1392
+ }
639
1393
  const envelope = {
640
1394
  events,
641
- streaming: { thinking: streamingThinking, assistant: streamingAssistant },
1395
+ streaming: {
1396
+ thinking: streamingThinking,
1397
+ assistant: streamingAssistant,
1398
+ },
642
1399
  };
643
1400
  if (limit != null) {
644
1401
  const total = store.getEventCount(sessionId, { excludeThinking });
645
1402
  const hasMore = events.length > 0
646
- ? (store.getEvents(sessionId, { excludeThinking, beforeSeq: events[0].seq, limit: 1 }).length > 0)
1403
+ ? store.getEvents(sessionId, {
1404
+ excludeThinking,
1405
+ beforeSeq: events[0].seq,
1406
+ limit: 1,
1407
+ }).length > 0
647
1408
  : false;
648
1409
  envelope.total = total;
649
1410
  envelope.hasMore = hasMore;
@@ -654,32 +1415,54 @@ export function createRequestHandler(deps) {
654
1415
  // --- SSE stream endpoints ---
655
1416
  // GET /api/v1/events/stream — global SSE stream
656
1417
  if (url.startsWith("/api/v1/events/stream") && req.method === "GET") {
657
- if (!deps.sseManager) {
658
- json(res, 501, { error: "SSE not available" });
659
- return;
1418
+ // Ticket-based auth (EventSource cannot send Authorization header).
1419
+ // When authStore is configured, every SSE connection MUST present a
1420
+ // valid single-use ticket from POST /api/v1/sse-ticket.
1421
+ let tokenName;
1422
+ if (deps.authStore) {
1423
+ const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
1424
+ const principal = deps.ticketStore?.consume(ticket);
1425
+ if (!principal) {
1426
+ json(res, 401, { error: "Invalid or expired ticket" });
1427
+ return;
1428
+ }
1429
+ tokenName = principal.tokenName;
660
1430
  }
661
- const sseManager = deps.sseManager;
662
1431
  const clientId = sseManager.generateClientId();
663
1432
  res.writeHead(200, {
664
1433
  "Content-Type": "text/event-stream",
665
1434
  "Cache-Control": "no-cache",
666
- "Connection": "keep-alive",
1435
+ Connection: "keep-alive",
667
1436
  });
668
- const client = { id: clientId, res };
1437
+ const client = {
1438
+ id: clientId,
1439
+ res,
1440
+ tokenName,
1441
+ };
669
1442
  sseManager.add(client);
670
1443
  // Send connected event
671
- sseManager.sendEvent(client, { type: "connected", clientId });
1444
+ sseManager.sendEvent(client, {
1445
+ type: "connected",
1446
+ clientId,
1447
+ debugLevel: deps.debugLevel ?? "off",
1448
+ });
1449
+ sseManager.writeHeartbeat(client);
672
1450
  return;
673
1451
  }
674
1452
  // GET /api/v1/sessions/:id/events/stream — per-session SSE stream
675
1453
  const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
676
1454
  if (sseSessionMatch && req.method === "GET") {
677
- if (!deps.sseManager) {
678
- json(res, 501, { error: "SSE not available" });
679
- return;
680
- }
681
- const sseManager = deps.sseManager;
682
1455
  const sessionId = decodeURIComponent(sseSessionMatch[1]);
1456
+ let tokenName;
1457
+ if (deps.authStore) {
1458
+ const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
1459
+ const principal = deps.ticketStore?.consume(ticket);
1460
+ if (!principal) {
1461
+ json(res, 401, { error: "Invalid or expired ticket" });
1462
+ return;
1463
+ }
1464
+ tokenName = principal.tokenName;
1465
+ }
683
1466
  const session = store.getSession(sessionId);
684
1467
  if (!session) {
685
1468
  json(res, 404, { error: "Session not found" });
@@ -689,12 +1472,22 @@ export function createRequestHandler(deps) {
689
1472
  res.writeHead(200, {
690
1473
  "Content-Type": "text/event-stream",
691
1474
  "Cache-Control": "no-cache",
692
- "Connection": "keep-alive",
1475
+ Connection: "keep-alive",
693
1476
  });
694
- const client = { id: clientId, res, sessionId };
1477
+ const client = {
1478
+ id: clientId,
1479
+ res,
1480
+ sessionId,
1481
+ tokenName,
1482
+ };
695
1483
  sseManager.add(client);
696
1484
  // Send connected event
697
- sseManager.sendEvent(client, { type: "connected", clientId });
1485
+ sseManager.sendEvent(client, {
1486
+ type: "connected",
1487
+ clientId,
1488
+ debugLevel: deps.debugLevel ?? "off",
1489
+ });
1490
+ sseManager.writeHeartbeat(client);
698
1491
  // Replay events from Last-Event-ID if provided
699
1492
  const lastEventId = req.headers["last-event-id"];
700
1493
  if (lastEventId) {
@@ -703,7 +1496,10 @@ export function createRequestHandler(deps) {
703
1496
  const events = store.getEvents(sessionId, { afterSeq });
704
1497
  for (const evt of events) {
705
1498
  try {
706
- sseManager.sendEvent(client, { type: evt.type, ...JSON.parse(evt.data) }, evt.seq);
1499
+ sseManager.sendEvent(client, {
1500
+ type: evt.type,
1501
+ ...JSON.parse(evt.data),
1502
+ }, evt.seq);
707
1503
  }
708
1504
  catch {
709
1505
  // Skip malformed event data
@@ -713,69 +1509,75 @@ export function createRequestHandler(deps) {
713
1509
  }
714
1510
  return;
715
1511
  }
716
- // --- Images (session-scoped) ---
717
- // POST /api/v1/sessions/:id/images
718
- const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/images\/?$/);
1512
+ // --- Attachments (session-scoped) ---
1513
+ // POST /api/v1/sessions/:id/attachments — multipart/form-data upload.
1514
+ //
1515
+ // Wire format: a single `file` field. busboy streams chunks straight
1516
+ // to <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp; on close
1517
+ // we atomic-rename to the final name and insert an attachments row.
1518
+ // Aborts / mid-stream errors / oversize / wrong field name all leave
1519
+ // the .tmp removed before responding.
1520
+ const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/?$/);
719
1521
  if (imgUploadMatch && req.method === "POST") {
720
1522
  const sessionId = decodeURIComponent(imgUploadMatch[1]);
721
1523
  if (!SAFE_ID.test(sessionId)) {
722
1524
  json(res, 400, { error: "Invalid session ID" });
723
1525
  return;
724
1526
  }
725
- // Enforce upload size limit
726
- const contentLength = parseInt(req.headers["content-length"] ?? "0", 10);
727
- if (contentLength > deps.limits.image_upload) {
728
- json(res, 413, { error: "Upload too large" });
1527
+ const ctype = req.headers["content-type"] ?? "";
1528
+ if (!ctype.toLowerCase().startsWith("multipart/form-data")) {
1529
+ json(res, 400, { error: "Expected multipart/form-data" });
729
1530
  return;
730
1531
  }
731
- const chunks = [];
732
- let totalSize = 0;
733
- for await (const chunk of req) {
734
- totalSize += chunk.length;
735
- if (totalSize > deps.limits.image_upload) {
736
- json(res, 413, { error: "Upload too large" });
737
- return;
738
- }
739
- chunks.push(chunk);
740
- }
741
- let body;
742
- try {
743
- body = JSON.parse(Buffer.concat(chunks).toString());
744
- }
745
- catch {
746
- json(res, 400, { error: "Invalid JSON" });
747
- return;
748
- }
749
- const { data, mimeType } = body;
750
- const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
751
- const seq = Date.now();
752
- const fileName = `${seq}.${ext}`;
753
- const relPath = `images/${sessionId}/${fileName}`;
754
- const absPath = join(deps.dataDir, relPath);
755
- await mkdir(join(deps.dataDir, "images", sessionId), { recursive: true });
756
- await writeFile(absPath, Buffer.from(data, "base64"));
757
- const imgUrl = `/api/v1/sessions/${sessionId}/images/${fileName}`;
758
- json(res, 200, { path: relPath, url: imgUrl });
1532
+ await handleAttachmentUpload(req, res, sessionId, deps);
759
1533
  return;
760
1534
  }
761
- // GET /api/v1/sessions/:id/images/:file
762
- const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/images\/([^/?]+)\/?$/);
1535
+ // GET /api/v1/sessions/:id/attachments/:file — serve a previously
1536
+ // uploaded attachment. Mime + displayName are looked up from the
1537
+ // attachments table so the response carries the original filename
1538
+ // (RFC 5987) and the per-mime inline/attachment disposition.
1539
+ const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
763
1540
  if (imgGetMatch && req.method === "GET") {
764
1541
  const sessionId = decodeURIComponent(imgGetMatch[1]);
765
1542
  const file = decodeURIComponent(imgGetMatch[2]);
766
- const filePath = join(deps.dataDir, "images", sessionId, file);
767
- if (!filePath.startsWith(join(deps.dataDir, "images"))) {
1543
+ // When secret is configured, GET requires sig+exp in query — there
1544
+ // is no Bearer fallback because <img src=...> / <a href=...> can't
1545
+ // carry headers. Verify before any disk I/O.
1546
+ if (deps.attachmentSecret) {
1547
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
1548
+ const sig = params.get("sig") ?? "";
1549
+ const exp = params.get("exp") ?? "";
1550
+ const basePath = `/api/v1/sessions/${sessionId}/attachments/${file}`;
1551
+ if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
1552
+ res.writeHead(401, { "Content-Type": "application/json" });
1553
+ res.end(JSON.stringify({ error: "Unauthorized" }));
1554
+ return;
1555
+ }
1556
+ }
1557
+ const filePath = join(deps.dataDir, "sessions", sessionId, "attachments", file);
1558
+ if (!filePath.startsWith(join(deps.dataDir, "sessions"))) {
768
1559
  res.writeHead(403);
769
1560
  res.end("Forbidden");
770
1561
  return;
771
1562
  }
1563
+ // Look up the row to recover the original mime + display name.
1564
+ // Pre-attachments-table uploads (none in v0.4+) would miss here;
1565
+ // we degrade gracefully to extension-based mime.
1566
+ const row = store.getAttachmentByFile(sessionId, file);
772
1567
  try {
773
1568
  const fileData = await readFile(filePath);
774
- const ext = extname(filePath);
775
- res.writeHead(200, {
776
- "Content-Type": MIME[ext] ?? "application/octet-stream",
1569
+ const mime = row?.mime ??
1570
+ (MIME[extname(filePath)] || "application/octet-stream");
1571
+ const headers = {
1572
+ "Content-Type": mime,
777
1573
  "Cache-Control": "public, max-age=31536000, immutable",
778
- });
1574
+ "X-Content-Type-Options": "nosniff",
1575
+ };
1576
+ if (row) {
1577
+ const disposition = isInlineMime(mime) ? "inline" : "attachment";
1578
+ headers["Content-Disposition"] = buildContentDisposition(disposition, row.name);
1579
+ }
1580
+ res.writeHead(200, headers);
779
1581
  res.end(fileData);
780
1582
  }
781
1583
  catch {
@@ -784,6 +1586,203 @@ export function createRequestHandler(deps) {
784
1586
  }
785
1587
  return;
786
1588
  }
1589
+ // --- Inbox messages (Stage B primitive) ---
1590
+ // POST /api/v1/messages — create ingress message
1591
+ if (url === "/api/v1/messages" && req.method === "POST") {
1592
+ // client-server-split M2: idempotency for the ingress message
1593
+ // creator. /messages has no real session id, so we scope the
1594
+ // cache under the synthetic key "__ingress__".
1595
+ const { opId, replayed } = tryReplayClientOp(req, res, store, "__ingress__");
1596
+ if (replayed)
1597
+ return;
1598
+ let raw;
1599
+ try {
1600
+ raw = await readBody(req);
1601
+ }
1602
+ catch {
1603
+ json(res, 400, { error: "Failed to read body" });
1604
+ return;
1605
+ }
1606
+ let parsed;
1607
+ try {
1608
+ parsed = JSON.parse(raw);
1609
+ }
1610
+ catch {
1611
+ json(res, 400, { error: "Invalid JSON" });
1612
+ return;
1613
+ }
1614
+ const validation = MessageIngressSchema.safeParse(parsed);
1615
+ if (!validation.success) {
1616
+ json(res, 400, {
1617
+ error: "Invalid body",
1618
+ issues: validation.error.issues,
1619
+ });
1620
+ return;
1621
+ }
1622
+ const input = validation.data;
1623
+ const id = `msg-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
1624
+ if (input.to.startsWith("session:")) {
1625
+ const targetSid = input.to.slice("session:".length);
1626
+ const session = store.getSession(targetSid);
1627
+ if (!session) {
1628
+ json(res, 400, { error: "session_not_found" });
1629
+ return;
1630
+ }
1631
+ sessions?.flushBuffers(targetSid);
1632
+ const data = {
1633
+ message_id: id,
1634
+ from_ref: input.from_ref,
1635
+ from_label: input.from_label ?? null,
1636
+ title: input.title,
1637
+ body: input.body,
1638
+ cwd: input.cwd ?? null,
1639
+ };
1640
+ store.saveEvent(targetSid, "message", data, {
1641
+ from_ref: input.from_ref,
1642
+ });
1643
+ sseManager.broadcast({
1644
+ type: "message",
1645
+ sessionId: targetSid,
1646
+ ...data,
1647
+ });
1648
+ if (deps.pushService) {
1649
+ void deps.pushService.sendForMessage({
1650
+ id,
1651
+ to: input.to,
1652
+ body: input.body,
1653
+ from_label: input.from_label,
1654
+ from_ref: input.from_ref,
1655
+ deliver: input.deliver,
1656
+ dedup_key: input.dedup_key ?? null,
1657
+ });
1658
+ }
1659
+ mlog.info("ingress bound", {
1660
+ msg_id: id,
1661
+ sess_id: targetSid.slice(0, 8),
1662
+ });
1663
+ const boundBody = { id, delivered: "session" };
1664
+ saveClientOpResult(store, opId, "__ingress__", 200, boundBody);
1665
+ json(res, 200, boundBody);
1666
+ return;
1667
+ }
1668
+ // Unbound: to=user → rows in `messages` table
1669
+ const dedupKey = input.dedup_key ?? null;
1670
+ if (dedupKey) {
1671
+ const prior = store.findBySupersede(input.to, dedupKey);
1672
+ if (prior) {
1673
+ store.deleteMessage(prior.id);
1674
+ mlog.info("dedup_key supersede", {
1675
+ to: input.to,
1676
+ dedup_key: dedupKey,
1677
+ old_msg_id: prior.id,
1678
+ new_msg_id: id,
1679
+ });
1680
+ }
1681
+ }
1682
+ store.createMessage({
1683
+ id,
1684
+ from_ref: input.from_ref,
1685
+ from_label: input.from_label ?? null,
1686
+ to_ref: input.to,
1687
+ deliver: input.deliver,
1688
+ dedup_key: dedupKey,
1689
+ title: input.title,
1690
+ body: input.body,
1691
+ cwd: input.cwd ?? null,
1692
+ created_at: Date.now(),
1693
+ });
1694
+ sseManager.broadcast({ type: "message_created", messageId: id });
1695
+ if (deps.pushService) {
1696
+ void deps.pushService.sendForMessage({
1697
+ id,
1698
+ to: input.to,
1699
+ body: input.body,
1700
+ from_label: input.from_label,
1701
+ from_ref: input.from_ref,
1702
+ deliver: input.deliver,
1703
+ dedup_key: dedupKey,
1704
+ });
1705
+ }
1706
+ mlog.info("ingress unbound", {
1707
+ msg_id: id,
1708
+ from_ref: input.from_ref,
1709
+ });
1710
+ const unboundBody = { id, delivered: "pending" };
1711
+ saveClientOpResult(store, opId, "__ingress__", 200, unboundBody);
1712
+ json(res, 200, unboundBody);
1713
+ return;
1714
+ }
1715
+ // GET /api/v1/messages — list unprocessed
1716
+ if (url === "/api/v1/messages" && req.method === "GET") {
1717
+ json(res, 200, { messages: store.listUnprocessed() });
1718
+ return;
1719
+ }
1720
+ // /api/v1/messages/:id... — GET single, POST :id/consume, POST :id/ack, DELETE :id
1721
+ if (url.startsWith("/api/v1/messages/")) {
1722
+ const tail = url.slice("/api/v1/messages/".length);
1723
+ const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
1724
+ if (consumeMatch && req.method === "POST") {
1725
+ const id = decodeURIComponent(consumeMatch[1]);
1726
+ const newSid = randomUUID();
1727
+ let out;
1728
+ try {
1729
+ out = store.consumeMessageTx(id, { sessionId: newSid });
1730
+ }
1731
+ catch (err) {
1732
+ if (/message not found/.test(errorMessage(err))) {
1733
+ json(res, 404, { error: "Message not found" });
1734
+ return;
1735
+ }
1736
+ throw err;
1737
+ }
1738
+ sseManager.broadcast({
1739
+ type: "message_consumed",
1740
+ messageId: id,
1741
+ sessionId: out.sessionId,
1742
+ });
1743
+ if (!out.alreadyConsumed && deps.pushService) {
1744
+ void deps.pushService.sendClose(id);
1745
+ }
1746
+ mlog.info("consume", {
1747
+ msg_id: id,
1748
+ sess_id: out.sessionId.slice(0, 8),
1749
+ already_consumed: out.alreadyConsumed,
1750
+ });
1751
+ json(res, 200, {
1752
+ sessionId: out.sessionId,
1753
+ alreadyConsumed: out.alreadyConsumed,
1754
+ });
1755
+ return;
1756
+ }
1757
+ const ackPost = tail.match(/^([^/?]+)\/ack\/?$/);
1758
+ const idOnly = tail.match(/^([^/?]+)\/?$/);
1759
+ const isAck = (ackPost !== null && req.method === "POST") ||
1760
+ (idOnly !== null && req.method === "DELETE");
1761
+ if (isAck) {
1762
+ const id = decodeURIComponent((ackPost ?? idOnly)[1]);
1763
+ const changes = store.deleteMessage(id);
1764
+ if (changes === 0) {
1765
+ json(res, 404, { error: "Message not found" });
1766
+ return;
1767
+ }
1768
+ sseManager.broadcast({ type: "message_acked", messageId: id });
1769
+ if (deps.pushService)
1770
+ void deps.pushService.sendClose(id);
1771
+ mlog.info("ack", { msg_id: id });
1772
+ json(res, 200, { ok: true });
1773
+ return;
1774
+ }
1775
+ if (idOnly && req.method === "GET") {
1776
+ const id = decodeURIComponent(idOnly[1]);
1777
+ const row = store.getMessage(id);
1778
+ if (!row) {
1779
+ json(res, 404, { error: "Message not found" });
1780
+ return;
1781
+ }
1782
+ json(res, 200, row);
1783
+ return;
1784
+ }
1785
+ }
787
1786
  json(res, 404, { error: "Not found" });
788
1787
  return;
789
1788
  }
@@ -814,34 +1813,45 @@ export function createRequestHandler(deps) {
814
1813
  json(res, 400, { error: "Missing required field: text" });
815
1814
  return;
816
1815
  }
817
- const cwd = body.cwd || undefined;
1816
+ const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
818
1817
  const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
819
1818
  const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
820
1819
  json(res, 202, { sessionId, streamUrl });
821
1820
  // Fire-and-forget: send the prompt asynchronously, tracking busy state
822
1821
  sessions.activePrompts.add(sessionId);
1822
+ sessions.syncBusy(sessionId);
823
1823
  // Generate title (fire-and-forget)
824
1824
  if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
825
1825
  titleService.generate(bridge, text, sessionId, (title) => {
826
- const titleEvent = { type: "session_title_updated", sessionId, title };
1826
+ const titleEvent = {
1827
+ type: "session_title_updated",
1828
+ sessionId,
1829
+ title,
1830
+ };
827
1831
  sseManager.broadcast(titleEvent);
828
1832
  });
829
1833
  }
830
- bridge.prompt(sessionId, text)
1834
+ bridge
1835
+ .prompt(sessionId, text)
831
1836
  .catch(() => { })
832
- .finally(() => sessions.activePrompts.delete(sessionId));
1837
+ .finally(() => {
1838
+ sessions.activePrompts.delete(sessionId);
1839
+ sessions.syncBusy(sessionId);
1840
+ });
833
1841
  return;
834
1842
  }
835
1843
  // POST /api/beta/clients/:clientId/visibility
836
1844
  const visMatch = url.match(/^\/api\/beta\/clients\/([^/]+)\/visibility$/);
837
1845
  if (visMatch && req.method === "POST") {
838
- if (!deps.sseManager) {
839
- json(res, 501, { error: "SSE not available" });
840
- return;
841
- }
842
- const sseManager = deps.sseManager;
843
1846
  const clientId = decodeURIComponent(visMatch[1]);
844
- if (!sseManager.clients.has(clientId)) {
1847
+ // Trust boundary: accept if the client is (a) currently connected
1848
+ // via SSE, or (b) known to the ClientRegistry (populated on /hello,
1849
+ // persists across SSE disconnect). Registry check fixes the
1850
+ // pagehide-beacon race where iOS PWA suspension drops the SSE TCP
1851
+ // connection before the beacon egresses.
1852
+ const clientKnown = sseManager.clients.has(clientId) ||
1853
+ deps.clientRegistry?.get(clientId) !== undefined;
1854
+ if (!clientKnown) {
845
1855
  json(res, 404, { error: "Client not found" });
846
1856
  return;
847
1857
  }
@@ -857,11 +1867,42 @@ export function createRequestHandler(deps) {
857
1867
  json(res, 400, { error: "Missing or invalid 'visible' field" });
858
1868
  return;
859
1869
  }
860
- // If push service is available, update visibility and session
1870
+ // sessionId patch semantics: absent = preserve, null = clear,
1871
+ // string = replace. Zod can't distinguish omitted from explicit
1872
+ // null after parse, so branch on raw body key.
1873
+ const hasSessionIdKey = Object.prototype.hasOwnProperty.call(body, "sessionId");
1874
+ let sessionIdPatch;
1875
+ if (!hasSessionIdKey) {
1876
+ sessionIdPatch = undefined;
1877
+ }
1878
+ else if (body.sessionId === null) {
1879
+ sessionIdPatch = null;
1880
+ }
1881
+ else if (typeof body.sessionId === "string" &&
1882
+ body.sessionId.length > 0) {
1883
+ sessionIdPatch = body.sessionId;
1884
+ }
1885
+ else {
1886
+ sessionIdPatch = null;
1887
+ }
861
1888
  if (deps.pushService) {
862
- deps.pushService.setClientVisibility(clientId, body.visible);
863
- if (typeof body.sessionId === "string" && body.sessionId) {
864
- deps.pushService.setClientSession(clientId, body.sessionId);
1889
+ const { becameVisibleForSession } = deps.pushService.updateClient(clientId, {
1890
+ visible: body.visible,
1891
+ sessionId: sessionIdPatch,
1892
+ });
1893
+ // Edge-triggered only: heartbeat refreshes repeat the same
1894
+ // (visible:true, sessionId:X) POST every 15s — firing sendClose
1895
+ // on each would hammer banner recall. Only the first such
1896
+ // transition after a change should recall stale banners.
1897
+ if (becameVisibleForSession) {
1898
+ void deps.pushService.sendClose(`sess-${becameVisibleForSession}-done`);
1899
+ if (sessions) {
1900
+ for (const perm of sessions.pendingPermissions.values()) {
1901
+ if (perm.sessionId === becameVisibleForSession) {
1902
+ void deps.pushService.sendClose(`sess-${becameVisibleForSession}-perm-${perm.requestId}`);
1903
+ }
1904
+ }
1905
+ }
865
1906
  }
866
1907
  }
867
1908
  json(res, 200, { ok: true });
@@ -894,11 +1935,12 @@ export function createRequestHandler(deps) {
894
1935
  json(res, 400, { error: "Invalid JSON" });
895
1936
  return;
896
1937
  }
897
- if (!body.endpoint || !body.keys?.auth || !body.keys?.p256dh) {
1938
+ if (!body.endpoint || !body.keys?.auth || !body.keys.p256dh) {
898
1939
  json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
899
1940
  return;
900
1941
  }
901
1942
  store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
1943
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive
902
1944
  if (body.clientId && deps.pushService) {
903
1945
  deps.pushService.registerClient(body.clientId, body.endpoint);
904
1946
  }
@@ -957,7 +1999,11 @@ export function createRequestHandler(deps) {
957
1999
  return;
958
2000
  }
959
2001
  // --- Static files ---
960
- const filePath = join(deps.publicDir, url === "/" ? "/index.html" : url);
2002
+ let staticPath = url;
2003
+ const htmlEntry = HTML_ENTRYPOINTS.find((e) => e.urlPath === staticPath);
2004
+ if (htmlEntry)
2005
+ staticPath = "/" + htmlEntry.file;
2006
+ const filePath = join(deps.publicDir, staticPath);
961
2007
  if (!filePath.startsWith(deps.publicDir)) {
962
2008
  res.writeHead(403);
963
2009
  res.end("Forbidden");
@@ -966,7 +2012,19 @@ export function createRequestHandler(deps) {
966
2012
  try {
967
2013
  const data = await readFile(filePath);
968
2014
  const ext = extname(filePath);
969
- res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
2015
+ const base = filePath.slice(filePath.lastIndexOf("/") + 1);
2016
+ const isHashedAsset = /\.[A-Za-z0-9_-]{8,}\.(js|css)$/.test(base);
2017
+ const cacheControl = isHashedAsset
2018
+ ? "public, max-age=31536000, immutable"
2019
+ : "no-cache";
2020
+ const headers = {
2021
+ "Content-Type": MIME[ext] ?? "application/octet-stream",
2022
+ "Cache-Control": cacheControl,
2023
+ };
2024
+ // CSP applies to HTML entrypoints (where script/style execute).
2025
+ if (htmlEntry)
2026
+ headers["Content-Security-Policy"] = CSP_POLICY;
2027
+ res.writeHead(200, headers);
970
2028
  res.end(data);
971
2029
  }
972
2030
  catch {