@lelouchhe/webagent 0.3.0 → 0.5.1

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 (61) hide show
  1. package/README.md +59 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +105 -3
  4. package/dist/fonts/temml/Temml.woff2 +0 -0
  5. package/dist/index.html +64 -41
  6. package/dist/js/app.3OEVQXHK.js +4 -0
  7. package/dist/js/chunk.AJZBJBMO.js +1 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.IJM5DBCO.js +173 -0
  10. package/dist/js/chunk.VZXGXFNN.js +5 -0
  11. package/dist/js/login.PYIK52HN.js +1 -0
  12. package/dist/js/viewer.FCSSTUVY.js +1 -0
  13. package/dist/login.html +49 -0
  14. package/dist/share-viewer.00gubshk.css +114 -0
  15. package/dist/share-viewer.html +54 -0
  16. package/dist/styles.00xfh3e6.css +1848 -0
  17. package/dist/sw.js +79 -27
  18. package/dist/theme-init.js +6 -0
  19. package/lib/agent-detect.js +110 -0
  20. package/lib/atomic-write.js +50 -0
  21. package/lib/attachment-dispatch.js +86 -0
  22. package/lib/attachment-interceptor.js +130 -0
  23. package/lib/attachment-labels.js +139 -0
  24. package/lib/attachments.js +154 -0
  25. package/lib/auth-middleware.js +105 -0
  26. package/lib/auth-store.js +269 -0
  27. package/lib/auth.js +89 -0
  28. package/lib/bootstrap.js +70 -0
  29. package/lib/bridge-event-config.js +29 -0
  30. package/lib/bridge.js +244 -93
  31. package/lib/client-registry.js +149 -0
  32. package/lib/config.js +130 -9
  33. package/lib/daemon.js +175 -41
  34. package/lib/event-handler.js +209 -91
  35. package/lib/image-dimensions.js +64 -0
  36. package/lib/log-fmt.js +67 -0
  37. package/lib/log.js +83 -0
  38. package/lib/message-cleanup.js +48 -0
  39. package/lib/mode-bucket.js +62 -0
  40. package/lib/model-picker.js +17 -0
  41. package/lib/preflight.js +214 -0
  42. package/lib/push-service.js +297 -52
  43. package/lib/routes.js +1315 -145
  44. package/lib/server.js +149 -37
  45. package/lib/session-manager.js +164 -18
  46. package/lib/session-state.js +160 -0
  47. package/lib/sessions-anchor.js +28 -0
  48. package/lib/share/cleanup.js +45 -0
  49. package/lib/share/routes.js +972 -0
  50. package/lib/share/sanitize.js +179 -0
  51. package/lib/sse-manager.js +94 -8
  52. package/lib/sse-ticket.js +45 -0
  53. package/lib/startup-checks.js +95 -0
  54. package/lib/store.js +636 -30
  55. package/lib/title-service.js +26 -9
  56. package/lib/tokens.js +50 -0
  57. package/lib/types.js +23 -0
  58. package/package.json +39 -4
  59. package/dist/js/app.2562YGRO.js +0 -10
  60. package/dist/styles.008ve1hx.css +0 -669
  61. package/lib/shared/constants.js +0 -17
package/lib/routes.js CHANGED
@@ -1,10 +1,23 @@
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";
20
+ import { readImageDimensions } from "./image-dimensions.js";
8
21
  const IS_WIN = process.platform === "win32";
9
22
  const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
