@lelouchhe/webagent 0.6.0 → 0.8.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.
package/lib/routes.js CHANGED
@@ -4,12 +4,13 @@ import { join, extname, basename } from "node:path";
4
4
  import { gzipSync } from "node:zlib";
5
5
  import busboy from "busboy";
6
6
  import { errorMessage, MessageIngressSchema } from "./types.js";
7
- import { interruptBashProc } from "./session-manager.js";
7
+ import { interruptBashProc, InvalidSessionDirectoryError, } from "./session-manager.js";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { createWriteStream } from "node:fs";
10
10
  import { handleShareRoutes } from "./share/routes.js";
11
11
  import { authenticate, isWhitelistedPath } from "./auth-middleware.js";
12
12
  import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
13
+ import { agentCommandToken, resolveAgentCommand } from "./agent-commands.js";
13
14
  import { log } from "./log.js";
14
15
  const rlog = log.scope("routes");
15
16
  const plog = rlog.scope("prompt");
@@ -18,8 +19,15 @@ const mlog = rlog.scope("msg");
18
19
  import { signAttachmentUrl, verifyAttachmentSig, reSignAttachmentUrlsInJson, } from "./auth.js";
19
20
  import { buildContentDisposition, classifyKind, isInlineMime, mimeToExt, normalizeDisplayName, sniffMime, } from "./attachments.js";
20
21
  import { readImageDimensions } from "./image-dimensions.js";
22
+ import { HTTP_STATUS } from "./http-status.js";
21
23
  const IS_WIN = process.platform === "win32";
22
24
  const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
25
+ function broadcastInboxCount(store, sseManager) {
26
+ sseManager.broadcastGlobal({
27
+ type: "inbox_count_changed",
28
+ pendingCount: store.countUnprocessed(),
29
+ });
30
+ }
23
31
  const MIME = {
24
32
  ".html": "text/html; charset=utf-8",
25
33
  ".js": "application/javascript; charset=utf-8",
@@ -172,7 +180,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
172
180
  const { store, dataDir, limits, sessions } = deps;
173
181
  const fileUploadLimit = limits.file_upload ?? 52_428_800;
174
182
  if (!store.getSession(sessionId)) {
175
- json(res, 404, { error: "Session not found" });
183
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
176
184
  return;
177
185
  }
178
186
  const dir = join(dataDir, "sessions", sessionId, "attachments");
@@ -233,7 +241,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
233
241
  });
234
242
  }
235
243
  catch {
236
- void finish(400, { error: "Invalid multipart" });
244
+ void finish(HTTP_STATUS.BAD_REQUEST, { error: "Invalid multipart" });
237
245
  return;
238
246
  }
239
247
  bb.on("file", (fieldName, stream, info) => {
@@ -246,7 +254,9 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
246
254
  sawFile = true;
247
255
  if (fieldName !== "file") {
248
256
  stream.resume();
249
- void finish(400, { error: "Unexpected field name" });
257
+ void finish(HTTP_STATUS.BAD_REQUEST, {
258
+ error: "Unexpected field name",
259
+ });
250
260
  return;
251
261
  }
252
262
  fileMime = (info.mimeType || "application/octet-stream").toLowerCase();
@@ -295,22 +305,28 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
295
305
  await writeDone;
296
306
  if (aborted) {
297
307
  await cleanupTmp();
298
- await finish(400, { error: "Upload aborted" });
308
+ await finish(HTTP_STATUS.BAD_REQUEST, { error: "Upload aborted" });
299
309
  return;
300
310
  }
301
311
  if (limitExceeded) {
302
312
  await cleanupTmp();
303
- await finish(413, { error: "Upload too large" });
313
+ await finish(HTTP_STATUS.PAYLOAD_TOO_LARGE, {
314
+ error: "Upload too large",
315
+ });
304
316
  return;
305
317
  }
306
318
  if (writeError) {
307
319
  await cleanupTmp();
308
- await finish(500, { error: "Upload failed" });
320
+ await finish(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
321
+ error: "Upload failed",
322
+ });
309
323
  return;
310
324
  }
311
325
  if (!sawFile || !tmpPath || !finalPath || !displayName) {
312
326
  await cleanupTmp();
313
- await finish(400, { error: "Missing file part" });
327
+ await finish(HTTP_STATUS.BAD_REQUEST, {
328
+ error: "Missing file part",
329
+ });
314
330
  return;
315
331
  }
316
332
  // Sniff the actual mime from file content (magic bytes + UTF-8
@@ -362,7 +378,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
362
378
  const fileUrl = deps.attachmentSecret
363
379
  ? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
364
380
  : basePath;
365
- await finish(200, {
381
+ await finish(HTTP_STATUS.OK, {
366
382
  attachmentId: row.id,
367
383
  displayName: row.name,
368
384
  mimeType: row.mime,
@@ -376,7 +392,9 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
376
392
  }
377
393
  catch (err) {
378
394
  await cleanupTmp();
379
- await finish(500, { error: errorMessage(err) });
395
+ await finish(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
396
+ error: errorMessage(err),
397
+ });
380
398
  }
381
399
  })();
382
400
  });
