@lelouchhe/webagent 0.2.6 → 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 +102 -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 +127 -9
  31. package/lib/daemon.js +185 -40
  32. package/lib/event-handler.js +209 -90
  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 +1218 -144
  40. package/lib/server.js +159 -32
  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 +654 -24
  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.4FZ67UW4.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");
@@ -58,6 +406,7 @@ export function createRequestHandler(deps) {
58
406
  version: "v1",
59
407
  endpoints: {
60
408
  sessions: "/api/v1/sessions",
409
+ paths: "/api/v1/recent-paths",
61
410
  config: "/api/v1/config",
62
411
  events_stream: "/api/v1/events/stream",
63
412
  prompt: "/api/beta/prompt",
@@ -68,7 +417,9 @@ export function createRequestHandler(deps) {
68
417
  return;
69
418
  }
70
419
  // GET /api/v1/sessions
71
- 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") {
72
423
  const params = new URLSearchParams(url.split("?")[1] ?? "");
73
424
  const source = params.get("source") ?? undefined;
74
425
  res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
@@ -79,7 +430,21 @@ export function createRequestHandler(deps) {
79
430
  json(res, 200, {
80
431
  configOptions: sessions?.cachedConfigOptions ?? [],
81
432
  cancelTimeout: deps.limits.cancel_timeout ?? 0,
433
+ recentPathsLimit: deps.limits.recent_paths ?? 10,
434
+ });
435
+ return;
436
+ }
437
+ // GET /api/v1/recent-paths
438
+ if (url.startsWith("/api/v1/recent-paths") && req.method === "GET") {
439
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
440
+ const limitParam = params.get("limit");
441
+ const limit = limitParam != null ? Math.max(0, parseInt(limitParam, 10)) : 0;
442
+ const ttlDays = deps.limits.recent_paths_ttl ?? 30;
443
+ const paths = store.listRecentPaths({
444
+ limit: isNaN(limit) ? 0 : limit,
445
+ ttlDays,
82
446
  });
447
+ json(res, 200, paths);
83
448
  return;
84
449
  }
85
450
  // GET /api/v1/version
@@ -90,6 +455,136 @@ export function createRequestHandler(deps) {
90
455
  });
91
456
  return;
92
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
+ }
93
588
  // --- POST /api/v1/bridge/reload ---
94
589
  if (url === "/api/v1/bridge/reload" && req.method === "POST") {
95
590
  const bridge = getBridge?.();
@@ -124,6 +619,9 @@ export function createRequestHandler(deps) {
124
619
  if (permActionMatch && req.method === "POST") {
125
620
  const sessionId = decodeURIComponent(permActionMatch[1]);
126
621
  const requestId = decodeURIComponent(permActionMatch[2]);
622
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
623
+ if (replayed)
624
+ return;
127
625
  const perm = sessions?.pendingPermissions.get(requestId);
128
626
  if (!perm) {
129
627
  json(res, 404, { error: "Permission not found" });
@@ -150,25 +648,34 @@ export function createRequestHandler(deps) {
150
648
  json(res, 400, { error: "Provide optionId or denied:true" });
151
649
  return;
152
650
  }
153
- const denied = !!body.denied;
651
+ const denied = Boolean(body.denied);
154
652
  const optionId = body.optionId ?? "deny";
155
- const optionName = perm.options.find(o => o.optionId === optionId)?.label ?? optionId;
653
+ const optionName = perm.options.find((o) => o.optionId === optionId)?.label ?? optionId;
156
654
  if (denied) {
157
- await bridge.denyPermission(requestId);
655
+ bridge.denyPermission(requestId);
158
656
  }
159
657
  else {
160
- await bridge.resolvePermission(requestId, optionId);
658
+ bridge.resolvePermission(requestId, optionId);
161
659
  }
162
660
  sessions.pendingPermissions.delete(requestId);
661
+ sessions.syncPendingPermissions(sessionId);
163
662
  // Store event and broadcast (same type so SSE drops are recoverable via sync)
164
663
  const permEventData = { requestId, optionName, denied };
165
- store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId });
664
+ store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
166
665
  sseManager.broadcast({
167
666
  type: "permission_response",
168
667
  sessionId: perm.sessionId,
169
668
  ...permEventData,
170
669
  });
171
- 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);
172
679
  return;
173
680
  }
174
681
  // --- POST /api/v1/sessions/:id/cancel ---
