@lelouchhe/webagent 0.5.1 → 0.7.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
@@ -10,6 +10,7 @@ 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,6 +19,7 @@ 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_-]+$/;
23
25
  const MIME = {
@@ -172,7 +174,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
172
174
  const { store, dataDir, limits, sessions } = deps;
173
175
  const fileUploadLimit = limits.file_upload ?? 52_428_800;
174
176
  if (!store.getSession(sessionId)) {
175
- json(res, 404, { error: "Session not found" });
177
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
176
178
  return;
177
179
  }
178
180
  const dir = join(dataDir, "sessions", sessionId, "attachments");
@@ -233,7 +235,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
233
235
  });
234
236
  }
235
237
  catch {
236
- void finish(400, { error: "Invalid multipart" });
238
+ void finish(HTTP_STATUS.BAD_REQUEST, { error: "Invalid multipart" });
237
239
  return;
238
240
  }
239
241
  bb.on("file", (fieldName, stream, info) => {
@@ -246,7 +248,9 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
246
248
  sawFile = true;
247
249
  if (fieldName !== "file") {
248
250
  stream.resume();
249
- void finish(400, { error: "Unexpected field name" });
251
+ void finish(HTTP_STATUS.BAD_REQUEST, {
252
+ error: "Unexpected field name",
253
+ });
250
254
  return;
251
255
  }
252
256
  fileMime = (info.mimeType || "application/octet-stream").toLowerCase();
@@ -295,22 +299,28 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
295
299
  await writeDone;
296
300
  if (aborted) {
297
301
  await cleanupTmp();
298
- await finish(400, { error: "Upload aborted" });
302
+ await finish(HTTP_STATUS.BAD_REQUEST, { error: "Upload aborted" });
299
303
  return;
300
304
  }
301
305
  if (limitExceeded) {
302
306
  await cleanupTmp();
303
- await finish(413, { error: "Upload too large" });
307
+ await finish(HTTP_STATUS.PAYLOAD_TOO_LARGE, {
308
+ error: "Upload too large",
309
+ });
304
310
  return;
305
311
  }
306
312
  if (writeError) {
307
313
  await cleanupTmp();
308
- await finish(500, { error: "Upload failed" });
314
+ await finish(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
315
+ error: "Upload failed",
316
+ });
309
317
  return;
310
318
  }
311
319
  if (!sawFile || !tmpPath || !finalPath || !displayName) {
312
320
  await cleanupTmp();
313
- await finish(400, { error: "Missing file part" });
321
+ await finish(HTTP_STATUS.BAD_REQUEST, {
322
+ error: "Missing file part",
323
+ });
314
324
  return;
315
325
  }
316
326
  // Sniff the actual mime from file content (magic bytes + UTF-8
@@ -362,7 +372,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
362
372
  const fileUrl = deps.attachmentSecret
363
373
  ? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
364
374
  : basePath;
365
- await finish(200, {
375
+ await finish(HTTP_STATUS.OK, {
366
376
  attachmentId: row.id,
367
377
  displayName: row.name,
368
378
  mimeType: row.mime,
@@ -376,7 +386,9 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
376
386
  }
377
387
  catch (err) {
378
388
  await cleanupTmp();
379
- await finish(500, { error: errorMessage(err) });
389
+ await finish(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
390
+ error: errorMessage(err),
391
+ });
380
392
  }
381
393
  })();
382
394
  });