10
23
  const MIME = {
@@ -18,20 +31,111 @@ const MIME = {
18
31
  ".jpeg": "image/jpeg",
19
32
  ".gif": "image/gif",
20
33
  ".webp": "image/webp",
34
+ ".woff2": "font/woff2",
35
+ ".wasm": "application/wasm",
21
36
  };
37
+ /**
38
+ * HTML entrypoints served by this app. Any new HTML page MUST be registered
39
+ * here. Tests in `test/html-entrypoints.test.ts`, `test/csp.test.ts` and
40
+ * `test/inline-assets.test.ts` rely on this list to enforce security
41
+ * invariants (CSP header, no inline scripts/styles).
42
+ */
43
+ export const HTML_ENTRYPOINTS = [
44
+ { urlPath: "/", file: "index.html" },
45
+ { urlPath: "/login", file: "login.html" },
46
+ // Share viewer — served by share's own dispatcher at /s/:token
47
+ // (see src/share/routes.ts → handleViewerHtml). The urlPath here is a
48
+ // pseudo-path used only by the test invariants (CSP/inline checks); the
49
+ // real route is /s/:token. Keep both views in sync if either changes.
50
+ { urlPath: "/s", file: "share-viewer.html" },
51
+ ];
52
+ /**
53
+ * Strict Content-Security-Policy for HTML responses.
54
+ *
55
+ * - default-src 'self': everything same-origin only
56
+ * - img-src adds data: + blob: for image-upload preview
57
+ * - script-src 'self' 'wasm-unsafe-eval' (no inline; theme bootstrap is
58
+ * /theme-init.js. 'wasm-unsafe-eval' is the narrow CSP3 token that
59
+ * permits WebAssembly.instantiate() WITHOUT re-enabling eval()/Function;
60
+ * needed by any wasm consumer.)
61
+ * - style-src 'self' (no inline; login styles live in /styles.css)
62
+ * - object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self'
63
+ * - connect-src 'self' for fetch + EventSource
64
+ */
65
+ export const CSP_POLICY = [
66
+ "default-src 'self'",
67
+ "img-src 'self' data: blob:",
68
+ "script-src 'self' 'wasm-unsafe-eval'",
69
+ "style-src 'self'",
70
+ "connect-src 'self'",
71
+ "object-src 'none'",
72
+ "frame-ancestors 'none'",
73
+ "base-uri 'self'",
74
+ "form-action 'self'",
75
+ ].join("; ");
22
76
  /** Read the full request body as a string. */
23
77
  function readBody(req) {
24
78
  return new Promise((resolve, reject) => {
25
79
  const chunks = [];
26
80
  req.on("data", (chunk) => chunks.push(chunk));
27
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
81
+ req.on("end", () => {
82
+ resolve(Buffer.concat(chunks).toString());
83
+ });
28
84
  req.on("error", reject);
29
85
  });
30
86
  }
87
+ /**
88
+ * client-server-split M2: idempotency helpers for mutating REST.
89
+ * Frontend generates a UUID per user-initiated action and sends it as
90
+ * `X-Client-Op-Id`. Replays (after SSE/network reconnect) return the
91
+ * cached response instead of re-executing side effects. Missing header →
92
+ * non-idempotent path (back-compat for curl / older clients).
93
+ */
94
+ function getClientOpId(req) {
95
+ const v = req.headers["x-client-op-id"];
96
+ if (typeof v === "string" && v.length > 0 && v.length <= 128)
97
+ return v;
98
+ return null;
99
+ }
100
+ function logPromptRejectBeforeSave(fields) {
101
+ plog.warn("rejected before save", {
102
+ sessionId: fields.sessionId.slice(0, 8),
103
+ status: fields.status,
104
+ reason: fields.reason,
105
+ ...(fields.opId ? { opId: fields.opId } : {}),
106
+ ...(fields.textLength != null ? { textLength: fields.textLength } : {}),
107
+ ...(fields.attachmentCount != null
108
+ ? { attachmentCount: fields.attachmentCount }
109
+ : {}),
110
+ ...(fields.busyKind ? { busyKind: fields.busyKind } : {}),
111
+ ...(fields.error ? { error: fields.error } : {}),
112
+ });
113
+ }
114
+ function tryReplayClientOp(req, res, store, sessionId) {
115
+ const opId = getClientOpId(req);
116
+ if (!opId)
117
+ return { opId: null, replayed: false };
118
+ const cached = store.getClientOp(sessionId, opId);
119
+ if (cached &&
120
+ typeof cached === "object" &&
121
+ "status" in cached &&
122
+ "body" in cached) {
123
+ json(res, cached.status, cached.body, req);
124
+ return { opId, replayed: true };
125
+ }
126
+ return { opId, replayed: false };
127
+ }
128
+ function saveClientOpResult(store, opId, sessionId, status, body) {
129
+ if (!opId)
130
+ return;
131
+ store.saveClientOp(sessionId, opId, { status, body });
132
+ }
31
133
  /** Send a JSON response, gzip-compressed when the client supports it. */
32
134
  function json(res, status, data, req) {
33
135
  const body = JSON.stringify(data);
34
- if (req && body.length > 1024 && (req.headers["accept-encoding"] || "").includes("gzip")) {
136
+ if (req &&
137
+ body.length > 1024 &&
138
+ (req.headers["accept-encoding"] ?? "").includes("gzip")) {
35
139
  const compressed = gzipSync(body);
36
140
  res.writeHead(status, {
37
141
  "Content-Type": "application/json",
@@ -45,10 +149,279 @@ function json(res, status, data, req) {
45
149
  res.end(body);
46
150
  }
47
151
  }
152
+ /** Per-request principal storage. WeakMap keeps it tied to the request lifetime
153
+ * without monkey-patching IncomingMessage or relying on `any`. */
154
+ const principalByRequest = new WeakMap();
155
+ export function getPrincipal(req) {
156
+ return principalByRequest.get(req);
157
+ }
158
+ /**
159
+ * Multipart upload handler. Streams the `file` field straight to disk under
160
+ * <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp, atomic-renames on
161
+ * success, deletes on any failure path. Inserts an attachments row with the
162
+ * resolved realpath so the bridge / permission interceptor can match it
163
+ * later.
164
+ *
165
+ * Wire format expected:
166
+ * - Content-Type: multipart/form-data; boundary=...
167
+ * - One file field named `file` (additional file fields are rejected).
168
+ * - Optional text fields are ignored — displayName comes from the file
169
+ * part's filename header, classification comes from its content-type.
170
+ */
171
+ async function handleAttachmentUpload(req, res, sessionId, deps) {
172
+ const { store, dataDir, limits, sessions } = deps;
173
+ const fileUploadLimit = limits.file_upload ?? 52_428_800;
174
+ if (!store.getSession(sessionId)) {
175
+ json(res, 404, { error: "Session not found" });
176
+ return;
177
+ }
178
+ const dir = join(dataDir, "sessions", sessionId, "attachments");
179
+ await mkdir(dir, { recursive: true });
180
+ const uploadId = randomUUID();
181
+ let tmpPath = null;
182
+ let finalPath = null;
183
+ let bytesWritten = 0;
184
+ let fileMime = "";
185
+ let fileExt = "bin";
186
+ let displayName = null;
187
+ let kind = "file";
188
+ let limit = Math.max(limits.image_upload, fileUploadLimit);
189
+ let limitExceeded = false;
190
+ let sawFile = false;
191
+ let aborted = false;
192
+ let writeError = null;
193
+ let resolved = false;
194
+ // Resolves when the write stream backing the file part has flushed and
195
+ // closed. busboy's `close` can fire before fs has finished writing the
196
+ // last chunk to disk, so we must await this before renaming the .tmp.
197
+ let writeDone = Promise.resolve();
198
+ // Single-shot response. We only ever respond to the request once even
199
+ // though multiple busboy callbacks could converge on the same outcome
200
+ // (e.g. file-too-large + close-after-finish).
201
+ const respond = (status, body) => {
202
+ if (resolved)
203
+ return;
204
+ resolved = true;
205
+ json(res, status, body);
206
+ };
207
+ const cleanupTmp = async () => {
208
+ if (tmpPath) {
209
+ await unlink(tmpPath).catch(() => { });
210
+ tmpPath = null;
211
+ }
212
+ };
213
+ return new Promise((resolveOuter) => {
214
+ const finish = async (status, body) => {
215
+ respond(status, body);
216
+ resolveOuter();
217
+ };
218
+ let bb;
219
+ try {
220
+ bb = busboy({
221
+ headers: req.headers,
222
+ defParamCharset: "utf8",
223
+ limits: {
224
+ // We enforce the size cap manually via per-file byte tracking
225
+ // (so we can pick the right cap based on classified kind and
226
+ // emit a 413 the moment we cross the line). Cap files = 1
227
+ // and field count = 16 as belt-and-suspenders.
228
+ files: 1,
229
+ fields: 16,
230
+ fieldNameSize: 100,
231
+ fieldSize: 1024,
232
+ },
233
+ });
234
+ }
235
+ catch {
236
+ void finish(400, { error: "Invalid multipart" });
237
+ return;
238
+ }
239
+ bb.on("file", (fieldName, stream, info) => {
240
+ if (sawFile) {
241
+ // Extra file part — drain and ignore (busboy `files: 1` should
242
+ // already prevent this, but be defensive).
243
+ stream.resume();
244
+ return;
245
+ }
246
+ sawFile = true;
247
+ if (fieldName !== "file") {
248
+ stream.resume();
249
+ void finish(400, { error: "Unexpected field name" });
250
+ return;
251
+ }
252
+ fileMime = (info.mimeType || "application/octet-stream").toLowerCase();
253
+ kind = classifyKind(fileMime);
254
+ limit = kind === "image" ? limits.image_upload : fileUploadLimit;
255
+ fileExt = mimeToExt(fileMime);
256
+ displayName = info.filename ? normalizeDisplayName(info.filename) : null;
257
+ displayName ??= kind === "image" ? "image" : "file";
258
+ tmpPath = join(dir, `${uploadId}.${fileExt}.tmp`);
259
+ finalPath = join(dir, `${uploadId}.${fileExt}`);
260
+ const ws = createWriteStream(tmpPath);
261
+ writeDone = new Promise((resolveWs) => {
262
+ ws.on("close", () => {
263
+ resolveWs();
264
+ });
265
+ });
266
+ stream.on("data", (chunk) => {
267
+ bytesWritten += chunk.length;
268
+ if (bytesWritten > limit) {
269
+ limitExceeded = true;
270
+ stream.unpipe(ws);
271
+ ws.destroy();
272
+ stream.resume();
273
+ // Abort the whole busboy pipeline. We respond on `close`.
274
+ req.unpipe(bb);
275
+ req.resume();
276
+ }
277
+ });
278
+ stream.on("error", (err) => {
279
+ writeError = err;
280
+ });
281
+ ws.on("error", (err) => {
282
+ writeError = err;
283
+ });
284
+ stream.pipe(ws);
285
+ });
286
+ bb.on("error", (err) => {
287
+ writeError = err instanceof Error ? err : new Error(String(err));
288
+ });
289
+ req.on("aborted", () => {
290
+ aborted = true;
291
+ });
292
+ bb.on("close", () => {
293
+ void (async () => {
294
+ try {
295
+ await writeDone;
296
+ if (aborted) {
297
+ await cleanupTmp();
298
+ await finish(400, { error: "Upload aborted" });
299
+ return;
300
+ }
301
+ if (limitExceeded) {
302
+ await cleanupTmp();
303
+ await finish(413, { error: "Upload too large" });
304
+ return;
305
+ }
306
+ if (writeError) {
307
+ await cleanupTmp();
308
+ await finish(500, { error: "Upload failed" });
309
+ return;
310
+ }
311
+ if (!sawFile || !tmpPath || !finalPath || !displayName) {
312
+ await cleanupTmp();
313
+ await finish(400, { error: "Missing file part" });
314
+ return;
315
+ }
316
+ // Sniff the actual mime from file content (magic bytes + UTF-8
317
+ // text fallback). Clients lie about Content-Type — browsers send
318
+ // application/octet-stream for any extension the OS doesn't know
319
+ // (.clj, .lua, .rs, ...), and ACP agents (Copilot CLI) refuse to
320
+ // read attachments tagged octet-stream. The sniffed mime wins
321
+ // silently; we override fileMime / kind / extension / final path
322
+ // before insertAttachment so DB and disk reflect reality.
323
+ const head = await readFile(tmpPath).catch(() => Buffer.alloc(0));
324
+ const headSlice = head.subarray(0, 4096);
325
+ const sniffed = await sniffMime(headSlice);
326
+ if (sniffed !== fileMime) {
327
+ fileMime = sniffed;
328
+ kind = classifyKind(fileMime);
329
+ const newExt = mimeToExt(fileMime);
330
+ if (newExt !== fileExt) {
331
+ fileExt = newExt;
332
+ const newTmp = join(dir, `${uploadId}.${fileExt}.tmp`);
333
+ if (newTmp !== tmpPath) {
334
+ await rename(tmpPath, newTmp);
335
+ tmpPath = newTmp;
336
+ }
337
+ finalPath = join(dir, `${uploadId}.${fileExt}`);
338
+ }
339
+ }
340
+ const imageDimensions = kind === "image" ? readImageDimensions(head) : null;
341
+ await rename(tmpPath, finalPath);
342
+ const rp = await realpath(finalPath);
343
+ const row = store.insertAttachment({
344
+ id: uploadId,
345
+ sessionId,
346
+ kind,
347
+ name: displayName,
348
+ mime: fileMime,
349
+ size: bytesWritten,
350
+ realpath: rp,
351
+ width: imageDimensions?.width ?? null,
352
+ height: imageDimensions?.height ?? null,
353
+ });
354
+ // Invalidate the per-session attachment label cache so the
355
+ // next egress (SSE broadcast or replay) sees this new row.
356
+ sessions?.invalidateLabelCache(sessionId);
357
+ const fileName = `${row.id}.${fileExt}`;
358
+ const basePath = `/api/v1/sessions/${sessionId}/attachments/${fileName}`;
359
+ // 1h signed URL — long enough that the browser holds the rendered
360
+ // image in <img> cache for the full session lifetime, short enough
361
+ // that a leaked URL (screenshot, link share) expires within the day.
362
+ const fileUrl = deps.attachmentSecret
363
+ ? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
364
+ : basePath;
365
+ await finish(200, {
366
+ attachmentId: row.id,
367
+ displayName: row.name,
368
+ mimeType: row.mime,
369
+ size: row.size,
370
+ width: row.width,
371
+ height: row.height,
372
+ kind: row.kind,
373
+ path: `sessions/${sessionId}/attachments/${fileName}`,
374
+ url: fileUrl,
375
+ });
376
+ }
377
+ catch (err) {
378
+ await cleanupTmp();
379
+ await finish(500, { error: errorMessage(err) });
380
+ }
381
+ })();
382
+ });
383
+ req.pipe(bb);
384
+ });
385
+ }
48
386
  export function createRequestHandler(deps) {
49
387
  const { store, sessions, getBridge, sseManager, titleService } = deps;
388
+ // eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
50
389
  return async (req, res) => {
51
390
  const url = req.url ?? "/";
391
+ // --- Auth gate: any /api/** outside whitelist requires Bearer ---
392
+ if (deps.authStore && url.startsWith("/api/")) {
393
+ const path = url.split("?")[0] ?? url;
394
+ const method = req.method ?? "GET";
395
+ if (!isWhitelistedPath(method, path)) {
396
+ const result = authenticate(req.headers, deps.authStore);
397
+ if (!result.ok) {
398
+ res.writeHead(401, {
399
+ "Content-Type": "application/json",
400
+ "WWW-Authenticate": "Bearer",
401
+ });
402
+ res.end(JSON.stringify({ error: "Unauthorized", reason: result.reason }));
403
+ return;
404
+ }
405
+ principalByRequest.set(req, result.principal);
406
+ }
407
+ }
408
+ // Share routes — early dispatch so /s/* and /api/v1/shares* claim
409
+ // their URL space before the generic /api/v1 branch. When
410
+ // `shareConfig.enabled === false` handleShareRoutes is a no-op.
411
+ // The auth gate above has already enforced Bearer on owner endpoints
412
+ // (/api/v1/sessions/:id/share*, /api/v1/shares); viewer endpoints
413
+ // (/s/:token, /api/v1/shared/:token/events) must be whitelisted in
414
+ // auth-middleware.ts so they remain public.
415
+ if (deps.shareConfig &&
416
+ (await handleShareRoutes(req, res, {
417
+ store,
418
+ sessions,
419
+ config: deps.shareConfig,
420
+ dataDir: deps.dataDir,
421
+ publicDir: deps.publicDir,
422
+ }))) {
423
+ return;
424
+ }
52
425
  // --- API routes ---
53
426
  if (url === "/api/v1" || url.startsWith("/api/v1/")) {
54
427
  res.setHeader("Content-Type", "application/json");
@@ -69,7 +442,9 @@ export function createRequestHandler(deps) {
69
442
  return;
70
443
  }
71
444
  // GET /api/v1/sessions
72
- if (url.startsWith("/api/v1/sessions") && !url.slice("/api/v1/sessions".length).match(/^\//) && req.method === "GET") {
445
+ if (url.startsWith("/api/v1/sessions") &&
446
+ !url.slice("/api/v1/sessions".length).match(/^\//) &&
447
+ req.method === "GET") {
73
448
  const params = new URLSearchParams(url.split("?")[1] ?? "");
74
449
  const source = params.get("source") ?? undefined;
75
450
  res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
@@ -105,6 +480,136 @@ export function createRequestHandler(deps) {
105
480
  });
106
481
  return;
107
482
  }
483
+ // GET /api/v1/auth/verify — token validation probe
484
+ if (url === "/api/v1/auth/verify" && req.method === "GET") {
485
+ const principal = principalByRequest.get(req);
486
+ if (!principal) {
487
+ json(res, 401, { error: "Unauthorized" });
488
+ return;
489
+ }
490
+ json(res, 200, {
491
+ ok: true,
492
+ name: principal.name,
493
+ scope: principal.scope,
494
+ });
495
+ return;
496
+ }
497
+ // POST /api/v1/sse-ticket — mint short-lived ticket for EventSource
498
+ if (url === "/api/v1/sse-ticket" && req.method === "POST") {
499
+ const principal = principalByRequest.get(req);
500
+ if (!principal) {
501
+ json(res, 401, { error: "Unauthorized" });
502
+ return;
503
+ }
504
+ if (!deps.ticketStore) {
505
+ json(res, 501, { error: "SSE not available" });
506
+ return;
507
+ }
508
+ const ticket = deps.ticketStore.mint({
509
+ tokenName: principal.name,
510
+ scope: principal.scope,
511
+ });
512
+ json(res, 200, { ticket, expiresIn: 60 });
513
+ return;
514
+ }
515
+ // --- Token management (admin scope) ---
516
+ // GET /api/v1/tokens — list tokens.
517
+ // admin: full list. api: own token only (self-row, so the slash
518
+ // menu can show the user's own metadata without leaking peers).
519
+ if (url === "/api/v1/tokens" && req.method === "GET") {
520
+ const principal = principalByRequest.get(req);
521
+ if (!deps.authStore || !principal) {
522
+ json(res, 401, { error: "Unauthorized" });
523
+ return;
524
+ }
525
+ const all = deps.authStore.list();
526
+ const visible = principal.scope === "admin"
527
+ ? all
528
+ : all.filter((t) => t.name === principal.name);
529
+ const list = visible.map((t) => ({
530
+ name: t.name,
531
+ scope: t.scope,
532
+ createdAt: t.createdAt,
533
+ lastUsedAt: t.lastUsedAt,
534
+ isSelf: t.name === principal.name,
535
+ }));
536
+ json(res, 200, list);
537
+ return;
538
+ }
539
+ // POST /api/v1/tokens — create new api-scope token, return raw value once
540
+ if (url === "/api/v1/tokens" && req.method === "POST") {
541
+ const principal = principalByRequest.get(req);
542
+ if (!deps.authStore || !principal) {
543
+ json(res, 401, { error: "Unauthorized" });
544
+ return;
545
+ }
546
+ if (principal.scope !== "admin") {
547
+ json(res, 403, { error: "Forbidden" });
548
+ return;
549
+ }
550
+ let body;
551
+ try {
552
+ body = JSON.parse(await readBody(req));
553
+ }
554
+ catch {
555
+ json(res, 400, { error: "Invalid JSON" });
556
+ return;
557
+ }
558
+ const name = typeof body.name === "string" ? body.name : "";
559
+ try {
560
+ const created = await deps.authStore.addToken(name, "api");
561
+ json(res, 201, {
562
+ token: created.token,
563
+ name: created.record.name,
564
+ scope: created.record.scope,
565
+ });
566
+ }
567
+ catch (err) {
568
+ const msg = errorMessage(err);
569
+ if (/already exists|duplicate/i.test(msg)) {
570
+ json(res, 409, { error: msg });
571
+ }
572
+ else {
573
+ json(res, 400, { error: msg });
574
+ }
575
+ }
576
+ return;
577
+ }
578
+ // DELETE /api/v1/tokens/:name — revoke
579
+ const tokenDelMatch = url.match(/^\/api\/v1\/tokens\/([^/?]+)\/?$/);
580
+ if (tokenDelMatch && req.method === "DELETE") {
581
+ const principal = principalByRequest.get(req);
582
+ if (!deps.authStore || !principal) {
583
+ json(res, 401, { error: "Unauthorized" });
584
+ return;
585
+ }
586
+ if (principal.scope !== "admin") {
587
+ json(res, 403, { error: "Forbidden" });
588
+ return;
589
+ }
590
+ const name = decodeURIComponent(tokenDelMatch[1]);
591
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
592
+ json(res, 400, { error: "Invalid token name" });
593
+ return;
594
+ }
595
+ if (name === principal.name) {
596
+ json(res, 400, { error: "Cannot revoke the token you are using" });
597
+ return;
598
+ }
599
+ try {
600
+ const ok = await deps.authStore.revokeToken(name);
601
+ if (!ok) {
602
+ json(res, 404, { error: "Token not found" });
603
+ return;
604
+ }
605
+ res.writeHead(204);
606
+ res.end();
607
+ }
608
+ catch (err) {
609
+ json(res, 400, { error: errorMessage(err) });
610
+ }
611
+ return;
612
+ }
108
613
  // --- POST /api/v1/bridge/reload ---
109
614
  if (url === "/api/v1/bridge/reload" && req.method === "POST") {
110
615
  const bridge = getBridge?.();
@@ -139,6 +644,9 @@ export function createRequestHandler(deps) {
139
644
  if (permActionMatch && req.method === "POST") {
140
645
  const sessionId = decodeURIComponent(permActionMatch[1]);
141
646
  const requestId = decodeURIComponent(permActionMatch[2]);
647
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
648
+ if (replayed)
649
+ return;
142
650
  const perm = sessions?.pendingPermissions.get(requestId);
143
651
  if (!perm) {
144
652
  json(res, 404, { error: "Permission not found" });
@@ -165,25 +673,34 @@ export function createRequestHandler(deps) {
165
673
  json(res, 400, { error: "Provide optionId or denied:true" });
166
674
  return;
167
675
  }
168
- const denied = !!body.denied;
676
+ const denied = Boolean(body.denied);
169
677
  const optionId = body.optionId ?? "deny";
170
- const optionName = perm.options.find(o => o.optionId === optionId)?.label ?? optionId;
678
+ const optionName = perm.options.find((o) => o.optionId === optionId)?.label ?? optionId;
171
679
  if (denied) {
172
- await bridge.denyPermission(requestId);
680
+ bridge.denyPermission(requestId);
173
681
  }
174
682
  else {
175
- await bridge.resolvePermission(requestId, optionId);
683
+ bridge.resolvePermission(requestId, optionId);
176
684
  }
177
685
  sessions.pendingPermissions.delete(requestId);
686
+ sessions.syncPendingPermissions(sessionId);
178
687
  // Store event and broadcast (same type so SSE drops are recoverable via sync)
179
688
  const permEventData = { requestId, optionName, denied };
180
- store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId });
689
+ store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
181
690
  sseManager.broadcast({
182
691
  type: "permission_response",
183
692
  sessionId: perm.sessionId,
184
693
  ...permEventData,
185
694
  });
186
- json(res, 200, { ok: true });
695
+ // Cross-device banner recall: close the permission banner on
696
+ // every subscribed endpoint now that the permission has been
697
+ // handled by this client.
698
+ if (deps.pushService) {
699
+ void deps.pushService.sendClose(`sess-${perm.sessionId}-perm-${requestId}`);
700
+ }
701
+ const okBody = { ok: true };
702
+ saveClientOpResult(store, opId, sessionId, 200, okBody);
703
+ json(res, 200, okBody);
187
704
  return;
188
705
  }
189
706
  // --- POST /api/v1/sessions/:id/cancel ---
@@ -200,6 +717,9 @@ export function createRequestHandler(deps) {
200
717
  json(res, 503, { error: "Agent not ready yet" });
201
718
  return;
202
719
  }
720
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
721
+ if (replayed)
722
+ return;
203
723
  // Kill running bash process if any
204
724
  const proc = sessions?.runningBashProcs.get(sessionId);
205
725
  if (proc) {
@@ -211,7 +731,16 @@ export function createRequestHandler(deps) {
211
731
  await bridge.cancel(sessionId);
212
732
  sessions.activePrompts.delete(sessionId);
213
733
  }
214
- json(res, 200, { ok: true });
734
+ // Arm backend safety net: if prompt_done doesn't arrive within the
735
+ // configured timeout, force-clear busy so the UI unstalls. Replaces
736
+ // the old frontend-side cancel timer.
737
+ const cancelTimeout = deps.limits.cancel_timeout ?? 0;
738
+ if (sessions && cancelTimeout > 0)
739
+ sessions.state.armCancelSafety(sessionId, cancelTimeout);
740
+ sessions?.syncBusy(sessionId);
741
+ const okBody = { ok: true };
742
+ saveClientOpResult(store, opId, sessionId, 200, okBody);
743
+ json(res, 200, okBody);
215
744
  return;
216
745
  }
217
746
  // --- GET /api/v1/sessions/:id/status ---
@@ -232,35 +761,112 @@ export function createRequestHandler(deps) {
232
761
  });
233
762
  return;
234
763
  }
764
+ // --- GET /api/v1/sessions/:id/snapshot ---
765
+ // client-server-split M1: single source of truth for "what state is
766
+ // this session in right now". Frontend calls this on connect / reconnect
767
+ // / after long backgrounding, then applies incremental `state_patch`
768
+ // SSE events.
769
+ const snapshotMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/snapshot\/?$/);
770
+ if (snapshotMatch && req.method === "GET") {
771
+ const sessionId = decodeURIComponent(snapshotMatch[1]);
772
+ const session = store.getSession(sessionId);
773
+ if (!session) {
774
+ json(res, 404, { error: "Session not found" });
775
+ return;
776
+ }
777
+ if (!sessions) {
778
+ json(res, 503, { error: "Session manager not available" });
779
+ return;
780
+ }
781
+ // Make sure runtime reflects the current activePrompts/bash state even
782
+ // if no patch has been emitted yet for this session.
783
+ sessions.syncBusy(sessionId);
784
+ sessions.syncPendingPermissions(sessionId);
785
+ const runtimeState = sessions.state.getState(sessionId);
786
+ const lastEventSeq = store.getLastEventSeq(sessionId);
787
+ json(res, 200, {
788
+ version: 1,
789
+ seq: runtimeState.seq,
790
+ session: {
791
+ id: session.id,
792
+ title: session.title,
793
+ cwd: session.cwd,
794
+ model: session.model,
795
+ mode: session.mode,
796
+ createdAt: session.created_at,
797
+ lastEventSeq,
798
+ },
799
+ runtime: runtimeState.runtime,
800
+ }, req);
801
+ return;
802
+ }
235
803
  // --- POST /api/v1/sessions/:id/prompt ---
236
804
  const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
237
805
  if (promptMatch && req.method === "POST") {
238
806
  const sessionId = decodeURIComponent(promptMatch[1]);
807
+ const requestOpId = getClientOpId(req);
239
808
  const session = store.getSession(sessionId);
240
809
  if (!session) {
810
+ logPromptRejectBeforeSave({
811
+ sessionId,
812
+ status: 404,
813
+ reason: "session_not_found",
814
+ opId: requestOpId,
815
+ });
241
816
  json(res, 404, { error: "Session not found" });
242
817
  return;
243
818
  }
244
819
  const bridge = getBridge?.();
245
820
  if (!bridge) {
821
+ logPromptRejectBeforeSave({
822
+ sessionId,
823
+ status: 503,
824
+ reason: "agent_not_ready",
825
+ opId: requestOpId,
826
+ });
246
827
  json(res, 503, { error: "Agent not ready yet" });
247
828
  return;
248
829
  }
249
830
  if (!sessions) {
831
+ logPromptRejectBeforeSave({
832
+ sessionId,
833
+ status: 503,
834
+ reason: "session_manager_unavailable",
835
+ opId: requestOpId,
836
+ });
250
837
  json(res, 503, { error: "Session manager not available" });
251
838
  return;
252
839
  }
840
+ const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
841
+ if (replayed)
842
+ return;
253
843
  // Ensure session is live in ACP before prompting (awaits in-flight resume)
254
844
  try {
255
845
  await sessions.ensureResumed(bridge, sessionId);
256
846
  }
257
847
  catch (err) {
258
- json(res, 500, { error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}` });
848
+ logPromptRejectBeforeSave({
849
+ sessionId,
850
+ status: 500,
851
+ reason: "resume_failed",
852
+ opId,
853
+ error: errorMessage(err),
854
+ });
855
+ json(res, 500, {
856
+ error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
857
+ });
259
858
  return;
260
859
  }
261
860
  // Check if session is busy
262
861
  const busyKind = sessions.getBusyKind(sessionId);
263
862
  if (busyKind) {
863
+ logPromptRejectBeforeSave({
864
+ sessionId,
865
+ status: 409,
866
+ reason: "session_busy",
867
+ opId,
868
+ busyKind,
869
+ });
264
870
  json(res, 409, { error: "Session is busy", busyKind });
265
871
  return;
266
872
  }
@@ -269,35 +875,152 @@ export function createRequestHandler(deps) {
269
875
  body = JSON.parse(await readBody(req));
270
876
  }
271
877
  catch {
878
+ logPromptRejectBeforeSave({
879
+ sessionId,
880
+ status: 400,
881
+ reason: "invalid_json",
882
+ opId,
883
+ });
272
884
  json(res, 400, { error: "Invalid JSON" });
273
885
  return;
274
886
  }
275
887
  if (!body.text) {
888
+ logPromptRejectBeforeSave({
889
+ sessionId,
890
+ status: 400,
891
+ reason: "missing_text",
892
+ opId,
893
+ textLength: 0,
894
+ attachmentCount: Array.isArray(body.attachments)
895
+ ? body.attachments.length
896
+ : undefined,
897
+ });
276
898
  json(res, 400, { error: "Missing required field: text" });
277
899
  return;
278
900
  }
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 }) });
901
+ // Validate attachment shape: client must NEVER supply uri/data/path,
902
+ // only the four canonical fields. Anything else 400 immediately so
903
+ // we don't even hand it to the dispatcher fallback.
904
+ const attachments = body.attachments;
905
+ if (attachments) {
906
+ if (!Array.isArray(attachments)) {
907
+ logPromptRejectBeforeSave({
908
+ sessionId,
909
+ status: 400,
910
+ reason: "attachments_not_array",
911
+ opId,
912
+ textLength: body.text.length,
913
+ });
914
+ json(res, 400, { error: "attachments must be an array" });
915
+ return;
916
+ }
917
+ for (const raw of attachments) {
918
+ const att = raw;
919
+ if (!att ||
920
+ typeof att !== "object" ||
921
+ (att.kind !== "image" && att.kind !== "file") ||
922
+ typeof att.attachmentId !== "string" ||
923
+ typeof att.displayName !== "string" ||
924
+ typeof att.mimeType !== "string") {
925
+ logPromptRejectBeforeSave({
926
+ sessionId,
927
+ status: 400,
928
+ reason: "invalid_attachment_entry",
929
+ opId,
930
+ textLength: body.text.length,
931
+ attachmentCount: attachments.length,
932
+ });
933
+ json(res, 400, { error: "Invalid attachment entry" });
934
+ return;
935
+ }
936
+ if (typeof att.uri === "string" ||
937
+ typeof att.data === "string" ||
938
+ typeof att.path === "string" ||
939
+ typeof att.width === "number" ||
940
+ typeof att.height === "number") {
941
+ logPromptRejectBeforeSave({
942
+ sessionId,
943
+ status: 400,
944
+ reason: "client_supplied_attachment_data",
945
+ opId,
946
+ textLength: body.text.length,
947
+ attachmentCount: attachments.length,
948
+ });
949
+ json(res, 400, {
950
+ error: "Client must not supply uri/data/path/width/height",
951
+ });
952
+ return;
953
+ }
954
+ }
955
+ }
956
+ // Stored shape mirrors the wire shape PLUS a server-derived `path`
957
+ // for renderers. The path is the unsigned base URL
958
+ // (`/api/v1/sessions/<sid>/attachments/<filename>`); reSign on
959
+ // egress (history GET + SSE broadcast) appends a fresh `?sig=&exp=`.
960
+ // Renderers use it to mount `<img>` (kind=image) or `<a>` (kind=file).
961
+ // Refs whose attachment row is missing are dropped (defense — the
962
+ // dispatcher's [attachment removed] fallback covers that case).
963
+ const storedAttachments = attachments?.flatMap((a) => {
964
+ const row = store.getAttachment(sessionId, a.attachmentId);
965
+ if (!row)
966
+ return [];
967
+ const fileName = basename(row.realpath);
968
+ return [
969
+ {
970
+ kind: a.kind,
971
+ attachmentId: a.attachmentId,
972
+ displayName: a.displayName,
973
+ mimeType: a.mimeType,
974
+ path: `/api/v1/sessions/${sessionId}/attachments/${fileName}`,
975
+ ...(row.width != null && row.height != null
976
+ ? { width: row.width, height: row.height }
977
+ : {}),
978
+ },
979
+ ];
980
+ });
981
+ store.saveEvent(sessionId, "user_message", {
982
+ text: body.text,
983
+ ...(storedAttachments?.length
984
+ ? { attachments: storedAttachments }
985
+ : {}),
986
+ }, { from_ref: "user" });
282
987
  store.updateSessionLastActive(sessionId);
283
988
  store.touchRecentPath(session.cwd);
284
- const userMsgEvent = { type: "user_message", sessionId, text: body.text, images: storedImages };
989
+ const userMsgEvent = {
990
+ type: "user_message",
991
+ sessionId,
992
+ text: body.text,
993
+ attachments: storedAttachments,
994
+ };
285
995
  sseManager.broadcast(userMsgEvent);
286
996
  // Generate title (fire-and-forget)
287
- if (titleService && sessions && !sessions.sessionHasTitle.has(sessionId)) {
997
+ if (titleService &&
998
+ sessions && // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- optional dep
999
+ !sessions.sessionHasTitle.has(sessionId)) {
288
1000
  titleService.generate(bridge, body.text, sessionId, (title) => {
289
- const titleEvent = { type: "session_title_updated", sessionId, title };
1001
+ const titleEvent = {
1002
+ type: "session_title_updated",
1003
+ sessionId,
1004
+ title,
1005
+ };
290
1006
  sseManager.broadcast(titleEvent);
291
1007
  });
292
1008
  }
293
1009
  // Fire prompt asynchronously (don't await — response is 202)
294
1010
  sessions.activePrompts.add(sessionId);
295
- bridge.prompt(sessionId, body.text, body.images).catch((err) => {
296
- console.error(`[prompt] error for ${sessionId}:`, err);
297
- }).finally(() => {
1011
+ sessions.syncBusy(sessionId);
1012
+ bridge
1013
+ .prompt(sessionId, body.text, attachments)
1014
+ .catch((err) => {
1015
+ plog.error("error", { sessionId, error: err });
1016
+ })
1017
+ .finally(() => {
298
1018
  sessions.activePrompts.delete(sessionId);
1019
+ sessions.syncBusy(sessionId);
299
1020
  });
300
- json(res, 202, { status: "accepted" });
1021
+ const acceptedBody = { status: "accepted" };
1022
+ saveClientOpResult(store, opId, sessionId, 202, acceptedBody);
1023
+ json(res, 202, acceptedBody);
301
1024
  return;
302
1025
  }
303
1026
  // --- POST /api/v1/sessions/:id/bash ---
@@ -314,7 +1037,9 @@ export function createRequestHandler(deps) {
314
1037
  return;
315
1038
  }
316
1039
  if (sessions.runningBashProcs.has(sessionId)) {
317
- json(res, 409, { error: "A bash command is already running in this session" });
1040
+ json(res, 409, {
1041
+ error: "A bash command is already running in this session",
1042
+ });
318
1043
  return;
319
1044
  }
320
1045
  let body;
@@ -330,11 +1055,19 @@ export function createRequestHandler(deps) {
330
1055
  return;
331
1056
  }
332
1057
  const cwd = sessions.getSessionCwd(sessionId);
333
- store.saveEvent(sessionId, "bash_command", { command: body.command });
334
- const bashCmdEvent = { type: "bash_command", sessionId, command: body.command };
1058
+ store.saveEvent(sessionId, "bash_command", { command: body.command }, { from_ref: "user" });
1059
+ const bashCmdEvent = {
1060
+ type: "bash_command",
1061
+ sessionId,
1062
+ command: body.command,
1063
+ };
335
1064
  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];
1065
+ const shell = IS_WIN
1066
+ ? (process.env.COMSPEC ?? "cmd.exe")
1067
+ : (process.env.SHELL ?? "bash");
1068
+ const shellArgs = IS_WIN
1069
+ ? ["/s", "/c", body.command]
1070
+ : ["-c", body.command];
338
1071
  const child = spawn(shell, shellArgs, {
339
1072
  cwd,
340
1073
  detached: !IS_WIN,
@@ -342,6 +1075,7 @@ export function createRequestHandler(deps) {
342
1075
  stdio: ["ignore", "pipe", "pipe"],
343
1076
  });
344
1077
  sessions.runningBashProcs.set(sessionId, child);
1078
+ sessions.syncBusy(sessionId);
345
1079
  let output = "";
346
1080
  let outputTruncated = false;
347
1081
  const limit = deps.limits.bash_output;
@@ -357,23 +1091,41 @@ export function createRequestHandler(deps) {
357
1091
  else {
358
1092
  output = (output + text).slice(-limit);
359
1093
  }
360
- const bashOutEvent = { type: "bash_output", sessionId, text, stream };
1094
+ const bashOutEvent = {
1095
+ type: "bash_output",
1096
+ sessionId,
1097
+ text,
1098
+ stream,
1099
+ };
361
1100
  sseManager.broadcast(bashOutEvent);
362
1101
  };
363
1102
  child.stdout.on("data", onData("stdout"));
364
1103
  child.stderr.on("data", onData("stderr"));
365
1104
  child.on("close", (code, signal) => {
366
1105
  sessions.runningBashProcs.delete(sessionId);
1106
+ sessions.syncBusy(sessionId);
367
1107
  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 };
1108
+ store.saveEvent(sessionId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
1109
+ const bashDoneEvent = {
1110
+ type: "bash_done",
1111
+ sessionId,
1112
+ code,
1113
+ signal,
1114
+ };
370
1115
  sseManager.broadcast(bashDoneEvent);
371
1116
  });
372
1117
  child.on("error", (err) => {
373
1118
  sessions.runningBashProcs.delete(sessionId);
1119
+ sessions.syncBusy(sessionId);
374
1120
  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 };
1121
+ store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null }, { from_ref: "system" });
1122
+ const bashErrEvent = {
1123
+ type: "bash_done",
1124
+ sessionId,
1125
+ code: -1,
1126
+ signal: null,
1127
+ error: errMsg,
1128
+ };
377
1129
  sseManager.broadcast(bashErrEvent);
378
1130
  });
379
1131
  json(res, 202, { status: "accepted" });
@@ -425,12 +1177,23 @@ export function createRequestHandler(deps) {
425
1177
  for (const opt of configOptions) {
426
1178
  store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
427
1179
  }
428
- sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
429
- sseManager.broadcast({ type: "config_set", sessionId, configId, value: body.value });
1180
+ sseManager.broadcast({
1181
+ type: "config_option_update",
1182
+ sessionId,
1183
+ configOptions,
1184
+ });
1185
+ sseManager.broadcast({
1186
+ type: "config_set",
1187
+ sessionId,
1188
+ configId,
1189
+ value: body.value,
1190
+ });
430
1191
  json(res, 200, { configOptions });
431
1192
  }
432
1193
  catch (err) {
433
- json(res, 500, { error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}` });
1194
+ json(res, 500, {
1195
+ error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}`,
1196
+ });
434
1197
  }
435
1198
  return;
436
1199
  }
@@ -460,8 +1223,12 @@ export function createRequestHandler(deps) {
460
1223
  sessions.sessionHasTitle.add(sessionId);
461
1224
  const bridge = getBridge?.();
462
1225
  if (titleService && bridge)
463
- titleService.cancel(sessionId, bridge);
464
- const titleEvent = { type: "session_title_updated", sessionId, title: body.value };
1226
+ void titleService.cancel(sessionId, bridge);
1227
+ const titleEvent = {
1228
+ type: "session_title_updated",
1229
+ sessionId,
1230
+ title: body.value,
1231
+ };
465
1232
  sseManager.broadcast(titleEvent);
466
1233
  json(res, 200, { title: body.value });
467
1234
  return;
@@ -479,53 +1246,112 @@ export function createRequestHandler(deps) {
479
1246
  json(res, 404, { error: "Session not found" });
480
1247
  return;
481
1248
  }
482
- // Start resume in background (non-blocking) so the client gets metadata fast
1249
+ // Start resume in background (non-blocking) so the client gets metadata fast.
1250
+ // BUT: if cache is cold (cold-restart case), block on resume up to 8s so
1251
+ // configOptions returns inline. This eliminates the race where the
1252
+ // post-resume `config_option_update` broadcast can arrive before the
1253
+ // client's SSE is fully wired up — a flake observed on slow CI runners.
483
1254
  const wasLive = sessions?.liveSessions.has(sessionId) ?? true;
1255
+ let resumePromise = null;
484
1256
  if (sessions && getBridge && !wasLive) {
485
1257
  const bridge = getBridge();
486
1258
  if (bridge) {
487
- const resumePromise = sessions.ensureResumed(bridge, sessionId);
1259
+ resumePromise = sessions.ensureResumed(bridge, sessionId);
1260
+ // Broadcast config_option_update too, so any OTHER client viewing
1261
+ // the same session (whose own GET may have raced and returned cold)
1262
+ // also picks up the warm values.
1263
+ resumePromise
1264
+ .then(() => {
1265
+ const cur = store.getSession(sessionId);
1266
+ if (!cur || !sessions.cachedConfigOptions.length)
1267
+ return;
1268
+ const opts = sessions.cachedConfigOptions.map((opt) => {
1269
+ const stored = {
1270
+ model: cur.model,
1271
+ mode: cur.mode,
1272
+ reasoning_effort: cur.reasoning_effort,
1273
+ };
1274
+ const override = stored[opt.id];
1275
+ return override ? { ...opt, currentValue: override } : opt;
1276
+ });
1277
+ sseManager.broadcast({
1278
+ type: "config_option_update",
1279
+ sessionId,
1280
+ configOptions: opts,
1281
+ });
1282
+ })
1283
+ .catch(() => { });
488
1284
  // Auto-retry if the last turn was interrupted (must wait for resume)
489
1285
  const hasInterrupted = store.hasInterruptedTurn(sessionId);
490
1286
  if (hasInterrupted) {
491
1287
  // Optimistically mark busy so concurrent POST sees the session as active
492
1288
  sessions.activePrompts.add(sessionId);
493
- resumePromise.then(() => {
1289
+ sessions.syncBusy(sessionId);
1290
+ void resumePromise
1291
+ .then(() => {
494
1292
  if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
495
1293
  // Retry not needed after all — release the optimistic lock
496
1294
  sessions.activePrompts.delete(sessionId);
1295
+ sessions.syncBusy(sessionId);
497
1296
  }
498
- }).catch(() => {
1297
+ })
1298
+ .catch(() => {
499
1299
  sessions.activePrompts.delete(sessionId);
1300
+ sessions.syncBusy(sessionId);
500
1301
  });
501
1302
  }
502
1303
  else {
503
1304
  resumePromise.catch((err) => {
504
- console.error(`[session] background resume failed for ${sessionId.slice(0, 8)}…:`, err);
1305
+ slog.error("background resume failed", {
1306
+ sessionId: sessionId.slice(0, 8) + "…",
1307
+ error: err,
1308
+ });
505
1309
  });
506
1310
  }
507
1311
  }
508
1312
  }
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;
1313
+ // If cache is cold and we kicked off a resume, wait briefly so the
1314
+ // GET response includes warm configOptions. Bounded to 8s past that
1315
+ // we fall back to returning empty configOptions and rely on the
1316
+ // broadcast above. The frontend retries (re-fetches) on broadcast.
1317
+ if (resumePromise && !sessions?.cachedConfigOptions.length) {
1318
+ let timer = null;
1319
+ const timeoutPromise = new Promise((resolve) => {
1320
+ timer = setTimeout(resolve, 8000);
515
1321
  });
516
- return opts;
517
- })() : [];
518
- const busyKind = sessions?.getBusyKind(sessionId) ?? null;
1322
+ await Promise.race([
1323
+ resumePromise.finally(() => {
1324
+ if (timer)
1325
+ clearTimeout(timer);
1326
+ }),
1327
+ timeoutPromise,
1328
+ ]).catch(() => { });
1329
+ }
1330
+ // Re-read session in case resume mutated stored config
1331
+ const freshSession = store.getSession(sessionId) ?? session;
1332
+ const configOptions = sessions
1333
+ ? (() => {
1334
+ // Build configOptions from cached + stored overrides
1335
+ const opts = sessions.cachedConfigOptions.map((opt) => {
1336
+ const stored = {
1337
+ model: freshSession.model,
1338
+ mode: freshSession.mode,
1339
+ reasoning_effort: freshSession.reasoning_effort,
1340
+ };
1341
+ const override = stored[opt.id];
1342
+ return override ? { ...opt, currentValue: override } : opt;
1343
+ });
1344
+ return opts;
1345
+ })()
1346
+ : [];
519
1347
  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,
1348
+ id: freshSession.id,
1349
+ cwd: freshSession.cwd,
1350
+ title: freshSession.title,
1351
+ source: freshSession.source,
1352
+ model: freshSession.model,
1353
+ mode: freshSession.mode,
526
1354
  configOptions,
527
- busy: busyKind != null,
528
- busyKind,
529
1355
  }, req);
530
1356
  return;
531
1357
  }
@@ -582,7 +1408,11 @@ export function createRequestHandler(deps) {
582
1408
  // ACP's session_created event fires before inheritance runs, so
583
1409
  // broadcast final configOptions so SSE clients get the inherited values.
584
1410
  if (configOptions.length) {
585
- sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
1411
+ sseManager.broadcast({
1412
+ type: "config_option_update",
1413
+ sessionId,
1414
+ configOptions,
1415
+ });
586
1416
  }
587
1417
  json(res, 201, {
588
1418
  id: sessionId,
@@ -607,14 +1437,17 @@ export function createRequestHandler(deps) {
607
1437
  const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
608
1438
  if (eventsMatch && req.method === "GET") {
609
1439
  const sessionId = decodeURIComponent(eventsMatch[1]);
610
- const params = new URLSearchParams(eventsMatch[2]?.slice(1) ?? "");
1440
+ const queryPart = eventsMatch[2];
1441
+ const params = new URLSearchParams(queryPart ? queryPart.slice(1) : "");
611
1442
  const excludeThinking = params.get("thinking") === "0";
612
1443
  const afterRaw = params.get("after");
613
1444
  const afterSeq = afterRaw != null ? Number(afterRaw) : undefined;
614
1445
  const beforeRaw = params.get("before");
615
1446
  const beforeSeq = beforeRaw != null ? Number(beforeRaw) : undefined;
616
1447
  const limitRaw = params.get("limit");
617
- const limit = limitRaw != null ? Math.max(1, Math.min(10000, Number(limitRaw))) : undefined;
1448
+ const limit = limitRaw != null
1449
+ ? Math.max(1, Math.min(10000, Number(limitRaw)))
1450
+ : undefined;
618
1451
  const session = store.getSession(sessionId);
619
1452
  if (!session) {
620
1453
  json(res, 404, { error: "Session not found" });
@@ -635,15 +1468,45 @@ export function createRequestHandler(deps) {
635
1468
  sessions.flushAssistantBuffer(sessionId);
636
1469
  }
637
1470
  }
638
- const events = store.getEvents(sessionId, { excludeThinking, afterSeq, beforeSeq, limit });
1471
+ const events = store.getEvents(sessionId, {
1472
+ excludeThinking,
1473
+ afterSeq,
1474
+ beforeSeq,
1475
+ limit,
1476
+ });
1477
+ // Replace internal uuid attachment paths with `<name> [#<id4>]`
1478
+ // labels at egress (CLAUDE.md "Attachment label egress
1479
+ // rewrite"). DB rows still hold raw paths.
1480
+ if (sessions) {
1481
+ enrichStoredEventsForDisplay(events, sessions.getLabelMap(sessionId));
1482
+ }
1483
+ // Re-sign image URLs at egress so 1h-old stored URLs become valid
1484
+ // again — the user can reload history days later and images still
1485
+ // resolve. Mutates `data` in-place; safe because store.getEvents
1486
+ // returns fresh records.
1487
+ if (deps.attachmentSecret) {
1488
+ for (const ev of events) {
1489
+ if (typeof ev.data === "string" &&
1490
+ ev.data.includes("/attachments/")) {
1491
+ ev.data = reSignAttachmentUrlsInJson(ev.data, deps.attachmentSecret);
1492
+ }
1493
+ }
1494
+ }
639
1495
  const envelope = {
640
1496
  events,
641
- streaming: { thinking: streamingThinking, assistant: streamingAssistant },
1497
+ streaming: {
1498
+ thinking: streamingThinking,
1499
+ assistant: streamingAssistant,
1500
+ },
642
1501
  };
643
1502
  if (limit != null) {
644
1503
  const total = store.getEventCount(sessionId, { excludeThinking });
645
1504
  const hasMore = events.length > 0
646
- ? (store.getEvents(sessionId, { excludeThinking, beforeSeq: events[0].seq, limit: 1 }).length > 0)
1505
+ ? store.getEvents(sessionId, {
1506
+ excludeThinking,
1507
+ beforeSeq: events[0].seq,
1508
+ limit: 1,
1509
+ }).length > 0
647
1510
  : false;
648
1511
  envelope.total = total;
649
1512
  envelope.hasMore = hasMore;
@@ -654,32 +1517,54 @@ export function createRequestHandler(deps) {
654
1517
  // --- SSE stream endpoints ---
655
1518
  // GET /api/v1/events/stream — global SSE stream
656
1519
  if (url.startsWith("/api/v1/events/stream") && req.method === "GET") {
657
- if (!deps.sseManager) {
658
- json(res, 501, { error: "SSE not available" });
659
- return;
1520
+ // Ticket-based auth (EventSource cannot send Authorization header).
1521
+ // When authStore is configured, every SSE connection MUST present a
1522
+ // valid single-use ticket from POST /api/v1/sse-ticket.
1523
+ let tokenName;
1524
+ if (deps.authStore) {
1525
+ const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
1526
+ const principal = deps.ticketStore?.consume(ticket);
1527
+ if (!principal) {
1528
+ json(res, 401, { error: "Invalid or expired ticket" });
1529
+ return;
1530
+ }
1531
+ tokenName = principal.tokenName;
660
1532
  }
661
- const sseManager = deps.sseManager;
662
1533
  const clientId = sseManager.generateClientId();
663
1534
  res.writeHead(200, {
664
1535
  "Content-Type": "text/event-stream",
665
1536
  "Cache-Control": "no-cache",
666
- "Connection": "keep-alive",
1537
+ Connection: "keep-alive",
667
1538
  });
668
- const client = { id: clientId, res };
1539
+ const client = {
1540
+ id: clientId,
1541
+ res,
1542
+ tokenName,
1543
+ };
669
1544
  sseManager.add(client);
670
1545
  // Send connected event
671
- sseManager.sendEvent(client, { type: "connected", clientId });
1546
+ sseManager.sendEvent(client, {
1547
+ type: "connected",
1548
+ clientId,
1549
+ debugLevel: deps.debugLevel ?? "off",
1550
+ });
1551
+ sseManager.writeHeartbeat(client);
672
1552
  return;
673
1553
  }
674
1554
  // GET /api/v1/sessions/:id/events/stream — per-session SSE stream
675
1555
  const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
676
1556
  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
1557
  const sessionId = decodeURIComponent(sseSessionMatch[1]);
1558
+ let tokenName;
1559
+ if (deps.authStore) {
1560
+ const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
1561
+ const principal = deps.ticketStore?.consume(ticket);
1562
+ if (!principal) {
1563
+ json(res, 401, { error: "Invalid or expired ticket" });
1564
+ return;
1565
+ }
1566
+ tokenName = principal.tokenName;
1567
+ }
683
1568
  const session = store.getSession(sessionId);
684
1569
  if (!session) {
685
1570
  json(res, 404, { error: "Session not found" });
@@ -689,12 +1574,22 @@ export function createRequestHandler(deps) {
689
1574
  res.writeHead(200, {
690
1575
  "Content-Type": "text/event-stream",
691
1576
  "Cache-Control": "no-cache",
692
- "Connection": "keep-alive",
1577
+ Connection: "keep-alive",
693
1578
  });
694
- const client = { id: clientId, res, sessionId };
1579
+ const client = {
1580
+ id: clientId,
1581
+ res,
1582
+ sessionId,
1583
+ tokenName,
1584
+ };
695
1585
  sseManager.add(client);
696
1586
  // Send connected event
697
- sseManager.sendEvent(client, { type: "connected", clientId });
1587
+ sseManager.sendEvent(client, {
1588
+ type: "connected",
1589
+ clientId,
1590
+ debugLevel: deps.debugLevel ?? "off",
1591
+ });
1592
+ sseManager.writeHeartbeat(client);
698
1593
  // Replay events from Last-Event-ID if provided
699
1594
  const lastEventId = req.headers["last-event-id"];
700
1595
  if (lastEventId) {
@@ -703,7 +1598,10 @@ export function createRequestHandler(deps) {
703
1598
  const events = store.getEvents(sessionId, { afterSeq });
704
1599
  for (const evt of events) {
705
1600
  try {
706
- sseManager.sendEvent(client, { type: evt.type, ...JSON.parse(evt.data) }, evt.seq);
1601
+ sseManager.sendEvent(client, {
1602
+ type: evt.type,
1603
+ ...JSON.parse(evt.data),
1604
+ }, evt.seq);
707
1605
  }
708
1606
  catch {
709
1607
  // Skip malformed event data
@@ -713,69 +1611,75 @@ export function createRequestHandler(deps) {
713
1611
  }
714
1612
  return;
715
1613
  }
716
- // --- Images (session-scoped) ---
717
- // POST /api/v1/sessions/:id/images
718
- const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/images\/?$/);
1614
+ // --- Attachments (session-scoped) ---
1615
+ // POST /api/v1/sessions/:id/attachments — multipart/form-data upload.
1616
+ //
1617
+ // Wire format: a single `file` field. busboy streams chunks straight
1618
+ // to <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp; on close
1619
+ // we atomic-rename to the final name and insert an attachments row.
1620
+ // Aborts / mid-stream errors / oversize / wrong field name all leave
1621
+ // the .tmp removed before responding.
1622
+ const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/?$/);
719
1623
  if (imgUploadMatch && req.method === "POST") {
720
1624
  const sessionId = decodeURIComponent(imgUploadMatch[1]);
721
1625
  if (!SAFE_ID.test(sessionId)) {
722
1626
  json(res, 400, { error: "Invalid session ID" });
723
1627
  return;
724
1628
  }
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" });
729
- return;
730
- }
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" });
1629
+ const ctype = req.headers["content-type"] ?? "";
1630
+ if (!ctype.toLowerCase().startsWith("multipart/form-data")) {
1631
+ json(res, 400, { error: "Expected multipart/form-data" });
747
1632
  return;
748
1633
  }
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 });
1634
+ await handleAttachmentUpload(req, res, sessionId, deps);
759
1635
  return;
760
1636
  }
761
- // GET /api/v1/sessions/:id/images/:file
762
- const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/images\/([^/?]+)\/?$/);
1637
+ // GET /api/v1/sessions/:id/attachments/:file — serve a previously
1638
+ // uploaded attachment. Mime + displayName are looked up from the
1639
+ // attachments table so the response carries the original filename
1640
+ // (RFC 5987) and the per-mime inline/attachment disposition.
1641
+ const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
763
1642
  if (imgGetMatch && req.method === "GET") {
764
1643
  const sessionId = decodeURIComponent(imgGetMatch[1]);
765
1644
  const file = decodeURIComponent(imgGetMatch[2]);
766
- const filePath = join(deps.dataDir, "images", sessionId, file);
767
- if (!filePath.startsWith(join(deps.dataDir, "images"))) {
1645
+ // When secret is configured, GET requires sig+exp in query — there
1646
+ // is no Bearer fallback because <img src=...> / <a href=...> can't
1647
+ // carry headers. Verify before any disk I/O.
1648
+ if (deps.attachmentSecret) {
1649
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
1650
+ const sig = params.get("sig") ?? "";
1651
+ const exp = params.get("exp") ?? "";
1652
+ const basePath = `/api/v1/sessions/${sessionId}/attachments/${file}`;
1653
+ if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
1654
+ res.writeHead(401, { "Content-Type": "application/json" });
1655
+ res.end(JSON.stringify({ error: "Unauthorized" }));
1656
+ return;
1657
+ }
1658
+ }
1659
+ const filePath = join(deps.dataDir, "sessions", sessionId, "attachments", file);
1660
+ if (!filePath.startsWith(join(deps.dataDir, "sessions"))) {
768
1661
  res.writeHead(403);
769
1662
  res.end("Forbidden");
770
1663
  return;
771
1664
  }
1665
+ // Look up the row to recover the original mime + display name.
1666
+ // Pre-attachments-table uploads (none in v0.4+) would miss here;
1667
+ // we degrade gracefully to extension-based mime.
1668
+ const row = store.getAttachmentByFile(sessionId, file);
772
1669
  try {
773
1670
  const fileData = await readFile(filePath);
774
- const ext = extname(filePath);
775
- res.writeHead(200, {
776
- "Content-Type": MIME[ext] ?? "application/octet-stream",
1671
+ const mime = row?.mime ??
1672
+ (MIME[extname(filePath)] || "application/octet-stream");
1673
+ const headers = {
1674
+ "Content-Type": mime,
777
1675
  "Cache-Control": "public, max-age=31536000, immutable",
778
- });
1676
+ "X-Content-Type-Options": "nosniff",
1677
+ };
1678
+ if (row) {
1679
+ const disposition = isInlineMime(mime) ? "inline" : "attachment";
1680
+ headers["Content-Disposition"] = buildContentDisposition(disposition, row.name);
1681
+ }
1682
+ res.writeHead(200, headers);
779
1683
  res.end(fileData);
780
1684
  }
781
1685
  catch {
@@ -784,6 +1688,203 @@ export function createRequestHandler(deps) {
784
1688
  }
785
1689
  return;
786
1690
  }
1691
+ // --- Inbox messages (Stage B primitive) ---
1692
+ // POST /api/v1/messages — create ingress message
1693
+ if (url === "/api/v1/messages" && req.method === "POST") {
1694
+ // client-server-split M2: idempotency for the ingress message
1695
+ // creator. /messages has no real session id, so we scope the
1696
+ // cache under the synthetic key "__ingress__".
1697
+ const { opId, replayed } = tryReplayClientOp(req, res, store, "__ingress__");
1698
+ if (replayed)
1699
+ return;
1700
+ let raw;
1701
+ try {
1702
+ raw = await readBody(req);
1703
+ }
1704
+ catch {
1705
+ json(res, 400, { error: "Failed to read body" });
1706
+ return;
1707
+ }
1708
+ let parsed;
1709
+ try {
1710
+ parsed = JSON.parse(raw);
1711
+ }
1712
+ catch {
1713
+ json(res, 400, { error: "Invalid JSON" });
1714
+ return;
1715
+ }
1716
+ const validation = MessageIngressSchema.safeParse(parsed);
1717
+ if (!validation.success) {
1718
+ json(res, 400, {
1719
+ error: "Invalid body",
1720
+ issues: validation.error.issues,
1721
+ });
1722
+ return;
1723
+ }
1724
+ const input = validation.data;
1725
+ const id = `msg-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
1726
+ if (input.to.startsWith("session:")) {
1727
+ const targetSid = input.to.slice("session:".length);
1728
+ const session = store.getSession(targetSid);
1729
+ if (!session) {
1730
+ json(res, 400, { error: "session_not_found" });
1731
+ return;
1732
+ }
1733
+ sessions?.flushBuffers(targetSid);
1734
+ const data = {
1735
+ message_id: id,
1736
+ from_ref: input.from_ref,
1737
+ from_label: input.from_label ?? null,
1738
+ title: input.title,
1739
+ body: input.body,
1740
+ cwd: input.cwd ?? null,
1741
+ };
1742
+ store.saveEvent(targetSid, "message", data, {
1743
+ from_ref: input.from_ref,
1744
+ });
1745
+ sseManager.broadcast({
1746
+ type: "message",
1747
+ sessionId: targetSid,
1748
+ ...data,
1749
+ });
1750
+ if (deps.pushService) {
1751
+ void deps.pushService.sendForMessage({
1752
+ id,
1753
+ to: input.to,
1754
+ body: input.body,
1755
+ from_label: input.from_label,
1756
+ from_ref: input.from_ref,
1757
+ deliver: input.deliver,
1758
+ dedup_key: input.dedup_key ?? null,
1759
+ });
1760
+ }
1761
+ mlog.info("ingress bound", {
1762
+ msg_id: id,
1763
+ sess_id: targetSid.slice(0, 8),
1764
+ });
1765
+ const boundBody = { id, delivered: "session" };
1766
+ saveClientOpResult(store, opId, "__ingress__", 200, boundBody);
1767
+ json(res, 200, boundBody);
1768
+ return;
1769
+ }
1770
+ // Unbound: to=user → rows in `messages` table
1771
+ const dedupKey = input.dedup_key ?? null;
1772
+ if (dedupKey) {
1773
+ const prior = store.findBySupersede(input.to, dedupKey);
1774
+ if (prior) {
1775
+ store.deleteMessage(prior.id);
1776
+ mlog.info("dedup_key supersede", {
1777
+ to: input.to,
1778
+ dedup_key: dedupKey,
1779
+ old_msg_id: prior.id,
1780
+ new_msg_id: id,
1781
+ });
1782
+ }
1783
+ }
1784
+ store.createMessage({
1785
+ id,
1786
+ from_ref: input.from_ref,
1787
+ from_label: input.from_label ?? null,
1788
+ to_ref: input.to,
1789
+ deliver: input.deliver,
1790
+ dedup_key: dedupKey,
1791
+ title: input.title,
1792
+ body: input.body,
1793
+ cwd: input.cwd ?? null,
1794
+ created_at: Date.now(),
1795
+ });
1796
+ sseManager.broadcast({ type: "message_created", messageId: id });
1797
+ if (deps.pushService) {
1798
+ void deps.pushService.sendForMessage({
1799
+ id,
1800
+ to: input.to,
1801
+ body: input.body,
1802
+ from_label: input.from_label,
1803
+ from_ref: input.from_ref,
1804
+ deliver: input.deliver,
1805
+ dedup_key: dedupKey,
1806
+ });
1807
+ }
1808
+ mlog.info("ingress unbound", {
1809
+ msg_id: id,
1810
+ from_ref: input.from_ref,
1811
+ });
1812
+ const unboundBody = { id, delivered: "pending" };
1813
+ saveClientOpResult(store, opId, "__ingress__", 200, unboundBody);
1814
+ json(res, 200, unboundBody);
1815
+ return;
1816
+ }
1817
+ // GET /api/v1/messages — list unprocessed
1818
+ if (url === "/api/v1/messages" && req.method === "GET") {
1819
+ json(res, 200, { messages: store.listUnprocessed() });
1820
+ return;
1821
+ }
1822
+ // /api/v1/messages/:id... — GET single, POST :id/consume, POST :id/ack, DELETE :id
1823
+ if (url.startsWith("/api/v1/messages/")) {
1824
+ const tail = url.slice("/api/v1/messages/".length);
1825
+ const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
1826
+ if (consumeMatch && req.method === "POST") {
1827
+ const id = decodeURIComponent(consumeMatch[1]);
1828
+ const newSid = randomUUID();
1829
+ let out;
1830
+ try {
1831
+ out = store.consumeMessageTx(id, { sessionId: newSid });
1832
+ }
1833
+ catch (err) {
1834
+ if (/message not found/.test(errorMessage(err))) {
1835
+ json(res, 404, { error: "Message not found" });
1836
+ return;
1837
+ }
1838
+ throw err;
1839
+ }
1840
+ sseManager.broadcast({
1841
+ type: "message_consumed",
1842
+ messageId: id,
1843
+ sessionId: out.sessionId,
1844
+ });
1845
+ if (!out.alreadyConsumed && deps.pushService) {
1846
+ void deps.pushService.sendClose(id);
1847
+ }
1848
+ mlog.info("consume", {
1849
+ msg_id: id,
1850
+ sess_id: out.sessionId.slice(0, 8),
1851
+ already_consumed: out.alreadyConsumed,
1852
+ });
1853
+ json(res, 200, {
1854
+ sessionId: out.sessionId,
1855
+ alreadyConsumed: out.alreadyConsumed,
1856
+ });
1857
+ return;
1858
+ }
1859
+ const ackPost = tail.match(/^([^/?]+)\/ack\/?$/);
1860
+ const idOnly = tail.match(/^([^/?]+)\/?$/);
1861
+ const isAck = (ackPost !== null && req.method === "POST") ||
1862
+ (idOnly !== null && req.method === "DELETE");
1863
+ if (isAck) {
1864
+ const id = decodeURIComponent((ackPost ?? idOnly)[1]);
1865
+ const changes = store.deleteMessage(id);
1866
+ if (changes === 0) {
1867
+ json(res, 404, { error: "Message not found" });
1868
+ return;
1869
+ }
1870
+ sseManager.broadcast({ type: "message_acked", messageId: id });
1871
+ if (deps.pushService)
1872
+ void deps.pushService.sendClose(id);
1873
+ mlog.info("ack", { msg_id: id });
1874
+ json(res, 200, { ok: true });
1875
+ return;
1876
+ }
1877
+ if (idOnly && req.method === "GET") {
1878
+ const id = decodeURIComponent(idOnly[1]);
1879
+ const row = store.getMessage(id);
1880
+ if (!row) {
1881
+ json(res, 404, { error: "Message not found" });
1882
+ return;
1883
+ }
1884
+ json(res, 200, row);
1885
+ return;
1886
+ }
1887
+ }
787
1888
  json(res, 404, { error: "Not found" });
788
1889
  return;
789
1890
  }
@@ -814,34 +1915,45 @@ export function createRequestHandler(deps) {
814
1915
  json(res, 400, { error: "Missing required field: text" });
815
1916
  return;
816
1917
  }
817
- const cwd = body.cwd || undefined;
1918
+ const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
818
1919
  const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
819
1920
  const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
820
1921
  json(res, 202, { sessionId, streamUrl });
821
1922
  // Fire-and-forget: send the prompt asynchronously, tracking busy state
822
1923
  sessions.activePrompts.add(sessionId);
1924
+ sessions.syncBusy(sessionId);
823
1925
  // Generate title (fire-and-forget)
824
1926
  if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
825
1927
  titleService.generate(bridge, text, sessionId, (title) => {
826
- const titleEvent = { type: "session_title_updated", sessionId, title };
1928
+ const titleEvent = {
1929
+ type: "session_title_updated",
1930
+ sessionId,
1931
+ title,
1932
+ };
827
1933
  sseManager.broadcast(titleEvent);
828
1934
  });
829
1935
  }
830
- bridge.prompt(sessionId, text)
1936
+ bridge
1937
+ .prompt(sessionId, text)
831
1938
  .catch(() => { })
832
- .finally(() => sessions.activePrompts.delete(sessionId));
1939
+ .finally(() => {
1940
+ sessions.activePrompts.delete(sessionId);
1941
+ sessions.syncBusy(sessionId);
1942
+ });
833
1943
  return;
834
1944
  }
835
1945
  // POST /api/beta/clients/:clientId/visibility
836
1946
  const visMatch = url.match(/^\/api\/beta\/clients\/([^/]+)\/visibility$/);
837
1947
  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
1948
  const clientId = decodeURIComponent(visMatch[1]);
844
- if (!sseManager.clients.has(clientId)) {
1949
+ // Trust boundary: accept if the client is (a) currently connected
1950
+ // via SSE, or (b) known to the ClientRegistry (populated on /hello,
1951
+ // persists across SSE disconnect). Registry check fixes the
1952
+ // pagehide-beacon race where iOS PWA suspension drops the SSE TCP
1953
+ // connection before the beacon egresses.
1954
+ const clientKnown = sseManager.clients.has(clientId) ||
1955
+ deps.clientRegistry?.get(clientId) !== undefined;
1956
+ if (!clientKnown) {
845
1957
  json(res, 404, { error: "Client not found" });
846
1958
  return;
847
1959
  }
@@ -857,11 +1969,48 @@ export function createRequestHandler(deps) {
857
1969
  json(res, 400, { error: "Missing or invalid 'visible' field" });
858
1970
  return;
859
1971
  }
860
- // If push service is available, update visibility and session
861
- 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);
1972
+ // sessionId patch semantics: absent = preserve, null = clear,
1973
+ // string = replace. Zod can't distinguish omitted from explicit
1974
+ // null after parse, so branch on raw body key.
1975
+ const hasSessionIdKey = Object.prototype.hasOwnProperty.call(body, "sessionId");
1976
+ let sessionIdPatch;
1977
+ if (!hasSessionIdKey) {
1978
+ sessionIdPatch = undefined;
1979
+ }
1980
+ else if (body.sessionId === null) {
1981
+ sessionIdPatch = null;
1982
+ }
1983
+ else if (typeof body.sessionId === "string" &&
1984
+ body.sessionId.length > 0) {
1985
+ sessionIdPatch = body.sessionId;
1986
+ }
1987
+ else {
1988
+ sessionIdPatch = null;
1989
+ }
1990
+ if (deps.clientRegistry) {
1991
+ // setVisibility no-ops on unknown clients; auto-register here so
1992
+ // SSE-only clients (which haven't sent /hello yet) still take
1993
+ // effect. The trust boundary above already gated on identity.
1994
+ if (!deps.clientRegistry.get(clientId)) {
1995
+ deps.clientRegistry.register(clientId, { capabilities: [] });
1996
+ }
1997
+ const { becameVisibleFor } = deps.clientRegistry.setVisibility(clientId, {
1998
+ visible: body.visible,
1999
+ active: sessionIdPatch,
2000
+ });
2001
+ // Edge-triggered only: heartbeat refreshes repeat the same
2002
+ // (visible:true, sessionId:X) POST every 15s — firing sendClose
2003
+ // on each would hammer banner recall. Only the first such
2004
+ // transition after a change should recall stale banners.
2005
+ if (becameVisibleFor && deps.pushService) {
2006
+ void deps.pushService.sendClose(`sess-${becameVisibleFor}-done`);
2007
+ if (sessions) {
2008
+ for (const perm of sessions.pendingPermissions.values()) {
2009
+ if (perm.sessionId === becameVisibleFor) {
2010
+ void deps.pushService.sendClose(`sess-${becameVisibleFor}-perm-${perm.requestId}`);
2011
+ }
2012
+ }
2013
+ }
865
2014
  }
866
2015
  }
867
2016
  json(res, 200, { ok: true });
@@ -894,11 +2043,12 @@ export function createRequestHandler(deps) {
894
2043
  json(res, 400, { error: "Invalid JSON" });
895
2044
  return;
896
2045
  }
897
- if (!body.endpoint || !body.keys?.auth || !body.keys?.p256dh) {
2046
+ if (!body.endpoint || !body.keys?.auth || !body.keys.p256dh) {
898
2047
  json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
899
2048
  return;
900
2049
  }
901
2050
  store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
2051
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive
902
2052
  if (body.clientId && deps.pushService) {
903
2053
  deps.pushService.registerClient(body.clientId, body.endpoint);
904
2054
  }
@@ -957,7 +2107,11 @@ export function createRequestHandler(deps) {
957
2107
  return;
958
2108
  }
959
2109
  // --- Static files ---
960
- const filePath = join(deps.publicDir, url === "/" ? "/index.html" : url);
2110
+ let staticPath = url;
2111
+ const htmlEntry = HTML_ENTRYPOINTS.find((e) => e.urlPath === staticPath);
2112
+ if (htmlEntry)
2113
+ staticPath = "/" + htmlEntry.file;
2114
+ const filePath = join(deps.publicDir, staticPath);
961
2115
  if (!filePath.startsWith(deps.publicDir)) {
962
2116
  res.writeHead(403);
963
2117
  res.end("Forbidden");
@@ -966,7 +2120,23 @@ export function createRequestHandler(deps) {
966
2120
  try {
967
2121
  const data = await readFile(filePath);
968
2122
  const ext = extname(filePath);
969
- res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
2123
+ const base = filePath.slice(filePath.lastIndexOf("/") + 1);
2124
+ const isHashedAsset = /\.[A-Za-z0-9_-]{8,}\.(js|css)$/.test(base);
2125
+ // `/lib/**` is vendored third-party content pinned by upstream SHA in
2126
+ // package metadata — treat as immutable just like content-hashed bundles.
2127
+ // Avoids re-downloading multi-hundred-KB wasm on every consumer init.
2128
+ const isVendoredLib = staticPath.startsWith("/lib/");
2129
+ const cacheControl = isHashedAsset || isVendoredLib
2130
+ ? "public, max-age=31536000, immutable"
2131
+ : "no-cache";
2132
+ const headers = {
2133
+ "Content-Type": MIME[ext] ?? "application/octet-stream",
2134
+ "Cache-Control": cacheControl,
2135
+ };
2136
+ // CSP applies to HTML entrypoints (where script/style execute).
2137
+ if (htmlEntry)
2138
+ headers["Content-Security-Policy"] = CSP_POLICY;
2139
+ res.writeHead(200, headers);
970
2140
  res.end(data);
971
2141
  }
972
2142
  catch {