@@ -185,6 +692,9 @@ export function createRequestHandler(deps) {
185
692
  json(res, 503, { error: "Agent not ready yet" });
186
693
  return;
187
694
  }
695
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
696
+ if (replayed)
697
+ return;
188
698
  // Kill running bash process if any
189
699
  const proc = sessions?.runningBashProcs.get(sessionId);
190
700
  if (proc) {
@@ -196,7 +706,16 @@ export function createRequestHandler(deps) {
196
706
  await bridge.cancel(sessionId);
197
707
  sessions.activePrompts.delete(sessionId);
198
708
  }
199
- 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);
200
719
  return;
201
720
  }
202
721
  // --- GET /api/v1/sessions/:id/status ---
@@ -217,6 +736,45 @@ export function createRequestHandler(deps) {
217
736
  });
218
737
  return;
219
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
+ }
220
778
  // --- POST /api/v1/sessions/:id/prompt ---
221
779
  const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
222
780
  if (promptMatch && req.method === "POST") {
@@ -235,12 +793,17 @@ export function createRequestHandler(deps) {
235
793
  json(res, 503, { error: "Session manager not available" });
236
794
  return;
237
795
  }
796
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
797
+ if (replayed)
798
+ return;
238
799
  // Ensure session is live in ACP before prompting (awaits in-flight resume)
239
800
  try {
240
801
  await sessions.ensureResumed(bridge, sessionId);
241
802
  }
242
803
  catch (err) {
243
- 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
+ });
244
807
  return;
245
808
  }
246
809
  // Check if session is busy
@@ -261,27 +824,101 @@ export function createRequestHandler(deps) {
261
824
  json(res, 400, { error: "Missing required field: text" });
262
825
  return;
263
826
  }
264
- // Store user_message event (strip base64 data, keep only path + mimeType)
265
- const storedImages = body.images?.map(i => ({ path: i.path, mimeType: i.mimeType }));
266
- 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" });
267
885
  store.updateSessionLastActive(sessionId);
268
- const userMsgEvent = { type: "user_message", sessionId, text: body.text, images: storedImages };
886
+ store.touchRecentPath(session.cwd);
887
+ const userMsgEvent = {
888
+ type: "user_message",
889
+ sessionId,
890
+ text: body.text,
891
+ attachments: storedAttachments,
892
+ };
269
893
  sseManager.broadcast(userMsgEvent);
270
894
  // Generate title (fire-and-forget)
271
- 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)) {
272
898
  titleService.generate(bridge, body.text, sessionId, (title) => {
273
- const titleEvent = { type: "session_title_updated", sessionId, title };
899
+ const titleEvent = {
900
+ type: "session_title_updated",
901
+ sessionId,
902
+ title,
903
+ };
274
904
  sseManager.broadcast(titleEvent);
275
905
  });
276
906
  }
277
907
  // Fire prompt asynchronously (don't await — response is 202)
278
908
  sessions.activePrompts.add(sessionId);
279
- bridge.prompt(sessionId, body.text, body.images).catch((err) => {
280
- console.error(`[prompt] error for ${sessionId}:`, err);
281
- }).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(() => {
282
916
  sessions.activePrompts.delete(sessionId);
917
+ sessions.syncBusy(sessionId);
283
918
  });
284
- json(res, 202, { status: "accepted" });
919
+ const acceptedBody = { status: "accepted" };
920
+ saveClientOpResult(store, opId, sessionId, 202, acceptedBody);
921
+ json(res, 202, acceptedBody);
285
922
  return;
286
923
  }
287
924
  // --- POST /api/v1/sessions/:id/bash ---
@@ -298,7 +935,9 @@ export function createRequestHandler(deps) {
298
935
  return;
299
936
  }
300
937
  if (sessions.runningBashProcs.has(sessionId)) {
301
- 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
+ });
302
941
  return;
303
942
  }
304
943
  let body;
@@ -314,11 +953,19 @@ export function createRequestHandler(deps) {
314
953
  return;
315
954
  }
316
955
  const cwd = sessions.getSessionCwd(sessionId);
317
- store.saveEvent(sessionId, "bash_command", { command: body.command });
318
- 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
+ };
319
962
  sseManager.broadcast(bashCmdEvent);