@@ -395,7 +407,7 @@ export function createRequestHandler(deps) {
395
407
  if (!isWhitelistedPath(method, path)) {
396
408
  const result = authenticate(req.headers, deps.authStore);
397
409
  if (!result.ok) {
398
- res.writeHead(401, {
410
+ res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
399
411
  "Content-Type": "application/json",
400
412
  "WWW-Authenticate": "Bearer",
401
413
  });
@@ -427,7 +439,7 @@ export function createRequestHandler(deps) {
427
439
  res.setHeader("Content-Type", "application/json");
428
440
  // GET /api/v1 — discovery endpoint
429
441
  if (url === "/api/v1" && req.method === "GET") {
430
- json(res, 200, {
442
+ json(res, HTTP_STATUS.OK, {
431
443
  version: "v1",
432
444
  endpoints: {
433
445
  sessions: "/api/v1/sessions",
@@ -452,7 +464,7 @@ export function createRequestHandler(deps) {
452
464
  }
453
465
  // --- GET /api/v1/config ---
454
466
  if (url === "/api/v1/config" && req.method === "GET") {
455
- json(res, 200, {
467
+ json(res, HTTP_STATUS.OK, {
456
468
  configOptions: sessions?.cachedConfigOptions ?? [],
457
469
  cancelTimeout: deps.limits.cancel_timeout ?? 0,
458
470
  recentPathsLimit: deps.limits.recent_paths ?? 10,
@@ -469,12 +481,12 @@ export function createRequestHandler(deps) {
469
481
  limit: isNaN(limit) ? 0 : limit,
470
482
  ttlDays,
471
483
  });
472
- json(res, 200, paths);
484
+ json(res, HTTP_STATUS.OK, paths);
473
485
  return;
474
486
  }
475
487
  // GET /api/v1/version
476
488
  if (url === "/api/v1/version" && req.method === "GET") {
477
- json(res, 200, {
489
+ json(res, HTTP_STATUS.OK, {
478
490
  server: deps.serverVersion ?? "unknown",
479
491
  agent: sessions?.agentInfo ?? null,
480
492
  });
@@ -484,10 +496,10 @@ export function createRequestHandler(deps) {
484
496
  if (url === "/api/v1/auth/verify" && req.method === "GET") {
485
497
  const principal = principalByRequest.get(req);
486
498
  if (!principal) {
487
- json(res, 401, { error: "Unauthorized" });
499
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
488
500
  return;
489
501
  }
490
- json(res, 200, {
502
+ json(res, HTTP_STATUS.OK, {
491
503
  ok: true,
492
504
  name: principal.name,
493
505
  scope: principal.scope,
@@ -498,18 +510,20 @@ export function createRequestHandler(deps) {
498
510
  if (url === "/api/v1/sse-ticket" && req.method === "POST") {
499
511
  const principal = principalByRequest.get(req);
500
512
  if (!principal) {
501
- json(res, 401, { error: "Unauthorized" });
513
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
502
514
  return;
503
515
  }
504
516
  if (!deps.ticketStore) {
505
- json(res, 501, { error: "SSE not available" });
517
+ json(res, HTTP_STATUS.NOT_IMPLEMENTED, {
518
+ error: "SSE not available",
519
+ });
506
520
  return;
507
521
  }
508
522
  const ticket = deps.ticketStore.mint({
509
523
  tokenName: principal.name,
510
524
  scope: principal.scope,
511
525
  });
512
- json(res, 200, { ticket, expiresIn: 60 });
526
+ json(res, HTTP_STATUS.OK, { ticket, expiresIn: 60 });
513
527
  return;
514
528
  }
515
529
  // --- Token management (admin scope) ---
@@ -519,7 +533,7 @@ export function createRequestHandler(deps) {
519
533
  if (url === "/api/v1/tokens" && req.method === "GET") {
520
534
  const principal = principalByRequest.get(req);
521
535
  if (!deps.authStore || !principal) {
522
- json(res, 401, { error: "Unauthorized" });
536
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
523
537
  return;
524
538
  }
525
539
  const all = deps.authStore.list();
@@ -533,18 +547,18 @@ export function createRequestHandler(deps) {
533
547
  lastUsedAt: t.lastUsedAt,
534
548
  isSelf: t.name === principal.name,
535
549
  }));
536
- json(res, 200, list);
550
+ json(res, HTTP_STATUS.OK, list);
537
551
  return;
538
552
  }
539
553
  // POST /api/v1/tokens — create new api-scope token, return raw value once
540
554
  if (url === "/api/v1/tokens" && req.method === "POST") {
541
555
  const principal = principalByRequest.get(req);
542
556
  if (!deps.authStore || !principal) {
543
- json(res, 401, { error: "Unauthorized" });
557
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
544
558
  return;
545
559
  }
546
560
  if (principal.scope !== "admin") {
547
- json(res, 403, { error: "Forbidden" });
561
+ json(res, HTTP_STATUS.FORBIDDEN, { error: "Forbidden" });
548
562
  return;
549
563
  }
550
564
  let body;
@@ -552,13 +566,13 @@ export function createRequestHandler(deps) {
552
566
  body = JSON.parse(await readBody(req));
553
567
  }
554
568
  catch {
555
- json(res, 400, { error: "Invalid JSON" });
569
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
556
570
  return;
557
571
  }
558
572
  const name = typeof body.name === "string" ? body.name : "";
559
573
  try {
560
574
  const created = await deps.authStore.addToken(name, "api");
561
- json(res, 201, {
575
+ json(res, HTTP_STATUS.CREATED, {
562
576
  token: created.token,
563
577
  name: created.record.name,
564
578
  scope: created.record.scope,
@@ -567,10 +581,10 @@ export function createRequestHandler(deps) {
567
581
  catch (err) {
568
582
  const msg = errorMessage(err);
569
583
  if (/already exists|duplicate/i.test(msg)) {
570
- json(res, 409, { error: msg });
584
+ json(res, HTTP_STATUS.CONFLICT, { error: msg });
571
585
  }
572
586
  else {
573
- json(res, 400, { error: msg });
587
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: msg });
574
588
  }
575
589
  }
576
590
  return;
@@ -580,33 +594,35 @@ export function createRequestHandler(deps) {
580
594
  if (tokenDelMatch && req.method === "DELETE") {
581
595
  const principal = principalByRequest.get(req);
582
596
  if (!deps.authStore || !principal) {
583
- json(res, 401, { error: "Unauthorized" });
597
+ json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
584
598
  return;
585
599
  }
586
600
  if (principal.scope !== "admin") {
587
- json(res, 403, { error: "Forbidden" });
601
+ json(res, HTTP_STATUS.FORBIDDEN, { error: "Forbidden" });
588
602
  return;
589
603
  }
590
604
  const name = decodeURIComponent(tokenDelMatch[1]);
591
605
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
592
- json(res, 400, { error: "Invalid token name" });
606
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid token name" });
593
607
  return;
594
608
  }
595
609
  if (name === principal.name) {
596
- json(res, 400, { error: "Cannot revoke the token you are using" });
610
+ json(res, HTTP_STATUS.BAD_REQUEST, {
611
+ error: "Cannot revoke the token you are using",
612
+ });
597
613
  return;
598
614
  }
599
615
  try {
600
616
  const ok = await deps.authStore.revokeToken(name);
601
617
  if (!ok) {
602
- json(res, 404, { error: "Token not found" });
618
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Token not found" });
603
619
  return;
604
620
  }
605
- res.writeHead(204);
621
+ res.writeHead(HTTP_STATUS.NO_CONTENT);
606
622
  res.end();
607
623
  }
608
624
  catch (err) {
609
- json(res, 400, { error: errorMessage(err) });
625
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: errorMessage(err) });
610
626
  }
611
627
  return;
612
628
  }
@@ -614,19 +630,23 @@ export function createRequestHandler(deps) {
614
630
  if (url === "/api/v1/bridge/reload" && req.method === "POST") {
615
631
  const bridge = getBridge?.();
616
632
  if (!bridge) {
617
- json(res, 503, { error: "Agent not ready yet" });
633
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
634
+ error: "Agent not ready yet",
635
+ });
618
636
  return;
619
637
  }
620
638
  if (bridge.reloading) {
621
- json(res, 409, { error: "Already reloading" });
639
+ json(res, HTTP_STATUS.CONFLICT, { error: "Already reloading" });
622
640
  return;
623
641
  }
624
642
  try {
625
643
  await bridge.restart(sessions, titleService);
626
- json(res, 200, { ok: true });
644
+ json(res, HTTP_STATUS.OK, { ok: true });
627
645
  }
628
646
  catch (err) {
629
- json(res, 500, { error: errorMessage(err) });
647
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
648
+ error: errorMessage(err),
649
+ });
630
650
  }
631
651
  return;
632
652
  }
@@ -636,7 +656,7 @@ export function createRequestHandler(deps) {
636
656
  if (permListMatch && req.method === "GET") {
637
657
  const sessionId = decodeURIComponent(permListMatch[1]);
638
658
  const perms = sessions?.getPendingPermissions(sessionId) ?? [];
639
- json(res, 200, perms);
659
+ json(res, HTTP_STATUS.OK, perms);
640
660
  return;
641
661
  }
642
662
  // POST /api/v1/sessions/:id/permissions/:reqId
@@ -649,16 +669,18 @@ export function createRequestHandler(deps) {
649
669
  return;
650
670
  const perm = sessions?.pendingPermissions.get(requestId);
651
671
  if (!perm) {
652
- json(res, 404, { error: "Permission not found" });
672
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Permission not found" });
653
673
  return;
654
674
  }
655
675
  if (perm.sessionId !== sessionId) {
656
- json(res, 400, { error: "Session ID mismatch" });
676
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Session ID mismatch" });
657
677
  return;
658
678
  }
659
679
  const bridge = getBridge?.();
660
680
  if (!bridge) {
661
- json(res, 503, { error: "Agent not ready yet" });
681
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
682
+ error: "Agent not ready yet",
683
+ });
662
684
  return;
663
685
  }
664
686
  let body;
@@ -666,11 +688,13 @@ export function createRequestHandler(deps) {
666
688
  body = JSON.parse(await readBody(req));
667
689
  }
668
690
  catch {
669
- json(res, 400, { error: "Invalid JSON" });
691
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
670
692
  return;
671
693
  }
672
694
  if (!body.optionId && !body.denied) {
673
- json(res, 400, { error: "Provide optionId or denied:true" });
695
+ json(res, HTTP_STATUS.BAD_REQUEST, {
696
+ error: "Provide optionId or denied:true",
697
+ });
674
698
  return;
675
699
  }
676
700
  const denied = Boolean(body.denied);
@@ -699,8 +723,8 @@ export function createRequestHandler(deps) {
699
723
  void deps.pushService.sendClose(`sess-${perm.sessionId}-perm-${requestId}`);
700
724
  }
701
725
  const okBody = { ok: true };
702
- saveClientOpResult(store, opId, sessionId, 200, okBody);
703
- json(res, 200, okBody);
726
+ saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, okBody);
727
+ json(res, HTTP_STATUS.OK, okBody);
704
728
  return;
705
729
  }
706
730
  // --- POST /api/v1/sessions/:id/cancel ---
@@ -709,12 +733,14 @@ export function createRequestHandler(deps) {
709
733
  const sessionId = decodeURIComponent(cancelMatch[1]);
710
734
  const session = store.getSession(sessionId);
711
735
  if (!session) {
712
- json(res, 404, { error: "Session not found" });
736
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
713
737
  return;
714
738
  }
715
739
  const bridge = getBridge?.();
716
740
  if (!bridge) {
717
- json(res, 503, { error: "Agent not ready yet" });
741
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
742
+ error: "Agent not ready yet",
743
+ });
718
744
  return;
719
745
  }
720
746
  const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
@@ -739,8 +765,8 @@ export function createRequestHandler(deps) {
739
765
  sessions.state.armCancelSafety(sessionId, cancelTimeout);
740
766
  sessions?.syncBusy(sessionId);
741
767
  const okBody = { ok: true };
742
- saveClientOpResult(store, opId, sessionId, 200, okBody);
743
- json(res, 200, okBody);
768
+ saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, okBody);
769
+ json(res, HTTP_STATUS.OK, okBody);
744
770
  return;
745
771
  }
746
772
  // --- GET /api/v1/sessions/:id/status ---
@@ -749,12 +775,12 @@ export function createRequestHandler(deps) {
749
775
  const sessionId = decodeURIComponent(statusMatch[1]);
750
776
  const session = store.getSession(sessionId);
751
777
  if (!session) {
752
- json(res, 404, { error: "Session not found" });
778
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
753
779
  return;
754
780
  }
755
781
  const busyKind = sessions?.getBusyKind(sessionId) ?? null;
756
782
  const pendingPerms = sessions?.getPendingPermissions(sessionId) ?? [];
757
- json(res, 200, {
783
+ json(res, HTTP_STATUS.OK, {
758
784
  busy: busyKind != null,
759
785
  busyKind,
760
786
  pendingPermissions: pendingPerms,
@@ -771,20 +797,37 @@ export function createRequestHandler(deps) {
771
797
  const sessionId = decodeURIComponent(snapshotMatch[1]);
772
798
  const session = store.getSession(sessionId);
773
799
  if (!session) {
774
- json(res, 404, { error: "Session not found" });
800
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
775
801
  return;
776
802
  }
777
803
  if (!sessions) {
778
- json(res, 503, { error: "Session manager not available" });
804
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
805
+ error: "Session manager not available",
806
+ });
779
807
  return;
780
808
  }
809
+ const bridge = getBridge?.();
810
+ if (bridge && !sessions.liveSessions.has(sessionId)) {
811
+ try {
812
+ // Command discovery happens during session/load. Snapshot is the
813
+ // authoritative hydration boundary, so it must join any in-flight
814
+ // restore before reading the per-session command state.
815
+ await sessions.ensureResumed(bridge, sessionId);
816
+ }
817
+ catch {
818
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
819
+ error: "Failed to restore session",
820
+ });
821
+ return;
822
+ }
823
+ }
781
824
  // Make sure runtime reflects the current activePrompts/bash state even
782
825
  // if no patch has been emitted yet for this session.
783
826
  sessions.syncBusy(sessionId);
784
827
  sessions.syncPendingPermissions(sessionId);
785
828
  const runtimeState = sessions.state.getState(sessionId);
786
829
  const lastEventSeq = store.getLastEventSeq(sessionId);
787
- json(res, 200, {
830
+ json(res, HTTP_STATUS.OK, {
788
831
  version: 1,
789
832
  seq: runtimeState.seq,
790
833
  session: {
@@ -797,6 +840,7 @@ export function createRequestHandler(deps) {
797
840
  lastEventSeq,
798
841
  },
799
842
  runtime: runtimeState.runtime,
843
+ agentCommands: sessions.getAgentCommands(sessionId),
800
844
  }, req);
801
845
  return;
802
846
  }
@@ -809,32 +853,36 @@ export function createRequestHandler(deps) {
809
853
  if (!session) {
810
854
  logPromptRejectBeforeSave({
811
855
  sessionId,
812
- status: 404,
856
+ status: HTTP_STATUS.NOT_FOUND,
813
857
  reason: "session_not_found",
814
858
  opId: requestOpId,
815
859
  });
816
- json(res, 404, { error: "Session not found" });
860
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
817
861
  return;
818
862
  }
819
863
  const bridge = getBridge?.();
820
864
  if (!bridge) {
821
865
  logPromptRejectBeforeSave({
822
866
  sessionId,
823
- status: 503,
867
+ status: HTTP_STATUS.SERVICE_UNAVAILABLE,
824
868
  reason: "agent_not_ready",
825
869
  opId: requestOpId,
826
870
  });
827
- json(res, 503, { error: "Agent not ready yet" });
871
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
872
+ error: "Agent not ready yet",
873
+ });
828
874
  return;
829
875
  }
830
876
  if (!sessions) {
831
877
  logPromptRejectBeforeSave({
832
878
  sessionId,
833
- status: 503,
879
+ status: HTTP_STATUS.SERVICE_UNAVAILABLE,
834
880
  reason: "session_manager_unavailable",
835
881
  opId: requestOpId,
836
882
  });
837
- json(res, 503, { error: "Session manager not available" });
883
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
884
+ error: "Session manager not available",
885
+ });
838
886
  return;
839
887
  }
840
888
  const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
@@ -847,12 +895,12 @@ export function createRequestHandler(deps) {
847
895
  catch (err) {
848
896
  logPromptRejectBeforeSave({
849
897
  sessionId,
850
- status: 500,
898
+ status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
851
899
  reason: "resume_failed",
852
900
  opId,
853
901
  error: errorMessage(err),
854
902
  });
855
- json(res, 500, {
903
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
856
904
  error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
857
905
  });
858
906
  return;
@@ -862,12 +910,15 @@ export function createRequestHandler(deps) {
862
910
  if (busyKind) {
863
911
  logPromptRejectBeforeSave({
864
912
  sessionId,
865
- status: 409,
913
+ status: HTTP_STATUS.CONFLICT,
866
914
  reason: "session_busy",
867
915
  opId,
868
916
  busyKind,
869
917
  });
870
- json(res, 409, { error: "Session is busy", busyKind });
918
+ json(res, HTTP_STATUS.CONFLICT, {
919
+ error: "Session is busy",
920
+ busyKind,
921
+ });
871
922
  return;
872
923
  }
873
924
  let body;
@@ -877,17 +928,17 @@ export function createRequestHandler(deps) {
877
928
  catch {
878
929
  logPromptRejectBeforeSave({
879
930
  sessionId,
880
- status: 400,
931
+ status: HTTP_STATUS.BAD_REQUEST,
881
932
  reason: "invalid_json",
882
933
  opId,
883
934
  });
884
- json(res, 400, { error: "Invalid JSON" });
935
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
885
936
  return;
886
937
  }
887
938
  if (!body.text) {
888
939
  logPromptRejectBeforeSave({
889
940
  sessionId,
890
- status: 400,
941
+ status: HTTP_STATUS.BAD_REQUEST,
891
942
  reason: "missing_text",
892
943
  opId,
893
944
  textLength: 0,
@@ -895,7 +946,9 @@ export function createRequestHandler(deps) {
895
946
  ? body.attachments.length
896
947
  : undefined,
897
948
  });
898
- json(res, 400, { error: "Missing required field: text" });
949
+ json(res, HTTP_STATUS.BAD_REQUEST, {
950
+ error: "Missing required field: text",
951
+ });
899
952
  return;
900
953
  }
901
954
  // Validate attachment shape: client must NEVER supply uri/data/path,
@@ -906,12 +959,14 @@ export function createRequestHandler(deps) {
906
959
  if (!Array.isArray(attachments)) {
907
960
  logPromptRejectBeforeSave({
908
961
  sessionId,
909
- status: 400,
962
+ status: HTTP_STATUS.BAD_REQUEST,
910
963
  reason: "attachments_not_array",
911
964
  opId,
912
965
  textLength: body.text.length,
913
966
  });
914
- json(res, 400, { error: "attachments must be an array" });
967
+ json(res, HTTP_STATUS.BAD_REQUEST, {
968
+ error: "attachments must be an array",
969
+ });
915
970
  return;
916
971
  }
917
972
  for (const raw of attachments) {
@@ -924,13 +979,15 @@ export function createRequestHandler(deps) {
924
979
  typeof att.mimeType !== "string") {
925
980
  logPromptRejectBeforeSave({
926
981
  sessionId,
927
- status: 400,
982
+ status: HTTP_STATUS.BAD_REQUEST,
928
983
  reason: "invalid_attachment_entry",
929
984
  opId,
930
985
  textLength: body.text.length,
931
986
  attachmentCount: attachments.length,
932
987
  });
933
- json(res, 400, { error: "Invalid attachment entry" });
988
+ json(res, HTTP_STATUS.BAD_REQUEST, {
989
+ error: "Invalid attachment entry",
990
+ });
934
991
  return;
935
992
  }
936
993
  if (typeof att.uri === "string" ||
@@ -940,19 +997,41 @@ export function createRequestHandler(deps) {
940
997
  typeof att.height === "number") {
941
998
  logPromptRejectBeforeSave({
942
999
  sessionId,
943
- status: 400,
1000
+ status: HTTP_STATUS.BAD_REQUEST,
944
1001
  reason: "client_supplied_attachment_data",
945
1002
  opId,
946
1003
  textLength: body.text.length,
947
1004
  attachmentCount: attachments.length,
948
1005
  });
949
- json(res, 400, {
1006
+ json(res, HTTP_STATUS.BAD_REQUEST, {
950
1007
  error: "Client must not supply uri/data/path/width/height",
951
1008
  });
952
1009
  return;
953
1010
  }
954
1011
  }
955
1012
  }
1013
+ let agentText = body.text;
1014
+ if (body.text.startsWith("//")) {
1015
+ const resolved = resolveAgentCommand(body.text, sessions.getAgentCommands(sessionId).commands);
1016
+ if (!resolved) {
1017
+ const command = agentCommandToken(body.text);
1018
+ logPromptRejectBeforeSave({
1019
+ sessionId,
1020
+ status: HTTP_STATUS.UNPROCESSABLE_CONTENT,
1021
+ reason: "unknown_command",
1022
+ opId,
1023
+ textLength: body.text.length,
1024
+ attachmentCount: attachments?.length,
1025
+ });
1026
+ json(res, HTTP_STATUS.UNPROCESSABLE_CONTENT, {
1027
+ error: "Unknown command",
1028
+ command,
1029
+ prefix: "//",
1030
+ });
1031
+ return;
1032
+ }
1033
+ agentText = resolved.agentText;
1034
+ }
956
1035
  // Stored shape mirrors the wire shape PLUS a server-derived `path`
957
1036
  // for renderers. The path is the unsigned base URL
958
1037
  // (`/api/v1/sessions/<sid>/attachments/<filename>`); reSign on
@@ -1010,7 +1089,7 @@ export function createRequestHandler(deps) {
1010
1089
  sessions.activePrompts.add(sessionId);
1011
1090
  sessions.syncBusy(sessionId);
1012
1091
  bridge
1013
- .prompt(sessionId, body.text, attachments)
1092
+ .prompt(sessionId, agentText, attachments)
1014
1093
  .catch((err) => {
1015
1094
  plog.error("error", { sessionId, error: err });
1016
1095
  })
@@ -1019,8 +1098,8 @@ export function createRequestHandler(deps) {
1019
1098
  sessions.syncBusy(sessionId);
1020
1099
  });
1021
1100
  const acceptedBody = { status: "accepted" };
1022
- saveClientOpResult(store, opId, sessionId, 202, acceptedBody);
1023
- json(res, 202, acceptedBody);
1101
+ saveClientOpResult(store, opId, sessionId, HTTP_STATUS.ACCEPTED, acceptedBody);
1102
+ json(res, HTTP_STATUS.ACCEPTED, acceptedBody);
1024
1103
  return;
1025
1104
  }
1026
1105
  // --- POST /api/v1/sessions/:id/bash ---
@@ -1029,15 +1108,17 @@ export function createRequestHandler(deps) {
1029
1108
  const sessionId = decodeURIComponent(bashMatch[1]);
1030
1109
  const session = store.getSession(sessionId);
1031
1110
  if (!session) {
1032
- json(res, 404, { error: "Session not found" });
1111
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1033
1112
  return;
1034
1113
  }
1035
1114
  if (!sessions) {
1036
- json(res, 503, { error: "Session manager not available" });
1115
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1116
+ error: "Session manager not available",
1117
+ });
1037
1118
  return;
1038
1119
  }
1039
1120
  if (sessions.runningBashProcs.has(sessionId)) {
1040
- json(res, 409, {
1121
+ json(res, HTTP_STATUS.CONFLICT, {
1041
1122
  error: "A bash command is already running in this session",
1042
1123
  });
1043
1124
  return;
@@ -1047,11 +1128,13 @@ export function createRequestHandler(deps) {
1047
1128
  body = JSON.parse(await readBody(req));
1048
1129
  }
1049
1130
  catch {
1050
- json(res, 400, { error: "Invalid JSON" });
1131
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1051
1132
  return;
1052
1133
  }
1053
1134
  if (!body.command) {
1054
- json(res, 400, { error: "Missing required field: command" });
1135
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1136
+ error: "Missing required field: command",
1137
+ });
1055
1138
  return;
1056
1139
  }
1057
1140
  const cwd = sessions.getSessionCwd(sessionId);
@@ -1128,7 +1211,7 @@ export function createRequestHandler(deps) {
1128
1211
  };
1129
1212
  sseManager.broadcast(bashErrEvent);
1130
1213
  });
1131
- json(res, 202, { status: "accepted" });
1214
+ json(res, HTTP_STATUS.ACCEPTED, { status: "accepted" });
1132
1215
  return;
1133
1216
  }
1134
1217
  // --- POST /api/v1/sessions/:id/bash/cancel ---
@@ -1137,27 +1220,32 @@ export function createRequestHandler(deps) {
1137
1220
  const sessionId = decodeURIComponent(bashCancelMatch[1]);
1138
1221
  const session = store.getSession(sessionId);
1139
1222
  if (!session) {
1140
- json(res, 404, { error: "Session not found" });
1223
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1141
1224
  return;
1142
1225
  }
1143
1226
  interruptBashProc(sessions?.runningBashProcs.get(sessionId));
1144
- json(res, 200, { ok: true });
1227
+ json(res, HTTP_STATUS.OK, { ok: true });
1145
1228
  return;
1146
1229
  }
1147
1230
  // --- PUT /api/v1/sessions/:id/{model,mode,reasoning-effort} ---
1148
- const configPutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/(model|mode|reasoning-effort)\/?$/);
1231
+ // --- PUT /api/v1/sessions/:id/config/:configId ---
1232
+ const legacyConfigPutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/(model|mode|reasoning-effort)\/?$/);
1233
+ const genericConfigPutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/config\/([^/]+)\/?$/);
1234
+ const configPutMatch = legacyConfigPutMatch ?? genericConfigPutMatch;
1149
1235
  if (configPutMatch && req.method === "PUT") {
1150
1236
  const sessionId = decodeURIComponent(configPutMatch[1]);
1151
- const configPath = configPutMatch[2];
1152
- const configId = configPath === "reasoning-effort" ? "reasoning_effort" : configPath;
1237
+ const configPath = decodeURIComponent(configPutMatch[2]);
1238
+ const configId = configPath.replace(/-/g, "_");
1153
1239
  const session = store.getSession(sessionId);
1154
1240
  if (!session) {
1155
- json(res, 404, { error: "Session not found" });
1241
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1156
1242
  return;
1157
1243
  }
1158
1244
  const bridge = getBridge?.();
1159
1245
  if (!bridge) {
1160
- json(res, 503, { error: "Agent not ready yet" });
1246
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1247
+ error: "Agent not ready yet",
1248
+ });
1161
1249
  return;
1162
1250
  }
1163
1251
  let body;
@@ -1165,17 +1253,21 @@ export function createRequestHandler(deps) {
1165
1253
  body = JSON.parse(await readBody(req));
1166
1254
  }
1167
1255
  catch {
1168
- json(res, 400, { error: "Invalid JSON" });
1256
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1169
1257
  return;
1170
1258
  }
1171
- if (!body.value) {
1172
- json(res, 400, { error: "Missing required field: value" });
1259
+ if (body.value === undefined) {
1260
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1261
+ error: "Missing required field: value",
1262
+ });
1173
1263
  return;
1174
1264
  }
1175
1265
  try {
1176
1266
  const configOptions = await bridge.setConfigOption(sessionId, configId, body.value);
1177
1267
  for (const opt of configOptions) {
1178
- store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
1268
+ if (typeof opt.currentValue === "string") {
1269
+ store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
1270
+ }
1179
1271
  }
1180
1272
  sseManager.broadcast({
1181
1273
  type: "config_option_update",
@@ -1188,10 +1280,10 @@ export function createRequestHandler(deps) {
1188
1280
  configId,
1189
1281
  value: body.value,
1190
1282
  });
1191
- json(res, 200, { configOptions });
1283
+ json(res, HTTP_STATUS.OK, { configOptions });
1192
1284
  }
1193
1285
  catch (err) {
1194
- json(res, 500, {
1286
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
1195
1287
  error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}`,
1196
1288
  });
1197
1289
  }
@@ -1203,7 +1295,7 @@ export function createRequestHandler(deps) {
1203
1295
  const sessionId = decodeURIComponent(titlePutMatch[1]);
1204
1296
  const session = store.getSession(sessionId);
1205
1297
  if (!session) {
1206
- json(res, 404, { error: "Session not found" });
1298
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1207
1299
  return;
1208
1300
  }
1209
1301
  let body;
@@ -1211,11 +1303,13 @@ export function createRequestHandler(deps) {
1211
1303
  body = JSON.parse(await readBody(req));
1212
1304
  }
1213
1305
  catch {
1214
- json(res, 400, { error: "Invalid JSON" });
1306
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1215
1307
  return;
1216
1308
  }
1217
1309
  if (!body.value) {
1218
- json(res, 400, { error: "Missing required field: value" });
1310
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1311
+ error: "Missing required field: value",
1312
+ });
1219
1313
  return;
1220
1314
  }
1221
1315
  store.updateSessionTitle(sessionId, body.value);
@@ -1230,7 +1324,7 @@ export function createRequestHandler(deps) {
1230
1324
  title: body.value,
1231
1325
  };
1232
1326
  sseManager.broadcast(titleEvent);
1233
- json(res, 200, { title: body.value });
1327
+ json(res, HTTP_STATUS.OK, { title: body.value });
1234
1328
  return;
1235
1329
  }
1236
1330
  // --- Session CRUD: /api/v1/sessions/:id ---
@@ -1243,7 +1337,7 @@ export function createRequestHandler(deps) {
1243
1337
  if (req.method === "GET") {
1244
1338
  const session = store.getSession(sessionId);
1245
1339
  if (!session) {
1246
- json(res, 404, { error: "Session not found" });
1340
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1247
1341
  return;
1248
1342
  }
1249
1343
  // Start resume in background (non-blocking) so the client gets metadata fast.
@@ -1272,7 +1366,9 @@ export function createRequestHandler(deps) {
1272
1366
  reasoning_effort: cur.reasoning_effort,
1273
1367
  };
1274
1368
  const override = stored[opt.id];
1275
- return override ? { ...opt, currentValue: override } : opt;
1369
+ return override && "options" in opt
1370
+ ? { ...opt, currentValue: override }
1371
+ : opt;
1276
1372
  });
1277
1373
  sseManager.broadcast({
1278
1374
  type: "config_option_update",
@@ -1339,12 +1435,14 @@ export function createRequestHandler(deps) {
1339
1435
  reasoning_effort: freshSession.reasoning_effort,
1340
1436
  };
1341
1437
  const override = stored[opt.id];
1342
- return override ? { ...opt, currentValue: override } : opt;
1438
+ return override && "options" in opt
1439
+ ? { ...opt, currentValue: override }
1440
+ : opt;
1343
1441
  });
1344
1442
  return opts;
1345
1443
  })()
1346
1444
  : [];
1347
- json(res, 200, {
1445
+ json(res, HTTP_STATUS.OK, {
1348
1446
  id: freshSession.id,
1349
1447
  cwd: freshSession.cwd,
1350
1448
  title: freshSession.title,
@@ -1359,7 +1457,7 @@ export function createRequestHandler(deps) {
1359
1457
  if (req.method === "DELETE") {
1360
1458
  const session = store.getSession(sessionId);
1361
1459
  if (!session) {
1362
- json(res, 404, { error: "Session not found" });
1460
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1363
1461
  return;
1364
1462
  }
1365
1463
  if (sessions) {
@@ -1369,7 +1467,7 @@ export function createRequestHandler(deps) {
1369
1467
  store.deleteSession(sessionId);
1370
1468
  }
1371
1469
  sseManager.broadcast({ type: "session_deleted", sessionId });
1372
- res.writeHead(204);
1470
+ res.writeHead(HTTP_STATUS.NO_CONTENT);
1373
1471
  res.end();
1374
1472
  return;
1375
1473
  }
@@ -1378,11 +1476,15 @@ export function createRequestHandler(deps) {
1378
1476
  if (url === "/api/v1/sessions" && req.method === "POST") {
1379
1477
  const bridge = getBridge?.();
1380
1478
  if (!bridge) {
1381
- json(res, 503, { error: "Agent not ready yet" });
1479
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1480
+ error: "Agent not ready yet",
1481
+ });
1382
1482
  return;
1383
1483
  }
1384
1484
  if (!sessions) {
1385
- json(res, 503, { error: "Session manager not available" });
1485
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1486
+ error: "Session manager not available",
1487
+ });
1386
1488
  return;
1387
1489
  }
1388
1490
  let body;
@@ -1390,7 +1492,7 @@ export function createRequestHandler(deps) {
1390
1492
  body = JSON.parse(await readBody(req));
1391
1493
  }
1392
1494
  catch {
1393
- json(res, 400, { error: "Invalid JSON" });
1495
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1394
1496
  return;
1395
1497
  }
1396
1498
  const source = body.source ?? "auto";
@@ -1403,6 +1505,7 @@ export function createRequestHandler(deps) {
1403
1505
  cwd: session?.cwd,
1404
1506
  title: session?.title,
1405
1507
  configOptions,
1508
+ agentCommands: sessions.getAgentCommands(sessionId),
1406
1509
  };
1407
1510
  sseManager.broadcast(sessionCreatedEvent);
1408
1511
  // ACP's session_created event fires before inheritance runs, so
@@ -1414,21 +1517,22 @@ export function createRequestHandler(deps) {
1414
1517
  configOptions,
1415
1518
  });
1416
1519
  }
1417
- json(res, 201, {
1520
+ json(res, HTTP_STATUS.CREATED, {
1418
1521
  id: sessionId,
1419
1522
  cwd: session?.cwd ?? body.cwd,
1420
1523
  title: session?.title ?? null,
1421
1524
  source: session?.source ?? source,
1422
1525
  configOptions,
1526
+ agentCommands: sessions.getAgentCommands(sessionId),
1423
1527
  });
1424
1528
  }
1425
1529
  catch (err) {
1426
1530
  const msg = err instanceof Error ? err.message : String(err);
1427
1531
  if (msg.includes("does not exist")) {
1428
- json(res, 400, { error: msg });
1532
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: msg });
1429
1533
  }
1430
1534
  else {
1431
- json(res, 500, { error: msg });
1535
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: msg });
1432
1536
  }
1433
1537
  }
1434
1538
  return;
@@ -1450,7 +1554,7 @@ export function createRequestHandler(deps) {
1450
1554
  : undefined;
1451
1555
  const session = store.getSession(sessionId);
1452
1556
  if (!session) {
1453
- json(res, 404, { error: "Session not found" });
1557
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1454
1558
  return;
1455
1559
  }
1456
1560
  // Flush pending buffers so their content becomes part of the event list.
@@ -1511,7 +1615,7 @@ export function createRequestHandler(deps) {
1511
1615
  envelope.total = total;
1512
1616
  envelope.hasMore = hasMore;
1513
1617
  }
1514
- json(res, 200, envelope, req);
1618
+ json(res, HTTP_STATUS.OK, envelope, req);
1515
1619
  return;
1516
1620
  }
1517
1621
  // --- SSE stream endpoints ---
@@ -1525,13 +1629,15 @@ export function createRequestHandler(deps) {
1525
1629
  const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
1526
1630
  const principal = deps.ticketStore?.consume(ticket);
1527
1631
  if (!principal) {
1528
- json(res, 401, { error: "Invalid or expired ticket" });
1632
+ json(res, HTTP_STATUS.UNAUTHORIZED, {
1633
+ error: "Invalid or expired ticket",
1634
+ });
1529
1635
  return;
1530
1636
  }
1531
1637
  tokenName = principal.tokenName;
1532
1638
  }
1533
1639
  const clientId = sseManager.generateClientId();
1534
- res.writeHead(200, {
1640
+ res.writeHead(HTTP_STATUS.OK, {
1535
1641
  "Content-Type": "text/event-stream",
1536
1642
  "Cache-Control": "no-cache",
1537
1643
  Connection: "keep-alive",
@@ -1560,18 +1666,20 @@ export function createRequestHandler(deps) {
1560
1666
  const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
1561
1667
  const principal = deps.ticketStore?.consume(ticket);
1562
1668
  if (!principal) {
1563
- json(res, 401, { error: "Invalid or expired ticket" });
1669
+ json(res, HTTP_STATUS.UNAUTHORIZED, {
1670
+ error: "Invalid or expired ticket",
1671
+ });
1564
1672
  return;
1565
1673
  }
1566
1674
  tokenName = principal.tokenName;
1567
1675
  }
1568
1676
  const session = store.getSession(sessionId);
1569
1677
  if (!session) {
1570
- json(res, 404, { error: "Session not found" });
1678
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1571
1679
  return;
1572
1680
  }
1573
1681
  const clientId = sseManager.generateClientId();
1574
- res.writeHead(200, {
1682
+ res.writeHead(HTTP_STATUS.OK, {
1575
1683
  "Content-Type": "text/event-stream",
1576
1684
  "Cache-Control": "no-cache",
1577
1685
  Connection: "keep-alive",
@@ -1623,12 +1731,14 @@ export function createRequestHandler(deps) {
1623
1731
  if (imgUploadMatch && req.method === "POST") {
1624
1732
  const sessionId = decodeURIComponent(imgUploadMatch[1]);
1625
1733
  if (!SAFE_ID.test(sessionId)) {
1626
- json(res, 400, { error: "Invalid session ID" });
1734
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid session ID" });
1627
1735
  return;
1628
1736
  }
1629
1737
  const ctype = req.headers["content-type"] ?? "";
1630
1738
  if (!ctype.toLowerCase().startsWith("multipart/form-data")) {
1631
- json(res, 400, { error: "Expected multipart/form-data" });
1739
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1740
+ error: "Expected multipart/form-data",
1741
+ });
1632
1742
  return;
1633
1743
  }
1634
1744
  await handleAttachmentUpload(req, res, sessionId, deps);
@@ -1651,14 +1761,16 @@ export function createRequestHandler(deps) {
1651
1761
  const exp = params.get("exp") ?? "";
1652
1762
  const basePath = `/api/v1/sessions/${sessionId}/attachments/${file}`;
1653
1763
  if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
1654
- res.writeHead(401, { "Content-Type": "application/json" });
1764
+ res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
1765
+ "Content-Type": "application/json",
1766
+ });
1655
1767
  res.end(JSON.stringify({ error: "Unauthorized" }));
1656
1768
  return;
1657
1769
  }
1658
1770
  }
1659
1771
  const filePath = join(deps.dataDir, "sessions", sessionId, "attachments", file);
1660
1772
  if (!filePath.startsWith(join(deps.dataDir, "sessions"))) {
1661
- res.writeHead(403);
1773
+ res.writeHead(HTTP_STATUS.FORBIDDEN);
1662
1774
  res.end("Forbidden");
1663
1775
  return;
1664
1776
  }
@@ -1679,11 +1791,11 @@ export function createRequestHandler(deps) {
1679
1791
  const disposition = isInlineMime(mime) ? "inline" : "attachment";
1680
1792
  headers["Content-Disposition"] = buildContentDisposition(disposition, row.name);
1681
1793
  }
1682
- res.writeHead(200, headers);
1794
+ res.writeHead(HTTP_STATUS.OK, headers);
1683
1795
  res.end(fileData);
1684
1796
  }
1685
1797
  catch {
1686
- res.writeHead(404);
1798
+ res.writeHead(HTTP_STATUS.NOT_FOUND);
1687
1799
  res.end("Not found");
1688
1800
  }
1689
1801
  return;
@@ -1702,7 +1814,7 @@ export function createRequestHandler(deps) {
1702
1814
  raw = await readBody(req);
1703
1815
  }
1704
1816
  catch {
1705
- json(res, 400, { error: "Failed to read body" });
1817
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Failed to read body" });
1706
1818
  return;
1707
1819
  }
1708
1820
  let parsed;
@@ -1710,12 +1822,12 @@ export function createRequestHandler(deps) {
1710
1822
  parsed = JSON.parse(raw);
1711
1823
  }
1712
1824
  catch {
1713
- json(res, 400, { error: "Invalid JSON" });
1825
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1714
1826
  return;
1715
1827
  }
1716
1828
  const validation = MessageIngressSchema.safeParse(parsed);
1717
1829
  if (!validation.success) {
1718
- json(res, 400, {
1830
+ json(res, HTTP_STATUS.BAD_REQUEST, {
1719
1831
  error: "Invalid body",
1720
1832
  issues: validation.error.issues,
1721
1833
  });
@@ -1727,7 +1839,7 @@ export function createRequestHandler(deps) {
1727
1839
  const targetSid = input.to.slice("session:".length);
1728
1840
  const session = store.getSession(targetSid);
1729
1841
  if (!session) {
1730
- json(res, 400, { error: "session_not_found" });
1842
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "session_not_found" });
1731
1843
  return;
1732
1844
  }
1733
1845
  sessions?.flushBuffers(targetSid);
@@ -1763,8 +1875,8 @@ export function createRequestHandler(deps) {
1763
1875
  sess_id: targetSid.slice(0, 8),
1764
1876
  });
1765
1877
  const boundBody = { id, delivered: "session" };
1766
- saveClientOpResult(store, opId, "__ingress__", 200, boundBody);
1767
- json(res, 200, boundBody);
1878
+ saveClientOpResult(store, opId, "__ingress__", HTTP_STATUS.OK, boundBody);
1879
+ json(res, HTTP_STATUS.OK, boundBody);
1768
1880
  return;
1769
1881
  }
1770
1882
  // Unbound: to=user → rows in `messages` table
@@ -1810,13 +1922,13 @@ export function createRequestHandler(deps) {
1810
1922
  from_ref: input.from_ref,
1811
1923
  });
1812
1924
  const unboundBody = { id, delivered: "pending" };
1813
- saveClientOpResult(store, opId, "__ingress__", 200, unboundBody);
1814
- json(res, 200, unboundBody);
1925
+ saveClientOpResult(store, opId, "__ingress__", HTTP_STATUS.OK, unboundBody);
1926
+ json(res, HTTP_STATUS.OK, unboundBody);
1815
1927
  return;
1816
1928
  }
1817
1929
  // GET /api/v1/messages — list unprocessed
1818
1930
  if (url === "/api/v1/messages" && req.method === "GET") {
1819
- json(res, 200, { messages: store.listUnprocessed() });
1931
+ json(res, HTTP_STATUS.OK, { messages: store.listUnprocessed() });
1820
1932
  return;
1821
1933
  }
1822
1934
  // /api/v1/messages/:id... — GET single, POST :id/consume, POST :id/ack, DELETE :id
@@ -1832,7 +1944,7 @@ export function createRequestHandler(deps) {
1832
1944
  }
1833
1945
  catch (err) {
1834
1946
  if (/message not found/.test(errorMessage(err))) {
1835
- json(res, 404, { error: "Message not found" });
1947
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
1836
1948
  return;
1837
1949
  }
1838
1950
  throw err;
@@ -1850,7 +1962,7 @@ export function createRequestHandler(deps) {
1850
1962
  sess_id: out.sessionId.slice(0, 8),
1851
1963
  already_consumed: out.alreadyConsumed,
1852
1964
  });
1853
- json(res, 200, {
1965
+ json(res, HTTP_STATUS.OK, {
1854
1966
  sessionId: out.sessionId,
1855
1967
  alreadyConsumed: out.alreadyConsumed,
1856
1968
  });
@@ -1864,28 +1976,28 @@ export function createRequestHandler(deps) {
1864
1976
  const id = decodeURIComponent((ackPost ?? idOnly)[1]);
1865
1977
  const changes = store.deleteMessage(id);
1866
1978
  if (changes === 0) {
1867
- json(res, 404, { error: "Message not found" });
1979
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
1868
1980
  return;
1869
1981
  }
1870
1982
  sseManager.broadcast({ type: "message_acked", messageId: id });
1871
1983
  if (deps.pushService)
1872
1984
  void deps.pushService.sendClose(id);
1873
1985
  mlog.info("ack", { msg_id: id });
1874
- json(res, 200, { ok: true });
1986
+ json(res, HTTP_STATUS.OK, { ok: true });
1875
1987
  return;
1876
1988
  }
1877
1989
  if (idOnly && req.method === "GET") {
1878
1990
  const id = decodeURIComponent(idOnly[1]);
1879
1991
  const row = store.getMessage(id);
1880
1992
  if (!row) {
1881
- json(res, 404, { error: "Message not found" });
1993
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
1882
1994
  return;
1883
1995
  }
1884
- json(res, 200, row);
1996
+ json(res, HTTP_STATUS.OK, row);
1885
1997
  return;
1886
1998
  }
1887
1999
  }
1888
- json(res, 404, { error: "Not found" });
2000
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Not found" });
1889
2001
  return;
1890
2002
  }
1891
2003
  // --- Beta API routes ---
@@ -1894,12 +2006,16 @@ export function createRequestHandler(deps) {
1894
2006
  // POST /api/beta/prompt — quick one-shot prompt (create temp session + send)
1895
2007
  if (url === "/api/beta/prompt" && req.method === "POST") {
1896
2008
  if (!sessions || !getBridge) {
1897
- json(res, 503, { error: "Agent not available" });
2009
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
2010
+ error: "Agent not available",
2011
+ });
1898
2012
  return;
1899
2013
  }
1900
2014
  const bridge = getBridge();
1901
2015
  if (!bridge) {
1902
- json(res, 503, { error: "Agent not available" });
2016
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
2017
+ error: "Agent not available",
2018
+ });
1903
2019
  return;
1904
2020
  }
1905
2021
  let body;
@@ -1907,18 +2023,20 @@ export function createRequestHandler(deps) {
1907
2023
  body = JSON.parse(await readBody(req));
1908
2024
  }
1909
2025
  catch {
1910
- json(res, 400, { error: "Invalid JSON" });
2026
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1911
2027
  return;
1912
2028
  }
1913
2029
  const text = body.text;
1914
2030
  if (!text || typeof text !== "string") {
1915
- json(res, 400, { error: "Missing required field: text" });
2031
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2032
+ error: "Missing required field: text",
2033
+ });
1916
2034
  return;
1917
2035
  }
1918
2036
  const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
1919
2037
  const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
1920
2038
  const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
1921
- json(res, 202, { sessionId, streamUrl });
2039
+ json(res, HTTP_STATUS.ACCEPTED, { sessionId, streamUrl });
1922
2040
  // Fire-and-forget: send the prompt asynchronously, tracking busy state
1923
2041
  sessions.activePrompts.add(sessionId);
1924
2042
  sessions.syncBusy(sessionId);
@@ -1954,7 +2072,7 @@ export function createRequestHandler(deps) {
1954
2072
  const clientKnown = sseManager.clients.has(clientId) ||
1955
2073
  deps.clientRegistry?.get(clientId) !== undefined;
1956
2074
  if (!clientKnown) {
1957
- json(res, 404, { error: "Client not found" });
2075
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Client not found" });
1958
2076
  return;
1959
2077
  }
1960
2078
  let body;
@@ -1962,11 +2080,13 @@ export function createRequestHandler(deps) {
1962
2080
  body = JSON.parse(await readBody(req));
1963
2081
  }
1964
2082
  catch {
1965
- json(res, 400, { error: "Invalid JSON" });
2083
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
1966
2084
  return;
1967
2085
  }
1968
2086
  if (typeof body.visible !== "boolean") {
1969
- json(res, 400, { error: "Missing or invalid 'visible' field" });
2087
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2088
+ error: "Missing or invalid 'visible' field",
2089
+ });
1970
2090
  return;
1971
2091
  }
1972
2092
  // sessionId patch semantics: absent = preserve, null = clear,
@@ -2013,23 +2133,25 @@ export function createRequestHandler(deps) {
2013
2133
  }
2014
2134
  }
2015
2135
  }
2016
- json(res, 200, { ok: true });
2136
+ json(res, HTTP_STATUS.OK, { ok: true });
2017
2137
  return;
2018
2138
  }
2019
2139
  // --- Push notification routes ---
2020
2140
  // GET /api/beta/push/vapid-key
2021
2141
  if (url === "/api/beta/push/vapid-key" && req.method === "GET") {
2022
2142
  if (!deps.pushService) {
2023
- json(res, 404, { error: "Push not configured" });
2143
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Push not configured" });
2024
2144
  return;
2025
2145
  }
2026
- json(res, 200, { publicKey: deps.pushService.getPublicKey() });
2146
+ json(res, HTTP_STATUS.OK, {
2147
+ publicKey: deps.pushService.getPublicKey(),
2148
+ });
2027
2149
  return;
2028
2150
  }
2029
2151
  // POST /api/beta/push/subscribe
2030
2152
  if (url === "/api/beta/push/subscribe" && req.method === "POST") {
2031
2153
  if (!deps.pushService) {
2032
- json(res, 404, { error: "Push not configured" });
2154
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Push not configured" });
2033
2155
  return;
2034
2156
  }
2035
2157
  const chunks = [];
@@ -2040,11 +2162,13 @@ export function createRequestHandler(deps) {
2040
2162
  body = JSON.parse(Buffer.concat(chunks).toString());
2041
2163
  }
2042
2164
  catch {
2043
- json(res, 400, { error: "Invalid JSON" });
2165
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
2044
2166
  return;
2045
2167
  }
2046
2168
  if (!body.endpoint || !body.keys?.auth || !body.keys.p256dh) {
2047
- json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
2169
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2170
+ error: "Missing endpoint or keys (auth, p256dh)",
2171
+ });
2048
2172
  return;
2049
2173
  }
2050
2174
  store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
@@ -2052,13 +2176,13 @@ export function createRequestHandler(deps) {
2052
2176
  if (body.clientId && deps.pushService) {
2053
2177
  deps.pushService.registerClient(body.clientId, body.endpoint);
2054
2178
  }
2055
- json(res, 201, { ok: true });
2179
+ json(res, HTTP_STATUS.CREATED, { ok: true });
2056
2180
  return;
2057
2181
  }
2058
2182
  // POST /api/beta/push/register-client — associate clientId with push endpoint
2059
2183
  if (url === "/api/beta/push/register-client" && req.method === "POST") {
2060
2184
  if (!deps.pushService) {
2061
- json(res, 404, { error: "Push not configured" });
2185
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Push not configured" });
2062
2186
  return;
2063
2187
  }
2064
2188
  const chunks = [];
@@ -2069,21 +2193,23 @@ export function createRequestHandler(deps) {
2069
2193
  body = JSON.parse(Buffer.concat(chunks).toString());
2070
2194
  }
2071
2195
  catch {
2072
- json(res, 400, { error: "Invalid JSON" });
2196
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
2073
2197
  return;
2074
2198
  }
2075
2199
  if (!body.clientId || !body.endpoint) {
2076
- json(res, 400, { error: "Missing clientId or endpoint" });
2200
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2201
+ error: "Missing clientId or endpoint",
2202
+ });
2077
2203
  return;
2078
2204
  }
2079
2205
  deps.pushService.registerClient(body.clientId, body.endpoint);
2080
- json(res, 200, { ok: true });
2206
+ json(res, HTTP_STATUS.OK, { ok: true });
2081
2207
  return;
2082
2208
  }
2083
2209
  // POST /api/beta/push/unsubscribe
2084
2210
  if (url === "/api/beta/push/unsubscribe" && req.method === "POST") {
2085
2211
  if (!deps.pushService) {
2086
- json(res, 404, { error: "Push not configured" });
2212
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Push not configured" });
2087
2213
  return;
2088
2214
  }
2089
2215
  const chunks = [];
@@ -2094,16 +2220,16 @@ export function createRequestHandler(deps) {
2094
2220
  body = JSON.parse(Buffer.concat(chunks).toString());
2095
2221
  }
2096
2222
  catch {
2097
- json(res, 400, { error: "Invalid JSON" });
2223
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
2098
2224
  return;
2099
2225
  }
2100
2226
  if (body.endpoint) {
2101
2227
  store.removeSubscription(body.endpoint);
2102
2228
  }
2103
- json(res, 200, { ok: true });
2229
+ json(res, HTTP_STATUS.OK, { ok: true });
2104
2230
  return;
2105
2231
  }
2106
- json(res, 404, { error: "Not found" });
2232
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Not found" });
2107
2233
  return;
2108
2234
  }
2109
2235
  // --- Static files ---
@@ -2113,7 +2239,7 @@ export function createRequestHandler(deps) {
2113
2239
  staticPath = "/" + htmlEntry.file;
2114
2240
  const filePath = join(deps.publicDir, staticPath);
2115
2241
  if (!filePath.startsWith(deps.publicDir)) {
2116
- res.writeHead(403);
2242
+ res.writeHead(HTTP_STATUS.FORBIDDEN);
2117
2243
  res.end("Forbidden");
2118
2244
  return;
2119
2245
  }
@@ -2136,11 +2262,11 @@ export function createRequestHandler(deps) {
2136
2262
  // CSP applies to HTML entrypoints (where script/style execute).
2137
2263
  if (htmlEntry)
2138
2264
  headers["Content-Security-Policy"] = CSP_POLICY;
2139
- res.writeHead(200, headers);
2265
+ res.writeHead(HTTP_STATUS.OK, headers);
2140
2266
  res.end(data);
2141
2267
  }
2142
2268
  catch {
2143
- res.writeHead(404);
2269
+ res.writeHead(HTTP_STATUS.NOT_FOUND);
2144
2270
  res.end("Not found");
2145
2271
  }
2146
2272
  };