@@ -395,7 +413,7 @@ export function createRequestHandler(deps) {
395
413
  if (!isWhitelistedPath(method, path)) {
396
414
  const result = authenticate(req.headers, deps.authStore);
397
415
  if (!result.ok) {
398
- res.writeHead(401, {
416
+ res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
399
417
  "Content-Type": "application/json",
400
418
  "WWW-Authenticate": "Bearer",
401
419
  });
@@ -427,7 +445,7 @@ export function createRequestHandler(deps) {
427
445
  res.setHeader("Content-Type", "application/json");
428
446
  // GET /api/v1 — discovery endpoint
429
447
  if (url === "/api/v1" && req.method === "GET") {
430
- json(res, 200, {
448
+ json(res, HTTP_STATUS.OK, {
431
449
  version: "v1",
432
450
  endpoints: {
433
451
  sessions: "/api/v1/sessions",
@@ -452,7 +470,7 @@ export function createRequestHandler(deps) {
452
470
  }
453
471
  // --- GET /api/v1/config ---
454
472
  if (url === "/api/v1/config" && req.method === "GET") {
455
- json(res, 200, {
473
+ json(res, HTTP_STATUS.OK, {
456
474
  configOptions: sessions?.cachedConfigOptions ?? [],
457
475
  cancelTimeout: deps.limits.cancel_timeout ?? 0,
458
476
  recentPathsLimit: deps.limits.recent_paths ?? 10,
@@ -469,12 +487,12 @@ export function createRequestHandler(deps) {
469
487
  limit: isNaN(limit) ? 0 : limit,
470
488
  ttlDays,
471
489
  });
472
- json(res, 200, paths);
490
+ json(res, HTTP_STATUS.OK, paths);
473
491
  return;
474
492
  }
475
493
  // GET /api/v1/version
476
494
  if (url === "/api/v1/version" && req.method === "GET") {
477
- json(res, 200, {
495
+ json(res, HTTP_STATUS.OK, {
478
496
  server: deps.serverVersion ?? "unknown",
479
497
  agent: sessions?.agentInfo ?? null,
480
498
  });
@@ -484,10 +502,10 @@ export function createRequestHandler(deps) {
484
502
  if (url === "/api/v1/auth/verify" && req.method === "GET") {
485
503
  const principal = principalByRequest.get(req);
486
504
  if (!principal) {
487
- json(res, 401, { error: "Unauthorized" });
505
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
488
506
  return;
489
507
  }
490
- json(res, 200, {
508
+ json(res, HTTP_STATUS.OK, {
491
509
  ok: true,
492
510
  name: principal.name,
493
511
  scope: principal.scope,
@@ -498,18 +516,20 @@ export function createRequestHandler(deps) {
498
516
  if (url === "/api/v1/sse-ticket" && req.method === "POST") {
499
517
  const principal = principalByRequest.get(req);
500
518
  if (!principal) {
501
- json(res, 401, { error: "Unauthorized" });
519
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
502
520
  return;
503
521
  }
504
522
  if (!deps.ticketStore) {
505
- json(res, 501, { error: "SSE not available" });
523
+ json(res, HTTP_STATUS.NOT_IMPLEMENTED, {
524
+ error: "SSE not available",
525
+ });
506
526
  return;
507
527
  }
508
528
  const ticket = deps.ticketStore.mint({
509
529
  tokenName: principal.name,
510
530
  scope: principal.scope,
511
531
  });
512
- json(res, 200, { ticket, expiresIn: 60 });
532
+ json(res, HTTP_STATUS.OK, { ticket, expiresIn: 60 });
513
533
  return;
514
534
  }
515
535
  // --- Token management (admin scope) ---
@@ -519,7 +539,7 @@ export function createRequestHandler(deps) {
519
539
  if (url === "/api/v1/tokens" && req.method === "GET") {
520
540
  const principal = principalByRequest.get(req);
521
541
  if (!deps.authStore || !principal) {
522
- json(res, 401, { error: "Unauthorized" });
542
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
523
543
  return;
524
544
  }
525
545
  const all = deps.authStore.list();
@@ -533,18 +553,18 @@ export function createRequestHandler(deps) {
533
553
  lastUsedAt: t.lastUsedAt,
534
554
  isSelf: t.name === principal.name,
535
555
  }));
536
- json(res, 200, list);
556
+ json(res, HTTP_STATUS.OK, list);
537
557
  return;
538
558
  }
539
559
  // POST /api/v1/tokens — create new api-scope token, return raw value once
540
560
  if (url === "/api/v1/tokens" && req.method === "POST") {
541
561
  const principal = principalByRequest.get(req);
542
562
  if (!deps.authStore || !principal) {
543
- json(res, 401, { error: "Unauthorized" });
563
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
544
564
  return;
545
565
  }
546
566
  if (principal.scope !== "admin") {
547
- json(res, 403, { error: "Forbidden" });
567
+ json(res, HTTP_STATUS.FORBIDDEN, { error: "Forbidden" });
548
568
  return;
549
569
  }
550
570
  let body;
@@ -552,13 +572,13 @@ export function createRequestHandler(deps) {
552
572
  body = JSON.parse(await readBody(req));
553
573
  }
554
574
  catch {
555
- json(res, 400, { error: "Invalid JSON" });
575
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
556
576
  return;
557
577
  }
558
578
  const name = typeof body.name === "string" ? body.name : "";
559
579
  try {
560
580
  const created = await deps.authStore.addToken(name, "api");
561
- json(res, 201, {
581
+ json(res, HTTP_STATUS.CREATED, {
562
582
  token: created.token,
563
583
  name: created.record.name,
564
584
  scope: created.record.scope,
@@ -567,10 +587,10 @@ export function createRequestHandler(deps) {
567
587
  catch (err) {
568
588
  const msg = errorMessage(err);
569
589
  if (/already exists|duplicate/i.test(msg)) {
570
- json(res, 409, { error: msg });
590
+ json(res, HTTP_STATUS.CONFLICT, { error: msg });
571
591
  }
572
592
  else {
573
- json(res, 400, { error: msg });
593
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: msg });
574
594
  }
575
595
  }
576
596
  return;
@@ -580,33 +600,35 @@ export function createRequestHandler(deps) {
580
600
  if (tokenDelMatch && req.method === "DELETE") {
581
601
  const principal = principalByRequest.get(req);
582
602
  if (!deps.authStore || !principal) {
583
- json(res, 401, { error: "Unauthorized" });
603
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
584
604
  return;
585
605
  }
586
606
  if (principal.scope !== "admin") {
587
- json(res, 403, { error: "Forbidden" });
607
+ json(res, HTTP_STATUS.FORBIDDEN, { error: "Forbidden" });
588
608
  return;
589
609
  }
590
610
  const name = decodeURIComponent(tokenDelMatch[1]);
591
611
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
592
- json(res, 400, { error: "Invalid token name" });
612
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid token name" });
593
613
  return;
594
614
  }
595
615
  if (name === principal.name) {
596
- json(res, 400, { error: "Cannot revoke the token you are using" });
616
+ json(res, HTTP_STATUS.BAD_REQUEST, {
617
+ error: "Cannot revoke the token you are using",
618
+ });
597
619
  return;
598
620
  }
599
621
  try {
600
622
  const ok = await deps.authStore.revokeToken(name);
601
623
  if (!ok) {
602
- json(res, 404, { error: "Token not found" });
624
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Token not found" });
603
625
  return;
604
626
  }
605
- res.writeHead(204);
627
+ res.writeHead(HTTP_STATUS.NO_CONTENT);
606
628
  res.end();
607
629
  }
608
630
  catch (err) {
609
- json(res, 400, { error: errorMessage(err) });
631
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: errorMessage(err) });
610
632
  }
611
633
  return;
612
634
  }
@@ -614,19 +636,23 @@ export function createRequestHandler(deps) {
614
636
  if (url === "/api/v1/bridge/reload" && req.method === "POST") {
615
637
  const bridge = getBridge?.();
616
638
  if (!bridge) {
617
- json(res, 503, { error: "Agent not ready yet" });
639
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
640
+ error: "Agent not ready yet",
641
+ });
618
642
  return;
619
643
  }
620
644
  if (bridge.reloading) {
621
- json(res, 409, { error: "Already reloading" });
645
+ json(res, HTTP_STATUS.CONFLICT, { error: "Already reloading" });
622
646
  return;
623
647
  }
624
648
  try {
625
649
  await bridge.restart(sessions, titleService);
626
- json(res, 200, { ok: true });
650
+ json(res, HTTP_STATUS.OK, { ok: true });
627
651
  }
628
652
  catch (err) {
629
- json(res, 500, { error: errorMessage(err) });
653
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
654
+ error: errorMessage(err),
655
+ });
630
656
  }
631
657
  return;
632
658
  }
@@ -636,7 +662,7 @@ export function createRequestHandler(deps) {
636
662
  if (permListMatch && req.method === "GET") {
637
663
  const sessionId = decodeURIComponent(permListMatch[1]);
638
664
  const perms = sessions?.getPendingPermissions(sessionId) ?? [];
639
- json(res, 200, perms);
665
+ json(res, HTTP_STATUS.OK, perms);
640
666
  return;
641
667
  }
642
668
  // POST /api/v1/sessions/:id/permissions/:reqId
@@ -649,16 +675,18 @@ export function createRequestHandler(deps) {
649
675
  return;
650
676
  const perm = sessions?.pendingPermissions.get(requestId);
651
677
  if (!perm) {
652
- json(res, 404, { error: "Permission not found" });
678
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Permission not found" });
653
679
  return;
654
680
  }
655
681
  if (perm.sessionId !== sessionId) {
656
- json(res, 400, { error: "Session ID mismatch" });
682
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Session ID mismatch" });
657
683
  return;
658
684
  }
659
685
  const bridge = getBridge?.();
660
686
  if (!bridge) {
661
- json(res, 503, { error: "Agent not ready yet" });
687
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
688
+ error: "Agent not ready yet",
689
+ });
662
690
  return;
663
691
  }
664
692
  let body;
@@ -666,11 +694,13 @@ export function createRequestHandler(deps) {
666
694
  body = JSON.parse(await readBody(req));
667
695
  }
668
696
  catch {
669
- json(res, 400, { error: "Invalid JSON" });
697
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
670
698
  return;
671
699
  }
672
700
  if (!body.optionId && !body.denied) {
673
- json(res, 400, { error: "Provide optionId or denied:true" });
701
+ json(res, HTTP_STATUS.BAD_REQUEST, {
702
+ error: "Provide optionId or denied:true",
703
+ });
674
704
  return;
675
705
  }
676
706
  const denied = Boolean(body.denied);
@@ -699,8 +729,8 @@ export function createRequestHandler(deps) {
699
729
  void deps.pushService.sendClose(`sess-${perm.sessionId}-perm-${requestId}`);
700
730
  }
701
731
  const okBody = { ok: true };
702
- saveClientOpResult(store, opId, sessionId, 200, okBody);
703
- json(res, 200, okBody);
732
+ saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, okBody);
733
+ json(res, HTTP_STATUS.OK, okBody);
704
734
  return;
705
735
  }
706
736
  // --- POST /api/v1/sessions/:id/cancel ---
@@ -709,38 +739,88 @@ export function createRequestHandler(deps) {
709
739
  const sessionId = decodeURIComponent(cancelMatch[1]);
710
740
  const session = store.getSession(sessionId);
711
741
  if (!session) {
712
- json(res, 404, { error: "Session not found" });
713
- return;
714
- }
715
- const bridge = getBridge?.();
716
- if (!bridge) {
717
- json(res, 503, { error: "Agent not ready yet" });
742
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
718
743
  return;
719
744
  }
720
745
  const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
721
746
  if (replayed)
722
747
  return;
748
+ const hadAgentPrompt = sessions?.activePrompts.has(sessionId) ?? false;
749
+ const hadPendingPrompt = sessions?.cancelPendingPromptSubmission(sessionId) ?? false;
750
+ const hadBash = sessions?.runningBashProcs.has(sessionId) ?? false;
751
+ if (!hadAgentPrompt && !hadPendingPrompt && !hadBash) {
752
+ const idleBody = { ok: true, status: "idle" };
753
+ saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, idleBody);
754
+ json(res, HTTP_STATUS.OK, idleBody);
755
+ return;
756
+ }
757
+ const cancelledPromptId = hadAgentPrompt
758
+ ? (sessions?.state.getState(sessionId).runtime.busy?.promptId ?? null)
759
+ : null;
723
760
  // Kill running bash process if any
724
761
  const proc = sessions?.runningBashProcs.get(sessionId);
725
762
  if (proc) {
726
- interruptBashProc(proc);
727
- sessions.runningBashProcs.delete(sessionId);
763
+ const force = sessions.interruptedBashProcs.has(proc);
764
+ interruptBashProc(proc, force);
765
+ sessions.interruptedBashProcs.add(proc);
766
+ }
767
+ const bridge = hadAgentPrompt ? getBridge?.() : null;
768
+ if (hadAgentPrompt && !bridge) {
769
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
770
+ error: "Agent not ready yet",
771
+ });
772
+ return;
728
773
  }
729
- // Cancel agent prompt
730
- if (sessions?.activePrompts.has(sessionId)) {
774
+ // ACP cancel is a notification, not an acknowledgement. Keep the
775
+ // prompt active until its prompt response supplies the terminal stop
776
+ // reason, and allow repeated requests to resend the notification.
777
+ if (hadAgentPrompt && sessions && bridge) {
778
+ const previousCancelStatus = sessions.state.getState(sessionId).runtime.busy?.cancelStatus ??
779
+ null;
780
+ rlog.info("cancel requested", {
781
+ sessionId: sessionId.slice(0, 8),
782
+ retry: previousCancelStatus !== null,
783
+ previousStatus: previousCancelStatus,
784
+ });
731
785
  await bridge.cancel(sessionId);
732
- sessions.activePrompts.delete(sessionId);
786
+ const busy = sessions.state.getState(sessionId).runtime.busy;
787
+ const stillCancellingSamePrompt = sessions.activePrompts.has(sessionId) &&
788
+ busy?.kind === "agent" &&
789
+ busy.promptId === cancelledPromptId;
790
+ if (stillCancellingSamePrompt) {
791
+ sessions.state.markCancelRequested(sessionId);
792
+ }
733
793
  }
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.
794
+ // If prompt_done does not arrive, expose the lack of acknowledgement
795
+ // instead of pretending the prompt stopped.
737
796
  const cancelTimeout = deps.limits.cancel_timeout ?? 0;
738
- if (sessions && cancelTimeout > 0)
797
+ const busyAfterCancel = sessions?.state.getState(sessionId).runtime.busy;
798
+ const cancelPending = hadAgentPrompt &&
799
+ sessions?.activePrompts.has(sessionId) === true &&
800
+ busyAfterCancel?.kind === "agent" &&
801
+ busyAfterCancel.promptId === cancelledPromptId;
802
+ if (cancelPending && cancelTimeout > 0)
739
803
  sessions.state.armCancelSafety(sessionId, cancelTimeout);
740
804
  sessions?.syncBusy(sessionId);
741
- const okBody = { ok: true };
742
- saveClientOpResult(store, opId, sessionId, 200, okBody);
743
- json(res, 200, okBody);
805
+ const workPending = cancelPending || hadBash;
806
+ const replacementPromptActive = hadAgentPrompt &&
807
+ ((sessions?.activePrompts.has(sessionId) === true &&
808
+ busyAfterCancel?.kind === "agent" &&
809
+ busyAfterCancel.promptId !== cancelledPromptId) ||
810
+ sessions?.pendingPromptSubmissions.has(sessionId) === true);
811
+ const status = workPending || replacementPromptActive
812
+ ? HTTP_STATUS.ACCEPTED
813
+ : HTTP_STATUS.OK;
814
+ const okBody = {
815
+ ok: true,
816
+ status: workPending
817
+ ? "cancelling"
818
+ : replacementPromptActive
819
+ ? "superseded"
820
+ : "cancelled",
821
+ };
822
+ saveClientOpResult(store, opId, sessionId, status, okBody);
823
+ json(res, status, okBody);
744
824
  return;
745
825
  }
746
826
  // --- GET /api/v1/sessions/:id/status ---
@@ -749,12 +829,12 @@ export function createRequestHandler(deps) {
749
829
  const sessionId = decodeURIComponent(statusMatch[1]);
750
830
  const session = store.getSession(sessionId);
751
831
  if (!session) {
752
- json(res, 404, { error: "Session not found" });
832
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
753
833
  return;
754
834
  }
755
835
  const busyKind = sessions?.getBusyKind(sessionId) ?? null;
756
836
  const pendingPerms = sessions?.getPendingPermissions(sessionId) ?? [];
757
- json(res, 200, {
837
+ json(res, HTTP_STATUS.OK, {
758
838
  busy: busyKind != null,
759
839
  busyKind,
760
840
  pendingPermissions: pendingPerms,
@@ -771,20 +851,37 @@ export function createRequestHandler(deps) {
771
851
  const sessionId = decodeURIComponent(snapshotMatch[1]);
772
852
  const session = store.getSession(sessionId);
773
853
  if (!session) {
774
- json(res, 404, { error: "Session not found" });
854
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
775
855
  return;
776
856
  }
777
857
  if (!sessions) {
778
- json(res, 503, { error: "Session manager not available" });
858
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
859
+ error: "Session manager not available",
860
+ });
779
861
  return;
780
862
  }
863
+ const bridge = getBridge?.();
864
+ if (bridge && !sessions.liveSessions.has(sessionId)) {
865
+ try {
866
+ // Command discovery happens during session/load. Snapshot is the
867
+ // authoritative hydration boundary, so it must join any in-flight
868
+ // restore before reading the per-session command state.
869
+ await sessions.ensureResumed(bridge, sessionId);
870
+ }
871
+ catch {
872
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
873
+ error: "Failed to restore session",
874
+ });
875
+ return;
876
+ }
877
+ }
781
878
  // Make sure runtime reflects the current activePrompts/bash state even
782
879
  // if no patch has been emitted yet for this session.
783
880
  sessions.syncBusy(sessionId);
784
881
  sessions.syncPendingPermissions(sessionId);
785
882
  const runtimeState = sessions.state.getState(sessionId);
786
883
  const lastEventSeq = store.getLastEventSeq(sessionId);
787
- json(res, 200, {
884
+ json(res, HTTP_STATUS.OK, {
788
885
  version: 1,
789
886
  seq: runtimeState.seq,
790
887
  session: {
@@ -797,6 +894,7 @@ export function createRequestHandler(deps) {
797
894
  lastEventSeq,
798
895
  },
799
896
  runtime: runtimeState.runtime,
897
+ agentCommands: sessions.getAgentCommands(sessionId),
800
898
  }, req);
801
899
  return;
802
900
  }
@@ -809,65 +907,101 @@ export function createRequestHandler(deps) {
809
907
  if (!session) {
810
908
  logPromptRejectBeforeSave({
811
909
  sessionId,
812
- status: 404,
910
+ status: HTTP_STATUS.NOT_FOUND,
813
911
  reason: "session_not_found",
814
912
  opId: requestOpId,
815
913
  });
816
- json(res, 404, { error: "Session not found" });
914
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
817
915
  return;
818
916
  }
819
917
  const bridge = getBridge?.();
820
918
  if (!bridge) {
821
919
  logPromptRejectBeforeSave({
822
920
  sessionId,
823
- status: 503,
921
+ status: HTTP_STATUS.SERVICE_UNAVAILABLE,
824
922
  reason: "agent_not_ready",
825
923
  opId: requestOpId,
826
924
  });
827
- json(res, 503, { error: "Agent not ready yet" });
925
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
926
+ error: "Agent not ready yet",
927
+ });
828
928
  return;
829
929
  }
830
930
  if (!sessions) {
831
931
  logPromptRejectBeforeSave({
832
932
  sessionId,
833
- status: 503,
933
+ status: HTTP_STATUS.SERVICE_UNAVAILABLE,
834
934
  reason: "session_manager_unavailable",
835
935
  opId: requestOpId,
836
936
  });
837
- json(res, 503, { error: "Session manager not available" });
937
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
938
+ error: "Session manager not available",
939
+ });
838
940
  return;
839
941
  }
840
942
  const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
841
943
  if (replayed)
842
944
  return;
945
+ const promptSubmissionId = sessions.reservePromptSubmission(sessionId);
946
+ if (promptSubmissionId === null) {
947
+ const busyKind = sessions.getBusyKind(sessionId);
948
+ logPromptRejectBeforeSave({
949
+ sessionId,
950
+ status: HTTP_STATUS.CONFLICT,
951
+ reason: "session_busy",
952
+ opId,
953
+ busyKind: busyKind ?? undefined,
954
+ });
955
+ json(res, HTTP_STATUS.CONFLICT, {
956
+ error: "Session is busy",
957
+ busyKind,
958
+ });
959
+ return;
960
+ }
961
+ const requestState = { aborted: false };
962
+ const isRequestAborted = () => requestState.aborted;
963
+ const abortPromptSubmission = () => {
964
+ requestState.aborted = true;
965
+ sessions.releasePromptSubmission(sessionId, promptSubmissionId);
966
+ };
967
+ res.once("finish", () => {
968
+ sessions.releasePromptSubmission(sessionId, promptSubmissionId);
969
+ });
970
+ req.once("aborted", abortPromptSubmission);
971
+ res.once("close", () => {
972
+ if (!res.writableEnded)
973
+ abortPromptSubmission();
974
+ });
843
975
  // Ensure session is live in ACP before prompting (awaits in-flight resume)
844
976
  try {
845
977
  await sessions.ensureResumed(bridge, sessionId);
846
978
  }
847
979
  catch (err) {
980
+ if (isRequestAborted())
981
+ return;
848
982
  logPromptRejectBeforeSave({
849
983
  sessionId,
850
- status: 500,
984
+ status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
851
985
  reason: "resume_failed",
852
986
  opId,
853
987
  error: errorMessage(err),
854
988
  });
855
- json(res, 500, {
989
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
856
990
  error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
857
991
  });
858
992
  return;
859
993
  }
860
- // Check if session is busy
861
- const busyKind = sessions.getBusyKind(sessionId);
862
- if (busyKind) {
994
+ if (isRequestAborted() ||
995
+ sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
863
996
  logPromptRejectBeforeSave({
864
997
  sessionId,
865
- status: 409,
866
- reason: "session_busy",
998
+ status: HTTP_STATUS.CONFLICT,
999
+ reason: "prompt_cancelled_before_start",
867
1000
  opId,
868
- busyKind,
869
1001
  });
870
- json(res, 409, { error: "Session is busy", busyKind });
1002
+ json(res, HTTP_STATUS.CONFLICT, {
1003
+ error: "Prompt was cancelled before start",
1004
+ });
871
1005
  return;
872
1006
  }
873
1007
  let body;
@@ -875,19 +1009,28 @@ export function createRequestHandler(deps) {
875
1009
  body = JSON.parse(await readBody(req));
876
1010
  }
877
1011
  catch {
1012
+ if (isRequestAborted())
1013
+ return;
878
1014
  logPromptRejectBeforeSave({
879
1015
  sessionId,
880
- status: 400,
1016
+ status: HTTP_STATUS.BAD_REQUEST,
881
1017
  reason: "invalid_json",
882
1018
  opId,
883
1019
  });
884
- json(res, 400, { error: "Invalid JSON" });
1020
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1021
+ return;
1022
+ }
1023
+ if (isRequestAborted() ||
1024
+ sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
1025
+ json(res, HTTP_STATUS.CONFLICT, {
1026
+ error: "Prompt was cancelled before start",
1027
+ });
885
1028
  return;
886
1029
  }
887
1030
  if (!body.text) {
888
1031
  logPromptRejectBeforeSave({
889
1032
  sessionId,
890
- status: 400,
1033
+ status: HTTP_STATUS.BAD_REQUEST,
891
1034
  reason: "missing_text",
892
1035
  opId,
893
1036
  textLength: 0,
@@ -895,7 +1038,9 @@ export function createRequestHandler(deps) {
895
1038
  ? body.attachments.length
896
1039
  : undefined,
897
1040
  });
898
- json(res, 400, { error: "Missing required field: text" });
1041
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1042
+ error: "Missing required field: text",
1043
+ });
899
1044
  return;
900
1045
  }
901
1046
  // Validate attachment shape: client must NEVER supply uri/data/path,
@@ -906,12 +1051,14 @@ export function createRequestHandler(deps) {
906
1051
  if (!Array.isArray(attachments)) {
907
1052
  logPromptRejectBeforeSave({
908
1053
  sessionId,
909
- status: 400,
1054
+ status: HTTP_STATUS.BAD_REQUEST,
910
1055
  reason: "attachments_not_array",
911
1056
  opId,
912
1057
  textLength: body.text.length,
913
1058
  });
914
- json(res, 400, { error: "attachments must be an array" });
1059
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1060
+ error: "attachments must be an array",
1061
+ });
915
1062
  return;
916
1063
  }
917
1064
  for (const raw of attachments) {
@@ -924,13 +1071,15 @@ export function createRequestHandler(deps) {
924
1071
  typeof att.mimeType !== "string") {
925
1072
  logPromptRejectBeforeSave({
926
1073
  sessionId,
927
- status: 400,
1074
+ status: HTTP_STATUS.BAD_REQUEST,
928
1075
  reason: "invalid_attachment_entry",
929
1076
  opId,
930
1077
  textLength: body.text.length,
931
1078
  attachmentCount: attachments.length,
932
1079
  });
933
- json(res, 400, { error: "Invalid attachment entry" });
1080
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1081
+ error: "Invalid attachment entry",
1082
+ });
934
1083
  return;
935
1084
  }
936
1085
  if (typeof att.uri === "string" ||
@@ -940,19 +1089,41 @@ export function createRequestHandler(deps) {
940
1089
  typeof att.height === "number") {
941
1090
  logPromptRejectBeforeSave({
942
1091
  sessionId,
943
- status: 400,
1092
+ status: HTTP_STATUS.BAD_REQUEST,
944
1093
  reason: "client_supplied_attachment_data",
945
1094
  opId,
946
1095
  textLength: body.text.length,
947
1096
  attachmentCount: attachments.length,
948
1097
  });
949
- json(res, 400, {
1098
+ json(res, HTTP_STATUS.BAD_REQUEST, {
950
1099
  error: "Client must not supply uri/data/path/width/height",
951
1100
  });
952
1101
  return;
953
1102
  }
954
1103
  }
955
1104
  }
1105
+ let agentText = body.text;
1106
+ if (body.text.startsWith("//")) {
1107
+ const resolved = resolveAgentCommand(body.text, sessions.getAgentCommands(sessionId).commands);
1108
+ if (!resolved) {
1109
+ const command = agentCommandToken(body.text);
1110
+ logPromptRejectBeforeSave({
1111
+ sessionId,
1112
+ status: HTTP_STATUS.UNPROCESSABLE_CONTENT,
1113
+ reason: "unknown_command",
1114
+ opId,
1115
+ textLength: body.text.length,
1116
+ attachmentCount: attachments?.length,
1117
+ });
1118
+ json(res, HTTP_STATUS.UNPROCESSABLE_CONTENT, {
1119
+ error: "Unknown command",
1120
+ command,
1121
+ prefix: "//",
1122
+ });
1123
+ return;
1124
+ }
1125
+ agentText = resolved.agentText;
1126
+ }
956
1127
  // Stored shape mirrors the wire shape PLUS a server-derived `path`
957
1128
  // for renderers. The path is the unsigned base URL
958
1129
  // (`/api/v1/sessions/<sid>/attachments/<filename>`); reSign on
@@ -978,8 +1149,10 @@ export function createRequestHandler(deps) {
978
1149
  },
979
1150
  ];
980
1151
  });
1152
+ const eventClientOpId = opId ?? randomUUID();
981
1153
  store.saveEvent(sessionId, "user_message", {
982
1154
  text: body.text,
1155
+ clientOpId: eventClientOpId,
983
1156
  ...(storedAttachments?.length
984
1157
  ? { attachments: storedAttachments }
985
1158
  : {}),
@@ -990,6 +1163,7 @@ export function createRequestHandler(deps) {
990
1163
  type: "user_message",
991
1164
  sessionId,
992
1165
  text: body.text,
1166
+ clientOpId: eventClientOpId,
993
1167
  attachments: storedAttachments,
994
1168
  };
995
1169
  sseManager.broadcast(userMsgEvent);
@@ -1007,20 +1181,27 @@ export function createRequestHandler(deps) {
1007
1181
  });
1008
1182
  }
1009
1183
  // Fire prompt asynchronously (don't await — response is 202)
1184
+ sessions.releasePromptSubmission(sessionId, promptSubmissionId, false);
1010
1185
  sessions.activePrompts.add(sessionId);
1011
1186
  sessions.syncBusy(sessionId);
1187
+ const promptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
1188
+ undefined;
1012
1189
  bridge
1013
- .prompt(sessionId, body.text, attachments)
1190
+ .prompt(sessionId, agentText, attachments, promptId)
1014
1191
  .catch((err) => {
1015
1192
  plog.error("error", { sessionId, error: err });
1016
1193
  })
1017
1194
  .finally(() => {
1195
+ // A turn that outlived its own supersession must not clear the
1196
+ // busy state of the turn that replaced it.
1197
+ if (!sessions.isCurrentPrompt(sessionId, promptId))
1198
+ return;
1018
1199
  sessions.activePrompts.delete(sessionId);
1019
1200
  sessions.syncBusy(sessionId);
1020
1201
  });
1021
1202
  const acceptedBody = { status: "accepted" };
1022
- saveClientOpResult(store, opId, sessionId, 202, acceptedBody);
1023
- json(res, 202, acceptedBody);
1203
+ saveClientOpResult(store, opId, sessionId, HTTP_STATUS.ACCEPTED, acceptedBody);
1204
+ json(res, HTTP_STATUS.ACCEPTED, acceptedBody);
1024
1205
  return;
1025
1206
  }
1026
1207
  // --- POST /api/v1/sessions/:id/bash ---
@@ -1029,15 +1210,17 @@ export function createRequestHandler(deps) {
1029
1210
  const sessionId = decodeURIComponent(bashMatch[1]);
1030
1211
  const session = store.getSession(sessionId);
1031
1212
  if (!session) {
1032
- json(res, 404, { error: "Session not found" });
1213
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1033
1214
  return;
1034
1215
  }
1035
1216
  if (!sessions) {
1036
- json(res, 503, { error: "Session manager not available" });
1217
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1218
+ error: "Session manager not available",
1219
+ });
1037
1220
  return;
1038
1221
  }
1039
1222
  if (sessions.runningBashProcs.has(sessionId)) {
1040
- json(res, 409, {
1223
+ json(res, HTTP_STATUS.CONFLICT, {
1041
1224
  error: "A bash command is already running in this session",
1042
1225
  });
1043
1226
  return;
@@ -1047,11 +1230,13 @@ export function createRequestHandler(deps) {
1047
1230
  body = JSON.parse(await readBody(req));
1048
1231
  }
1049
1232
  catch {
1050
- json(res, 400, { error: "Invalid JSON" });
1233
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1051
1234
  return;
1052
1235
  }
1053
1236
  if (!body.command) {
1054
- json(res, 400, { error: "Missing required field: command" });
1237
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1238
+ error: "Missing required field: command",
1239
+ });
1055
1240
  return;
1056
1241
  }
1057
1242
  const cwd = sessions.getSessionCwd(sessionId);
@@ -1128,7 +1313,7 @@ export function createRequestHandler(deps) {
1128
1313
  };
1129
1314
  sseManager.broadcast(bashErrEvent);
1130
1315
  });
1131
- json(res, 202, { status: "accepted" });
1316
+ json(res, HTTP_STATUS.ACCEPTED, { status: "accepted" });
1132
1317
  return;
1133
1318
  }
1134
1319
  // --- POST /api/v1/sessions/:id/bash/cancel ---
@@ -1137,11 +1322,11 @@ export function createRequestHandler(deps) {
1137
1322
  const sessionId = decodeURIComponent(bashCancelMatch[1]);
1138
1323
  const session = store.getSession(sessionId);
1139
1324
  if (!session) {
1140
- json(res, 404, { error: "Session not found" });
1325
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1141
1326
  return;
1142
1327
  }
1143
1328
  interruptBashProc(sessions?.runningBashProcs.get(sessionId));
1144
- json(res, 200, { ok: true });
1329
+ json(res, HTTP_STATUS.OK, { ok: true });
1145
1330
  return;
1146
1331
  }
1147
1332
  // --- PUT /api/v1/sessions/:id/{model,mode,reasoning-effort} ---
@@ -1155,12 +1340,14 @@ export function createRequestHandler(deps) {
1155
1340
  const configId = configPath.replace(/-/g, "_");
1156
1341
  const session = store.getSession(sessionId);
1157
1342
  if (!session) {
1158
- json(res, 404, { error: "Session not found" });
1343
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1159
1344
  return;
1160
1345
  }
1161
1346
  const bridge = getBridge?.();
1162
1347
  if (!bridge) {
1163
- json(res, 503, { error: "Agent not ready yet" });
1348
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1349
+ error: "Agent not ready yet",
1350
+ });
1164
1351
  return;
1165
1352
  }
1166
1353
  let body;
@@ -1168,11 +1355,13 @@ export function createRequestHandler(deps) {
1168
1355
  body = JSON.parse(await readBody(req));
1169
1356
  }
1170
1357
  catch {
1171
- json(res, 400, { error: "Invalid JSON" });
1358
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1172
1359
  return;
1173
1360
  }
1174
1361
  if (body.value === undefined) {
1175
- json(res, 400, { error: "Missing required field: value" });
1362
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1363
+ error: "Missing required field: value",
1364
+ });
1176
1365
  return;
1177
1366
  }
1178
1367
  try {
@@ -1193,10 +1382,10 @@ export function createRequestHandler(deps) {
1193
1382
  configId,
1194
1383
  value: body.value,
1195
1384
  });
1196
- json(res, 200, { configOptions });
1385
+ json(res, HTTP_STATUS.OK, { configOptions });
1197
1386
  }
1198
1387
  catch (err) {
1199
- json(res, 500, {
1388
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
1200
1389
  error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}`,
1201
1390
  });
1202
1391
  }
@@ -1208,7 +1397,7 @@ export function createRequestHandler(deps) {
1208
1397
  const sessionId = decodeURIComponent(titlePutMatch[1]);
1209
1398
  const session = store.getSession(sessionId);
1210
1399
  if (!session) {
1211
- json(res, 404, { error: "Session not found" });
1400
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1212
1401
  return;
1213
1402
  }
1214
1403
  let body;
@@ -1216,11 +1405,13 @@ export function createRequestHandler(deps) {
1216
1405
  body = JSON.parse(await readBody(req));
1217
1406
  }
1218
1407
  catch {
1219
- json(res, 400, { error: "Invalid JSON" });
1408
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1220
1409
  return;
1221
1410
  }
1222
1411
  if (!body.value) {
1223
- json(res, 400, { error: "Missing required field: value" });
1412
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1413
+ error: "Missing required field: value",
1414
+ });
1224
1415
  return;
1225
1416
  }
1226
1417
  store.updateSessionTitle(sessionId, body.value);
@@ -1235,7 +1426,7 @@ export function createRequestHandler(deps) {
1235
1426
  title: body.value,
1236
1427
  };
1237
1428
  sseManager.broadcast(titleEvent);
1238
- json(res, 200, { title: body.value });
1429
+ json(res, HTTP_STATUS.OK, { title: body.value });
1239
1430
  return;
1240
1431
  }
1241
1432
  // --- Session CRUD: /api/v1/sessions/:id ---
@@ -1248,7 +1439,7 @@ export function createRequestHandler(deps) {
1248
1439
  if (req.method === "GET") {
1249
1440
  const session = store.getSession(sessionId);
1250
1441
  if (!session) {
1251
- json(res, 404, { error: "Session not found" });
1442
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1252
1443
  return;
1253
1444
  }
1254
1445
  // Start resume in background (non-blocking) so the client gets metadata fast.
@@ -1353,7 +1544,7 @@ export function createRequestHandler(deps) {
1353
1544
  return opts;
1354
1545
  })()
1355
1546
  : [];
1356
- json(res, 200, {
1547
+ json(res, HTTP_STATUS.OK, {
1357
1548
  id: freshSession.id,
1358
1549
  cwd: freshSession.cwd,
1359
1550
  title: freshSession.title,
@@ -1368,7 +1559,13 @@ export function createRequestHandler(deps) {
1368
1559
  if (req.method === "DELETE") {
1369
1560
  const session = store.getSession(sessionId);
1370
1561
  if (!session) {
1371
- json(res, 404, { error: "Session not found" });
1562
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1563
+ return;
1564
+ }
1565
+ if (sessions && sessions.getBusyKind(sessionId) !== null) {
1566
+ json(res, HTTP_STATUS.CONFLICT, {
1567
+ error: "Cancel active work before deleting the session",
1568
+ });
1372
1569
  return;
1373
1570
  }
1374
1571
  if (sessions) {
@@ -1378,20 +1575,25 @@ export function createRequestHandler(deps) {
1378
1575
  store.deleteSession(sessionId);
1379
1576
  }
1380
1577
  sseManager.broadcast({ type: "session_deleted", sessionId });
1381
- res.writeHead(204);
1578
+ res.writeHead(HTTP_STATUS.NO_CONTENT);
1382
1579
  res.end();
1383
1580
  return;
1384
1581
  }
1385
1582
  }
1386
1583
  // POST /api/v1/sessions (create new session)
1387
1584
  if (url === "/api/v1/sessions" && req.method === "POST") {
1585
+ const clientOpId = getClientOpId(req);
1388
1586
  const bridge = getBridge?.();
1389
1587
  if (!bridge) {
1390
- json(res, 503, { error: "Agent not ready yet" });
1588
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1589
+ error: "Agent not ready yet",
1590
+ });
1391
1591
  return;
1392
1592
  }
1393
1593
  if (!sessions) {
1394
- json(res, 503, { error: "Session manager not available" });
1594
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1595
+ error: "Session manager not available",
1596
+ });
1395
1597
  return;
1396
1598
  }
1397
1599
  let body;
@@ -1399,7 +1601,7 @@ export function createRequestHandler(deps) {
1399
1601
  body = JSON.parse(await readBody(req));
1400
1602
  }
1401
1603
  catch {
1402
- json(res, 400, { error: "Invalid JSON" });
1604
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1403
1605
  return;
1404
1606
  }
1405
1607
  const source = body.source ?? "auto";
@@ -1412,6 +1614,8 @@ export function createRequestHandler(deps) {
1412
1614
  cwd: session?.cwd,
1413
1615
  title: session?.title,
1414
1616
  configOptions,
1617
+ agentCommands: sessions.getAgentCommands(sessionId),
1618
+ clientOpId: clientOpId ?? undefined,
1415
1619
  };
1416
1620
  sseManager.broadcast(sessionCreatedEvent);
1417
1621
  // ACP's session_created event fires before inheritance runs, so
@@ -1423,21 +1627,23 @@ export function createRequestHandler(deps) {
1423
1627
  configOptions,
1424
1628
  });
1425
1629
  }
1426
- json(res, 201, {
1630
+ json(res, HTTP_STATUS.CREATED, {
1427
1631
  id: sessionId,
1428
1632
  cwd: session?.cwd ?? body.cwd,
1429
1633
  title: session?.title ?? null,
1430
1634
  source: session?.source ?? source,
1431
1635
  configOptions,
1636
+ agentCommands: sessions.getAgentCommands(sessionId),
1637
+ clientOpId: clientOpId ?? undefined,
1432
1638
  });
1433
1639
  }
1434
1640
  catch (err) {
1435
1641
  const msg = err instanceof Error ? err.message : String(err);
1436
- if (msg.includes("does not exist")) {
1437
- json(res, 400, { error: msg });
1642
+ if (err instanceof InvalidSessionDirectoryError) {
1643
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: msg });
1438
1644
  }
1439
1645
  else {
1440
- json(res, 500, { error: msg });
1646
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: msg });
1441
1647
  }
1442
1648
  }
1443
1649
  return;
@@ -1459,7 +1665,7 @@ export function createRequestHandler(deps) {
1459
1665
  : undefined;
1460
1666
  const session = store.getSession(sessionId);
1461
1667
  if (!session) {
1462
- json(res, 404, { error: "Session not found" });
1668
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1463
1669
  return;
1464
1670
  }
1465
1671
  // Flush pending buffers so their content becomes part of the event list.
@@ -1468,14 +1674,15 @@ export function createRequestHandler(deps) {
1468
1674
  let streamingThinking = false;
1469
1675
  let streamingAssistant = false;
1470
1676
  if (sessions) {
1471
- if (sessions.thinkingBuffers.has(sessionId)) {
1472
- streamingThinking = true;
1473
- sessions.flushThinkingBuffer(sessionId);
1474
- }
1475
- if (sessions.assistantBuffers.has(sessionId)) {
1476
- streamingAssistant = true;
1477
- sessions.flushAssistantBuffer(sessionId);
1478
- }
1677
+ const runtimeStreaming = sessions.state.peekStreaming(sessionId);
1678
+ streamingThinking =
1679
+ runtimeStreaming.thinking ||
1680
+ Boolean(sessions.thinkingBuffers.get(sessionId));
1681
+ streamingAssistant =
1682
+ runtimeStreaming.assistant ||
1683
+ Boolean(sessions.assistantBuffers.get(sessionId));
1684
+ sessions.flushThinkingBuffer(sessionId);
1685
+ sessions.flushAssistantBuffer(sessionId);
1479
1686
  }
1480
1687
  const events = store.getEvents(sessionId, {
1481
1688
  excludeThinking,
@@ -1520,7 +1727,7 @@ export function createRequestHandler(deps) {
1520
1727
  envelope.total = total;
1521
1728
  envelope.hasMore = hasMore;
1522
1729
  }
1523
- json(res, 200, envelope, req);
1730
+ json(res, HTTP_STATUS.OK, envelope, req);
1524
1731
  return;
1525
1732
  }
1526
1733
  // --- SSE stream endpoints ---
@@ -1534,13 +1741,15 @@ export function createRequestHandler(deps) {
1534
1741
  const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
1535
1742
  const principal = deps.ticketStore?.consume(ticket);
1536
1743
  if (!principal) {
1537
- json(res, 401, { error: "Invalid or expired ticket" });
1744
+ json(res, HTTP_STATUS.UNAUTHORIZED, {
1745
+ error: "Invalid or expired ticket",
1746
+ });
1538
1747
  return;
1539
1748
  }
1540
1749
  tokenName = principal.tokenName;
1541
1750
  }
1542
1751
  const clientId = sseManager.generateClientId();
1543
- res.writeHead(200, {
1752
+ res.writeHead(HTTP_STATUS.OK, {
1544
1753
  "Content-Type": "text/event-stream",
1545
1754
  "Cache-Control": "no-cache",
1546
1755
  Connection: "keep-alive",
@@ -1556,6 +1765,7 @@ export function createRequestHandler(deps) {
1556
1765
  type: "connected",
1557
1766
  clientId,
1558
1767
  debugLevel: deps.debugLevel ?? "off",
1768
+ pendingCount: store.countUnprocessed(),
1559
1769
  });
1560
1770
  sseManager.writeHeartbeat(client);
1561
1771
  return;
@@ -1569,18 +1779,20 @@ export function createRequestHandler(deps) {
1569
1779
  const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
1570
1780
  const principal = deps.ticketStore?.consume(ticket);
1571
1781
  if (!principal) {
1572
- json(res, 401, { error: "Invalid or expired ticket" });
1782
+ json(res, HTTP_STATUS.UNAUTHORIZED, {
1783
+ error: "Invalid or expired ticket",
1784
+ });
1573
1785
  return;
1574
1786
  }
1575
1787
  tokenName = principal.tokenName;
1576
1788
  }
1577
1789
  const session = store.getSession(sessionId);
1578
1790
  if (!session) {
1579
- json(res, 404, { error: "Session not found" });
1791
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1580
1792
  return;
1581
1793
  }
1582
1794
  const clientId = sseManager.generateClientId();
1583
- res.writeHead(200, {
1795
+ res.writeHead(HTTP_STATUS.OK, {
1584
1796
  "Content-Type": "text/event-stream",
1585
1797
  "Cache-Control": "no-cache",
1586
1798
  Connection: "keep-alive",
@@ -1597,6 +1809,7 @@ export function createRequestHandler(deps) {
1597
1809
  type: "connected",
1598
1810
  clientId,
1599
1811
  debugLevel: deps.debugLevel ?? "off",
1812
+ pendingCount: store.countUnprocessed(),
1600
1813
  });
1601
1814
  sseManager.writeHeartbeat(client);
1602
1815
  // Replay events from Last-Event-ID if provided
@@ -1632,12 +1845,14 @@ export function createRequestHandler(deps) {
1632
1845
  if (imgUploadMatch && req.method === "POST") {
1633
1846
  const sessionId = decodeURIComponent(imgUploadMatch[1]);
1634
1847
  if (!SAFE_ID.test(sessionId)) {
1635
- json(res, 400, { error: "Invalid session ID" });
1848
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid session ID" });
1636
1849
  return;
1637
1850
  }
1638
1851
  const ctype = req.headers["content-type"] ?? "";
1639
1852
  if (!ctype.toLowerCase().startsWith("multipart/form-data")) {
1640
- json(res, 400, { error: "Expected multipart/form-data" });
1853
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1854
+ error: "Expected multipart/form-data",
1855
+ });
1641
1856
  return;
1642
1857
  }
1643
1858
  await handleAttachmentUpload(req, res, sessionId, deps);
@@ -1660,14 +1875,16 @@ export function createRequestHandler(deps) {
1660
1875
  const exp = params.get("exp") ?? "";
1661
1876
  const basePath = `/api/v1/sessions/${sessionId}/attachments/${file}`;
1662
1877
  if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
1663
- res.writeHead(401, { "Content-Type": "application/json" });
1878
+ res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
1879
+ "Content-Type": "application/json",
1880
+ });
1664
1881
  res.end(JSON.stringify({ error: "Unauthorized" }));
1665
1882
  return;
1666
1883
  }
1667
1884
  }
1668
1885
  const filePath = join(deps.dataDir, "sessions", sessionId, "attachments", file);
1669
1886
  if (!filePath.startsWith(join(deps.dataDir, "sessions"))) {
1670
- res.writeHead(403);
1887
+ res.writeHead(HTTP_STATUS.FORBIDDEN);
1671
1888
  res.end("Forbidden");
1672
1889
  return;
1673
1890
  }
@@ -1688,11 +1905,11 @@ export function createRequestHandler(deps) {
1688
1905
  const disposition = isInlineMime(mime) ? "inline" : "attachment";
1689
1906
  headers["Content-Disposition"] = buildContentDisposition(disposition, row.name);
1690
1907
  }
1691
- res.writeHead(200, headers);
1908
+ res.writeHead(HTTP_STATUS.OK, headers);
1692
1909
  res.end(fileData);
1693
1910
  }
1694
1911
  catch {
1695
- res.writeHead(404);
1912
+ res.writeHead(HTTP_STATUS.NOT_FOUND);
1696
1913
  res.end("Not found");
1697
1914
  }
1698
1915
  return;
@@ -1711,7 +1928,7 @@ export function createRequestHandler(deps) {
1711
1928
  raw = await readBody(req);
1712
1929
  }
1713
1930
  catch {
1714
- json(res, 400, { error: "Failed to read body" });
1931
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Failed to read body" });
1715
1932
  return;
1716
1933
  }
1717
1934
  let parsed;
@@ -1719,12 +1936,12 @@ export function createRequestHandler(deps) {
1719
1936
  parsed = JSON.parse(raw);
1720
1937
  }
1721
1938
  catch {
1722
- json(res, 400, { error: "Invalid JSON" });
1939
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1723
1940
  return;
1724
1941
  }
1725
1942
  const validation = MessageIngressSchema.safeParse(parsed);
1726
1943
  if (!validation.success) {
1727
- json(res, 400, {
1944
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1728
1945
  error: "Invalid body",
1729
1946
  issues: validation.error.issues,
1730
1947
  });
@@ -1736,7 +1953,7 @@ export function createRequestHandler(deps) {
1736
1953
  const targetSid = input.to.slice("session:".length);
1737
1954
  const session = store.getSession(targetSid);
1738
1955
  if (!session) {
1739
- json(res, 400, { error: "session_not_found" });
1956
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "session_not_found" });
1740
1957
  return;
1741
1958
  }
1742
1959
  sessions?.flushBuffers(targetSid);
@@ -1772,8 +1989,8 @@ export function createRequestHandler(deps) {
1772
1989
  sess_id: targetSid.slice(0, 8),
1773
1990
  });
1774
1991
  const boundBody = { id, delivered: "session" };
1775
- saveClientOpResult(store, opId, "__ingress__", 200, boundBody);
1776
- json(res, 200, boundBody);
1992
+ saveClientOpResult(store, opId, "__ingress__", HTTP_STATUS.OK, boundBody);
1993
+ json(res, HTTP_STATUS.OK, boundBody);
1777
1994
  return;
1778
1995
  }
1779
1996
  // Unbound: to=user → rows in `messages` table
@@ -1803,6 +2020,7 @@ export function createRequestHandler(deps) {
1803
2020
  created_at: Date.now(),
1804
2021
  });
1805
2022
  sseManager.broadcast({ type: "message_created", messageId: id });
2023
+ broadcastInboxCount(store, sseManager);
1806
2024
  if (deps.pushService) {
1807
2025
  void deps.pushService.sendForMessage({
1808
2026
  id,
@@ -1819,13 +2037,13 @@ export function createRequestHandler(deps) {
1819
2037
  from_ref: input.from_ref,
1820
2038
  });
1821
2039
  const unboundBody = { id, delivered: "pending" };
1822
- saveClientOpResult(store, opId, "__ingress__", 200, unboundBody);
1823
- json(res, 200, unboundBody);
2040
+ saveClientOpResult(store, opId, "__ingress__", HTTP_STATUS.OK, unboundBody);
2041
+ json(res, HTTP_STATUS.OK, unboundBody);
1824
2042
  return;
1825
2043
  }
1826
2044
  // GET /api/v1/messages — list unprocessed
1827
2045
  if (url === "/api/v1/messages" && req.method === "GET") {
1828
- json(res, 200, { messages: store.listUnprocessed() });
2046
+ json(res, HTTP_STATUS.OK, { messages: store.listUnprocessed() });
1829
2047
  return;
1830
2048
  }
1831
2049
  // /api/v1/messages/:id... — GET single, POST :id/consume, POST :id/ack, DELETE :id
@@ -1834,23 +2052,58 @@ export function createRequestHandler(deps) {
1834
2052
  const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
1835
2053
  if (consumeMatch && req.method === "POST") {
1836
2054
  const id = decodeURIComponent(consumeMatch[1]);
1837
- const newSid = randomUUID();
2055
+ let inheritFromSessionId;
2056
+ try {
2057
+ const rawBody = await readBody(req);
2058
+ if (rawBody) {
2059
+ const body = JSON.parse(rawBody);
2060
+ if (body.inheritFromSessionId !== undefined &&
2061
+ typeof body.inheritFromSessionId !== "string") {
2062
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2063
+ error: "inheritFromSessionId must be a string",
2064
+ });
2065
+ return;
2066
+ }
2067
+ inheritFromSessionId = body.inheritFromSessionId;
2068
+ }
2069
+ }
2070
+ catch {
2071
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
2072
+ return;
2073
+ }
2074
+ const bridge = getBridge?.();
2075
+ if (!sessions || !bridge) {
2076
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
2077
+ error: "Agent not available",
2078
+ });
2079
+ return;
2080
+ }
1838
2081
  let out;
1839
2082
  try {
1840
- out = store.consumeMessageTx(id, { sessionId: newSid });
2083
+ out = await sessions.consumeMessage(bridge, id, inheritFromSessionId);
1841
2084
  }
1842
2085
  catch (err) {
1843
- if (/message not found/.test(errorMessage(err))) {
1844
- json(res, 404, { error: "Message not found" });
2086
+ if (err instanceof InvalidSessionDirectoryError) {
2087
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2088
+ error: err.message,
2089
+ });
1845
2090
  return;
1846
2091
  }
1847
- throw err;
2092
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
2093
+ error: errorMessage(err),
2094
+ });
2095
+ return;
2096
+ }
2097
+ if (!out) {
2098
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
2099
+ return;
1848
2100
  }
1849
2101
  sseManager.broadcast({
1850
2102
  type: "message_consumed",
1851
2103
  messageId: id,
1852
2104
  sessionId: out.sessionId,
1853
2105
  });
2106
+ broadcastInboxCount(store, sseManager);
1854
2107
  if (!out.alreadyConsumed && deps.pushService) {
1855
2108
  void deps.pushService.sendClose(id);
1856
2109
  }
@@ -1859,7 +2112,7 @@ export function createRequestHandler(deps) {
1859
2112
  sess_id: out.sessionId.slice(0, 8),
1860
2113
  already_consumed: out.alreadyConsumed,
1861
2114
  });
1862
- json(res, 200, {
2115
+ json(res, HTTP_STATUS.OK, {
1863
2116
  sessionId: out.sessionId,
1864
2117
  alreadyConsumed: out.alreadyConsumed,
1865
2118
  });
@@ -1873,28 +2126,29 @@ export function createRequestHandler(deps) {
1873
2126
  const id = decodeURIComponent((ackPost ?? idOnly)[1]);
1874
2127
  const changes = store.deleteMessage(id);
1875
2128
  if (changes === 0) {
1876
- json(res, 404, { error: "Message not found" });
2129
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
1877
2130
  return;
1878
2131
  }
1879
2132
  sseManager.broadcast({ type: "message_acked", messageId: id });
2133
+ broadcastInboxCount(store, sseManager);
1880
2134
  if (deps.pushService)
1881
2135
  void deps.pushService.sendClose(id);
1882
2136
  mlog.info("ack", { msg_id: id });
1883
- json(res, 200, { ok: true });
2137
+ json(res, HTTP_STATUS.OK, { ok: true });
1884
2138
  return;
1885
2139
  }
1886
2140
  if (idOnly && req.method === "GET") {
1887
2141
  const id = decodeURIComponent(idOnly[1]);
1888
2142
  const row = store.getMessage(id);
1889
2143
  if (!row) {
1890
- json(res, 404, { error: "Message not found" });
2144
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
1891
2145
  return;
1892
2146
  }
1893
- json(res, 200, row);
2147
+ json(res, HTTP_STATUS.OK, row);
1894
2148
  return;
1895
2149
  }
1896
2150
  }
1897
- json(res, 404, { error: "Not found" });
2151
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Not found" });
1898
2152
  return;
1899
2153
  }
1900
2154
  // --- Beta API routes ---
@@ -1903,12 +2157,16 @@ export function createRequestHandler(deps) {
1903
2157
  // POST /api/beta/prompt — quick one-shot prompt (create temp session + send)
1904
2158
  if (url === "/api/beta/prompt" && req.method === "POST") {
1905
2159
  if (!sessions || !getBridge) {
1906
- json(res, 503, { error: "Agent not available" });
2160
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
2161
+ error: "Agent not available",
2162
+ });
1907
2163
  return;
1908
2164
  }
1909
2165
  const bridge = getBridge();
1910
2166
  if (!bridge) {
1911
- json(res, 503, { error: "Agent not available" });
2167
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
2168
+ error: "Agent not available",
2169
+ });
1912
2170
  return;
1913
2171
  }
1914
2172
  let body;
@@ -1916,18 +2174,20 @@ export function createRequestHandler(deps) {
1916
2174
  body = JSON.parse(await readBody(req));
1917
2175
  }
1918
2176
  catch {
1919
- json(res, 400, { error: "Invalid JSON" });
2177
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1920
2178
  return;
1921
2179
  }
1922
2180
  const text = body.text;
1923
2181
  if (!text || typeof text !== "string") {
1924
- json(res, 400, { error: "Missing required field: text" });
2182
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2183
+ error: "Missing required field: text",
2184
+ });
1925
2185
  return;
1926
2186
  }
1927
2187
  const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
1928
2188
  const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
1929
2189
  const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
1930
- json(res, 202, { sessionId, streamUrl });
2190
+ json(res, HTTP_STATUS.ACCEPTED, { sessionId, streamUrl });
1931
2191
  // Fire-and-forget: send the prompt asynchronously, tracking busy state
1932
2192
  sessions.activePrompts.add(sessionId);
1933
2193
  sessions.syncBusy(sessionId);
@@ -1942,10 +2202,14 @@ export function createRequestHandler(deps) {
1942
2202
  sseManager.broadcast(titleEvent);
1943
2203
  });
1944
2204
  }
2205
+ const betaPromptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
2206
+ undefined;
1945
2207
  bridge
1946
- .prompt(sessionId, text)
2208
+ .prompt(sessionId, text, undefined, betaPromptId)
1947
2209
  .catch(() => { })
1948
2210
  .finally(() => {
2211
+ if (!sessions.isCurrentPrompt(sessionId, betaPromptId))
2212
+ return;
1949
2213
  sessions.activePrompts.delete(sessionId);
1950
2214
  sessions.syncBusy(sessionId);
1951
2215
  });
@@ -1963,7 +2227,7 @@ export function createRequestHandler(deps) {
1963
2227
  const clientKnown = sseManager.clients.has(clientId) ||
1964
2228
  deps.clientRegistry?.get(clientId) !== undefined;
1965
2229
  if (!clientKnown) {
1966
- json(res, 404, { error: "Client not found" });
2230
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Client not found" });
1967
2231
  return;
1968
2232
  }
1969
2233
  let body;
@@ -1971,11 +2235,13 @@ export function createRequestHandler(deps) {
1971
2235
  body = JSON.parse(await readBody(req));
1972
2236
  }
1973
2237
  catch {
1974
- json(res, 400, { error: "Invalid JSON" });
2238
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1975
2239
  return;
1976
2240
  }
1977
2241
  if (typeof body.visible !== "boolean") {
1978
- json(res, 400, { error: "Missing or invalid 'visible' field" });
2242
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2243
+ error: "Missing or invalid 'visible' field",
2244
+ });
1979
2245
  return;
1980
2246
  }
1981
2247
  // sessionId patch semantics: absent = preserve, null = clear,
@@ -2022,23 +2288,25 @@ export function createRequestHandler(deps) {
2022
2288
  }
2023
2289
  }
2024
2290
  }
2025
- json(res, 200, { ok: true });
2291
+ json(res, HTTP_STATUS.OK, { ok: true });
2026
2292
  return;
2027
2293
  }
2028
2294
  // --- Push notification routes ---
2029
2295
  // GET /api/beta/push/vapid-key
2030
2296
  if (url === "/api/beta/push/vapid-key" && req.method === "GET") {
2031
2297
  if (!deps.pushService) {
2032
- json(res, 404, { error: "Push not configured" });
2298
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Push not configured" });
2033
2299
  return;
2034
2300
  }
2035
- json(res, 200, { publicKey: deps.pushService.getPublicKey() });
2301
+ json(res, HTTP_STATUS.OK, {
2302
+ publicKey: deps.pushService.getPublicKey(),
2303
+ });
2036
2304
  return;
2037
2305
  }
2038
2306
  // POST /api/beta/push/subscribe
2039
2307
  if (url === "/api/beta/push/subscribe" && req.method === "POST") {
2040
2308
  if (!deps.pushService) {
2041
- json(res, 404, { error: "Push not configured" });
2309
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Push not configured" });
2042
2310
  return;
2043
2311
  }
2044
2312
  const chunks = [];
@@ -2049,11 +2317,13 @@ export function createRequestHandler(deps) {
2049
2317
  body = JSON.parse(Buffer.concat(chunks).toString());
2050
2318
  }
2051
2319
  catch {
2052
- json(res, 400, { error: "Invalid JSON" });
2320
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
2053
2321
  return;
2054
2322
  }
2055
2323
  if (!body.endpoint || !body.keys?.auth || !body.keys.p256dh) {
2056
- json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
2324
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2325
+ error: "Missing endpoint or keys (auth, p256dh)",
2326
+ });
2057
2327
  return;
2058
2328
  }
2059
2329
  store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
@@ -2061,13 +2331,13 @@ export function createRequestHandler(deps) {
2061
2331
  if (body.clientId && deps.pushService) {
2062
2332
  deps.pushService.registerClient(body.clientId, body.endpoint);
2063
2333
  }
2064
- json(res, 201, { ok: true });
2334
+ json(res, HTTP_STATUS.CREATED, { ok: true });
2065
2335
  return;
2066
2336
  }
2067
2337
  // POST /api/beta/push/register-client — associate clientId with push endpoint
2068
2338
  if (url === "/api/beta/push/register-client" && req.method === "POST") {
2069
2339
  if (!deps.pushService) {
2070
- json(res, 404, { error: "Push not configured" });
2340
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Push not configured" });
2071
2341
  return;
2072
2342
  }
2073
2343
  const chunks = [];
@@ -2078,21 +2348,23 @@ export function createRequestHandler(deps) {
2078
2348
  body = JSON.parse(Buffer.concat(chunks).toString());
2079
2349
  }
2080
2350
  catch {
2081
- json(res, 400, { error: "Invalid JSON" });
2351
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
2082
2352
  return;
2083
2353
  }
2084
2354
  if (!body.clientId || !body.endpoint) {
2085
- json(res, 400, { error: "Missing clientId or endpoint" });
2355
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2356
+ error: "Missing clientId or endpoint",
2357
+ });
2086
2358
  return;
2087
2359
  }
2088
2360
  deps.pushService.registerClient(body.clientId, body.endpoint);
2089
- json(res, 200, { ok: true });
2361
+ json(res, HTTP_STATUS.OK, { ok: true });
2090
2362
  return;
2091
2363
  }
2092
2364
  // POST /api/beta/push/unsubscribe
2093
2365
  if (url === "/api/beta/push/unsubscribe" && req.method === "POST") {
2094
2366
  if (!deps.pushService) {
2095
- json(res, 404, { error: "Push not configured" });
2367
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Push not configured" });
2096
2368
  return;
2097
2369
  }
2098
2370
  const chunks = [];
@@ -2103,26 +2375,26 @@ export function createRequestHandler(deps) {
2103
2375
  body = JSON.parse(Buffer.concat(chunks).toString());
2104
2376
  }
2105
2377
  catch {
2106
- json(res, 400, { error: "Invalid JSON" });
2378
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
2107
2379
  return;
2108
2380
  }
2109
2381
  if (body.endpoint) {
2110
2382
  store.removeSubscription(body.endpoint);
2111
2383
  }
2112
- json(res, 200, { ok: true });
2384
+ json(res, HTTP_STATUS.OK, { ok: true });
2113
2385
  return;
2114
2386
  }
2115
- json(res, 404, { error: "Not found" });
2387
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Not found" });
2116
2388
  return;
2117
2389
  }
2118
2390
  // --- Static files ---
2119
- let staticPath = url;
2391
+ let staticPath = url.split("?")[0] ?? url;
2120
2392
  const htmlEntry = HTML_ENTRYPOINTS.find((e) => e.urlPath === staticPath);
2121
2393
  if (htmlEntry)
2122
2394
  staticPath = "/" + htmlEntry.file;
2123
2395
  const filePath = join(deps.publicDir, staticPath);
2124
2396
  if (!filePath.startsWith(deps.publicDir)) {
2125
- res.writeHead(403);
2397
+ res.writeHead(HTTP_STATUS.FORBIDDEN);
2126
2398
  res.end("Forbidden");
2127
2399
  return;
2128
2400
  }
@@ -2145,11 +2417,11 @@ export function createRequestHandler(deps) {
2145
2417
  // CSP applies to HTML entrypoints (where script/style execute).
2146
2418
  if (htmlEntry)
2147
2419
  headers["Content-Security-Policy"] = CSP_POLICY;
2148
- res.writeHead(200, headers);
2420
+ res.writeHead(HTTP_STATUS.OK, headers);
2149
2421
  res.end(data);
2150
2422
  }
2151
2423
  catch {
2152
- res.writeHead(404);
2424
+ res.writeHead(HTTP_STATUS.NOT_FOUND);
2153
2425
  res.end("Not found");
2154
2426
  }
2155
2427
  };