320
- const shell = IS_WIN ? (process.env.COMSPEC || "cmd.exe") : (process.env.SHELL || "bash");
321
- 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];
322
969
  const child = spawn(shell, shellArgs, {
323
970
  cwd,
324
971
  detached: !IS_WIN,
@@ -326,6 +973,7 @@ export function createRequestHandler(deps) {
326
973
  stdio: ["ignore", "pipe", "pipe"],
327
974
  });
328
975
  sessions.runningBashProcs.set(sessionId, child);
976
+ sessions.syncBusy(sessionId);
329
977
  let output = "";
330
978
  let outputTruncated = false;
331
979
  const limit = deps.limits.bash_output;
@@ -341,23 +989,41 @@ export function createRequestHandler(deps) {
341
989
  else {
342
990
  output = (output + text).slice(-limit);
343
991
  }
344
- const bashOutEvent = { type: "bash_output", sessionId, text, stream };
992
+ const bashOutEvent = {
993
+ type: "bash_output",
994
+ sessionId,
995
+ text,
996
+ stream,
997
+ };
345
998
  sseManager.broadcast(bashOutEvent);
346
999
  };
347
1000
  child.stdout.on("data", onData("stdout"));
348
1001
  child.stderr.on("data", onData("stderr"));
349
1002
  child.on("close", (code, signal) => {
350
1003
  sessions.runningBashProcs.delete(sessionId);
1004
+ sessions.syncBusy(sessionId);
351
1005
  const stored = outputTruncated ? "[truncated]\n" + output : output;
352
- store.saveEvent(sessionId, "bash_result", { output: stored, code, signal });
353
- 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
+ };
354
1013
  sseManager.broadcast(bashDoneEvent);
355
1014
  });
356
1015
  child.on("error", (err) => {
357
1016
  sessions.runningBashProcs.delete(sessionId);
1017
+ sessions.syncBusy(sessionId);
358
1018
  const errMsg = errorMessage(err);
359
- store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
360
- 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
+ };
361
1027
  sseManager.broadcast(bashErrEvent);
362
1028
  });
363
1029
  json(res, 202, { status: "accepted" });
@@ -409,12 +1075,23 @@ export function createRequestHandler(deps) {
409
1075
  for (const opt of configOptions) {
410
1076
  store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
411
1077
  }
412
- sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
413
- 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
+ });
414
1089
  json(res, 200, { configOptions });
415
1090
  }
416
1091
  catch (err) {
417
- 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
+ });
418
1095
  }
419
1096
  return;
420
1097
  }
@@ -444,8 +1121,12 @@ export function createRequestHandler(deps) {
444
1121
  sessions.sessionHasTitle.add(sessionId);
445
1122
  const bridge = getBridge?.();
446
1123
  if (titleService && bridge)
447
- titleService.cancel(sessionId, bridge);
448
- 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
+ };
449
1130
  sseManager.broadcast(titleEvent);
450
1131
  json(res, 200, { title: body.value });
451
1132
  return;
@@ -463,53 +1144,112 @@ export function createRequestHandler(deps) {
463
1144
  json(res, 404, { error: "Session not found" });
464
1145
  return;
465
1146
  }
466
- // 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.
467
1152
  const wasLive = sessions?.liveSessions.has(sessionId) ?? true;
1153
+ let resumePromise = null;
468
1154
  if (sessions && getBridge && !wasLive) {
469
1155
  const bridge = getBridge();
470
1156
  if (bridge) {
471
- 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(() => { });
472
1182
  // Auto-retry if the last turn was interrupted (must wait for resume)
473
1183
  const hasInterrupted = store.hasInterruptedTurn(sessionId);
474
1184
  if (hasInterrupted) {
475
1185
  // Optimistically mark busy so concurrent POST sees the session as active
476
1186
  sessions.activePrompts.add(sessionId);
477
- resumePromise.then(() => {
1187
+ sessions.syncBusy(sessionId);
1188
+ void resumePromise
1189
+ .then(() => {
478
1190
  if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
479
1191
  // Retry not needed after all — release the optimistic lock
480
1192
  sessions.activePrompts.delete(sessionId);
1193
+ sessions.syncBusy(sessionId);
481
1194
  }
482
- }).catch(() => {
1195
+ })
1196
+ .catch(() => {
483
1197
  sessions.activePrompts.delete(sessionId);
1198
+ sessions.syncBusy(sessionId);
484
1199
  });
485
1200
  }
486
1201
  else {
487
1202
  resumePromise.catch((err) => {
488
- 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
+ });
489
1207
  });
490
1208
  }
491
1209
  }
492
1210
  }
493
- const configOptions = sessions ? (() => {
494
- // Build configOptions from cached + stored overrides
495
- const opts = sessions.cachedConfigOptions.map(opt => {
496
- const stored = { model: session.model, mode: session.mode, reasoning_effort: session.reasoning_effort };
497
- const override = stored[opt.id];
498
- 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);
499
1219
  });
500
- return opts;
501
- })() : [];
502
- 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
+ : [];
503
1245
  json(res, 200, {
504
- id: session.id,
505
- cwd: session.cwd,
506
- title: session.title,
507
- source: session.source,
508
- model: session.model,
509
- 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,
510
1252
  configOptions,
511
- busy: busyKind != null,
512
- busyKind,
513
1253
  }, req);
514
1254
  return;
515
1255
  }
@@ -566,7 +1306,11 @@ export function createRequestHandler(deps) {
566
1306
  // ACP's session_created event fires before inheritance runs, so
567
1307
  // broadcast final configOptions so SSE clients get the inherited values.
568
1308
  if (configOptions.length) {
569
- sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
1309
+ sseManager.broadcast({
1310
+ type: "config_option_update",
1311
+ sessionId,
1312
+ configOptions,
1313
+ });
570
1314
  }
571
1315
  json(res, 201, {
572
1316
  id: sessionId,
@@ -591,14 +1335,17 @@ export function createRequestHandler(deps) {
591
1335
  const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
592
1336
  if (eventsMatch && req.method === "GET") {
593
1337
  const sessionId = decodeURIComponent(eventsMatch[1]);
594
- const params = new URLSearchParams(eventsMatch[2]?.slice(1) ?? "");
1338
+ const queryPart = eventsMatch[2];
1339
+ const params = new URLSearchParams(queryPart ? queryPart.slice(1) : "");
595
1340
  const excludeThinking = params.get("thinking") === "0";
596
1341
  const afterRaw = params.get("after");
597
1342
  const afterSeq = afterRaw != null ? Number(afterRaw) : undefined;
598
1343
  const beforeRaw = params.get("before");
599
1344
  const beforeSeq = beforeRaw != null ? Number(beforeRaw) : undefined;
600
1345
  const limitRaw = params.get("limit");
601
- 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;
602
1349
  const session = store.getSession(sessionId);
603
1350
  if (!session) {
604
1351
  json(res, 404, { error: "Session not found" });
@@ -619,15 +1366,45 @@ export function createRequestHandler(deps) {
619
1366
  sessions.flushAssistantBuffer(sessionId);
620
1367
  }
621
1368
  }
622
- 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
+ }
623
1393
  const envelope = {
624
1394
  events,
625
- streaming: { thinking: streamingThinking, assistant: streamingAssistant },
1395
+ streaming: {
1396
+ thinking: streamingThinking,
1397
+ assistant: streamingAssistant,
1398
+ },
626
1399
  };
627
1400
  if (limit != null) {
628
1401
  const total = store.getEventCount(sessionId, { excludeThinking });
629
1402
  const hasMore = events.length > 0
630
- ? (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
631
1408
  : false;
632
1409
  envelope.total = total;
633
1410
  envelope.hasMore = hasMore;
@@ -638,32 +1415,54 @@ export function createRequestHandler(deps) {
638
1415
  // --- SSE stream endpoints ---
639
1416
  // GET /api/v1/events/stream — global SSE stream
640
1417
  if (url.startsWith("/api/v1/events/stream") && req.method === "GET") {
641
- if (!deps.sseManager) {
642
- json(res, 501, { error: "SSE not available" });
643
- 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;
644
1430
  }
645
- const sseManager = deps.sseManager;
646
1431
  const clientId = sseManager.generateClientId();
647
1432
  res.writeHead(200, {
648
1433
  "Content-Type": "text/event-stream",
649
1434
  "Cache-Control": "no-cache",
650
- "Connection": "keep-alive",
1435
+ Connection: "keep-alive",
651
1436
  });
652
- const client = { id: clientId, res };
1437
+ const client = {
1438
+ id: clientId,
1439
+ res,
1440
+ tokenName,
1441
+ };
653
1442
  sseManager.add(client);
654
1443
  // Send connected event
655
- 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);
656
1450
  return;
657
1451
  }
658
1452
  // GET /api/v1/sessions/:id/events/stream — per-session SSE stream
659
1453
  const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
660
1454
  if (sseSessionMatch && req.method === "GET") {
661
- if (!deps.sseManager) {
662
- json(res, 501, { error: "SSE not available" });
663
- return;
664
- }
665
- const sseManager = deps.sseManager;
666
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
+ }
667
1466
  const session = store.getSession(sessionId);
668
1467
  if (!session) {
669
1468
  json(res, 404, { error: "Session not found" });
@@ -673,12 +1472,22 @@ export function createRequestHandler(deps) {
673
1472
  res.writeHead(200, {
674
1473
  "Content-Type": "text/event-stream",
675
1474
  "Cache-Control": "no-cache",
676
- "Connection": "keep-alive",
1475
+ Connection: "keep-alive",
677
1476
  });
678
- const client = { id: clientId, res, sessionId };
1477
+ const client = {
1478
+ id: clientId,
1479
+ res,
1480
+ sessionId,
1481
+ tokenName,
1482
+ };
679
1483
  sseManager.add(client);
680
1484
  // Send connected event
681
- 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);
682
1491
  // Replay events from Last-Event-ID if provided
683
1492
  const lastEventId = req.headers["last-event-id"];
684
1493
  if (lastEventId) {
@@ -687,7 +1496,10 @@ export function createRequestHandler(deps) {
687
1496
  const events = store.getEvents(sessionId, { afterSeq });
688
1497
  for (const evt of events) {
689
1498
  try {
690
- 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);
691
1503
  }
692
1504
  catch {
693
1505
  // Skip malformed event data
@@ -697,69 +1509,75 @@ export function createRequestHandler(deps) {
697
1509
  }
698
1510
  return;
699
1511
  }
700
- // --- Images (session-scoped) ---
701
- // POST /api/v1/sessions/:id/images
702
- 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\/?$/);
703
1521
  if (imgUploadMatch && req.method === "POST") {
704
1522
  const sessionId = decodeURIComponent(imgUploadMatch[1]);
705
1523
  if (!SAFE_ID.test(sessionId)) {
706
1524
  json(res, 400, { error: "Invalid session ID" });
707
1525
  return;
708
1526
  }
709
- // Enforce upload size limit
710
- const contentLength = parseInt(req.headers["content-length"] ?? "0", 10);
711
- if (contentLength > deps.limits.image_upload) {
712
- 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" });
713
1530
  return;
714
1531
  }
715
- const chunks = [];
716
- let totalSize = 0;
717
- for await (const chunk of req) {
718
- totalSize += chunk.length;
719
- if (totalSize > deps.limits.image_upload) {
720
- json(res, 413, { error: "Upload too large" });
721
- return;
722
- }
723
- chunks.push(chunk);
724
- }
725
- let body;
726
- try {
727
- body = JSON.parse(Buffer.concat(chunks).toString());
728
- }
729
- catch {
730
- json(res, 400, { error: "Invalid JSON" });
731
- return;
732
- }
733
- const { data, mimeType } = body;
734
- const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
735
- const seq = Date.now();
736
- const fileName = `${seq}.${ext}`;
737
- const relPath = `images/${sessionId}/${fileName}`;
738
- const absPath = join(deps.dataDir, relPath);
739
- await mkdir(join(deps.dataDir, "images", sessionId), { recursive: true });
740
- await writeFile(absPath, Buffer.from(data, "base64"));
741
- const imgUrl = `/api/v1/sessions/${sessionId}/images/${fileName}`;
742
- json(res, 200, { path: relPath, url: imgUrl });
1532
+ await handleAttachmentUpload(req, res, sessionId, deps);
743
1533
  return;
744
1534
  }
745
- // GET /api/v1/sessions/:id/images/:file
746
- 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\/([^/?]+)(\?.*)?$/);
747
1540
  if (imgGetMatch && req.method === "GET") {
748
1541
  const sessionId = decodeURIComponent(imgGetMatch[1]);
749
1542
  const file = decodeURIComponent(imgGetMatch[2]);
750
- const filePath = join(deps.dataDir, "images", sessionId, file);
751
- 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"))) {
752
1559
  res.writeHead(403);
753
1560
  res.end("Forbidden");
754
1561
  return;
755
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);
756
1567
  try {
757
1568
  const fileData = await readFile(filePath);
758
- const ext = extname(filePath);
759
- res.writeHead(200, {
760
- "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,
761
1573
  "Cache-Control": "public, max-age=31536000, immutable",
762
- });
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);
763
1581
  res.end(fileData);
764
1582
  }
765
1583
  catch {
@@ -768,6 +1586,203 @@ export function createRequestHandler(deps) {
768
1586
  }
769
1587
  return;
770
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
+ }
771
1786
  json(res, 404, { error: "Not found" });
772
1787
  return;
773
1788
  }
@@ -798,34 +1813,45 @@ export function createRequestHandler(deps) {
798
1813
  json(res, 400, { error: "Missing required field: text" });
799
1814
  return;
800
1815
  }
801
- const cwd = body.cwd || undefined;
1816
+ const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
802
1817
  const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
803
1818
  const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
804
1819
  json(res, 202, { sessionId, streamUrl });
805
1820
  // Fire-and-forget: send the prompt asynchronously, tracking busy state
806
1821
  sessions.activePrompts.add(sessionId);
1822
+ sessions.syncBusy(sessionId);
807
1823
  // Generate title (fire-and-forget)
808
1824
  if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
809
1825
  titleService.generate(bridge, text, sessionId, (title) => {
810
- const titleEvent = { type: "session_title_updated", sessionId, title };
1826
+ const titleEvent = {
1827
+ type: "session_title_updated",
1828
+ sessionId,
1829
+ title,
1830
+ };
811
1831
  sseManager.broadcast(titleEvent);
812
1832
  });
813
1833
  }
814
- bridge.prompt(sessionId, text)
1834
+ bridge
1835
+ .prompt(sessionId, text)
815
1836
  .catch(() => { })
816
- .finally(() => sessions.activePrompts.delete(sessionId));
1837
+ .finally(() => {
1838
+ sessions.activePrompts.delete(sessionId);
1839
+ sessions.syncBusy(sessionId);
1840
+ });
817
1841
  return;
818
1842
  }
819
1843
  // POST /api/beta/clients/:clientId/visibility
820
1844
  const visMatch = url.match(/^\/api\/beta\/clients\/([^/]+)\/visibility$/);
821
1845
  if (visMatch && req.method === "POST") {
822
- if (!deps.sseManager) {
823
- json(res, 501, { error: "SSE not available" });
824
- return;
825
- }
826
- const sseManager = deps.sseManager;
827
1846
  const clientId = decodeURIComponent(visMatch[1]);
828
- 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) {
829
1855
  json(res, 404, { error: "Client not found" });
830
1856
  return;
831
1857
  }
@@ -841,11 +1867,42 @@ export function createRequestHandler(deps) {
841
1867
  json(res, 400, { error: "Missing or invalid 'visible' field" });
842
1868
  return;
843
1869
  }
844
- // 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
+ }
845
1888
  if (deps.pushService) {
846
- deps.pushService.setClientVisibility(clientId, body.visible);
847
- if (typeof body.sessionId === "string" && body.sessionId) {
848
- 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
+ }
849
1906
  }
850
1907
  }
851
1908
  json(res, 200, { ok: true });
@@ -878,11 +1935,12 @@ export function createRequestHandler(deps) {
878
1935
  json(res, 400, { error: "Invalid JSON" });
879
1936
  return;
880
1937
  }
881
- if (!body.endpoint || !body.keys?.auth || !body.keys?.p256dh) {
1938
+ if (!body.endpoint || !body.keys?.auth || !body.keys.p256dh) {
882
1939
  json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
883
1940
  return;
884
1941
  }
885
1942
  store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
1943
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive
886
1944
  if (body.clientId && deps.pushService) {
887
1945
  deps.pushService.registerClient(body.clientId, body.endpoint);
888
1946
  }
@@ -941,7 +1999,11 @@ export function createRequestHandler(deps) {
941
1999
  return;
942
2000
  }
943
2001
  // --- Static files ---
944
- 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);
945
2007
  if (!filePath.startsWith(deps.publicDir)) {
946
2008
  res.writeHead(403);
947
2009
  res.end("Forbidden");
@@ -950,7 +2012,19 @@ export function createRequestHandler(deps) {
950
2012
  try {
951
2013
  const data = await readFile(filePath);
952
2014
  const ext = extname(filePath);
953
- 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);
954
2028
  res.end(data);
955
2029
  }
956
2030
  catch {