@lelouchhe/webagent 0.7.0 → 0.9.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,13 +4,15 @@ 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
+ import { handleFileRoutes } from "./files/routes.js";
11
12
  import { authenticate, isWhitelistedPath } from "./auth-middleware.js";
12
13
  import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
13
14
  import { agentCommandToken, resolveAgentCommand } from "./agent-commands.js";
15
+ import { abbreviateHomePath } from "./home-path.js";
14
16
  import { log } from "./log.js";
15
17
  const rlog = log.scope("routes");
16
18
  const plog = rlog.scope("prompt");
@@ -22,6 +24,12 @@ import { readImageDimensions } from "./image-dimensions.js";
22
24
  import { HTTP_STATUS } from "./http-status.js";
23
25
  const IS_WIN = process.platform === "win32";
24
26
  const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
27
+ function broadcastInboxCount(store, sseManager) {
28
+ sseManager.broadcastGlobal({
29
+ type: "inbox_count_changed",
30
+ pendingCount: store.countUnprocessed(),
31
+ });
32
+ }
25
33
  const MIME = {
26
34
  ".html": "text/html; charset=utf-8",
27
35
  ".js": "application/javascript; charset=utf-8",
@@ -397,6 +405,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
397
405
  }
398
406
  export function createRequestHandler(deps) {
399
407
  const { store, sessions, getBridge, sseManager, titleService } = deps;
408
+ let bootstrapSessionPromise = null;
400
409
  // eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
401
410
  return async (req, res) => {
402
411
  const url = req.url ?? "/";
@@ -434,6 +443,14 @@ export function createRequestHandler(deps) {
434
443
  }))) {
435
444
  return;
436
445
  }
446
+ // File viewer — sessionless read-only access to arbitrary local paths.
447
+ // Claims /api/v1/files/{info,list,content} before the generic /api/v1
448
+ // branch. info/list use the Bearer gate above; content is whitelisted
449
+ // only because its handler requires an HMAC-signed URL for headerless
450
+ // media/download fetches (see src/files/routes.ts).
451
+ if (await handleFileRoutes(req, res, { secret: deps.attachmentSecret })) {
452
+ return;
453
+ }
437
454
  // --- API routes ---
438
455
  if (url === "/api/v1" || url.startsWith("/api/v1/")) {
439
456
  res.setHeader("Content-Type", "application/json");
@@ -444,6 +461,7 @@ export function createRequestHandler(deps) {
444
461
  endpoints: {
445
462
  sessions: "/api/v1/sessions",
446
463
  paths: "/api/v1/recent-paths",
464
+ files: "/api/v1/files",
447
465
  config: "/api/v1/config",
448
466
  events_stream: "/api/v1/events/stream",
449
467
  prompt: "/api/beta/prompt",
@@ -481,7 +499,10 @@ export function createRequestHandler(deps) {
481
499
  limit: isNaN(limit) ? 0 : limit,
482
500
  ttlDays,
483
501
  });
484
- json(res, HTTP_STATUS.OK, paths);
502
+ json(res, HTTP_STATUS.OK, paths.map((entry) => ({
503
+ ...entry,
504
+ cwdDisplay: abbreviateHomePath(entry.cwd),
505
+ })));
485
506
  return;
486
507
  }
487
508
  // GET /api/v1/version
@@ -736,37 +757,85 @@ export function createRequestHandler(deps) {
736
757
  json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
737
758
  return;
738
759
  }
739
- const bridge = getBridge?.();
740
- if (!bridge) {
741
- json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
742
- error: "Agent not ready yet",
743
- });
744
- return;
745
- }
746
760
  const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
747
761
  if (replayed)
748
762
  return;
763
+ const hadAgentPrompt = sessions?.activePrompts.has(sessionId) ?? false;
764
+ const hadPendingPrompt = sessions?.cancelPendingPromptSubmission(sessionId) ?? false;
765
+ const hadBash = sessions?.runningBashProcs.has(sessionId) ?? false;
766
+ if (!hadAgentPrompt && !hadPendingPrompt && !hadBash) {
767
+ const idleBody = { ok: true, status: "idle" };
768
+ saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, idleBody);
769
+ json(res, HTTP_STATUS.OK, idleBody);
770
+ return;
771
+ }
772
+ const cancelledPromptId = hadAgentPrompt
773
+ ? (sessions?.state.getState(sessionId).runtime.busy?.promptId ?? null)
774
+ : null;
749
775
  // Kill running bash process if any
750
776
  const proc = sessions?.runningBashProcs.get(sessionId);
751
777
  if (proc) {
752
- interruptBashProc(proc);
753
- sessions.runningBashProcs.delete(sessionId);
778
+ const force = sessions.interruptedBashProcs.has(proc);
779
+ interruptBashProc(proc, force);
780
+ sessions.interruptedBashProcs.add(proc);
781
+ }
782
+ const bridge = hadAgentPrompt ? getBridge?.() : null;
783
+ if (hadAgentPrompt && !bridge) {
784
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
785
+ error: "Agent not ready yet",
786
+ });
787
+ return;
754
788
  }
755
- // Cancel agent prompt
756
- if (sessions?.activePrompts.has(sessionId)) {
789
+ // ACP cancel is a notification, not an acknowledgement. Keep the
790
+ // prompt active until its prompt response supplies the terminal stop
791
+ // reason, and allow repeated requests to resend the notification.
792
+ if (hadAgentPrompt && sessions && bridge) {
793
+ const previousCancelStatus = sessions.state.getState(sessionId).runtime.busy?.cancelStatus ??
794
+ null;
795
+ rlog.info("cancel requested", {
796
+ sessionId: sessionId.slice(0, 8),
797
+ retry: previousCancelStatus !== null,
798
+ previousStatus: previousCancelStatus,
799
+ });
757
800
  await bridge.cancel(sessionId);
758
- sessions.activePrompts.delete(sessionId);
801
+ const busy = sessions.state.getState(sessionId).runtime.busy;
802
+ const stillCancellingSamePrompt = sessions.activePrompts.has(sessionId) &&
803
+ busy?.kind === "agent" &&
804
+ busy.promptId === cancelledPromptId;
805
+ if (stillCancellingSamePrompt) {
806
+ sessions.state.markCancelRequested(sessionId);
807
+ }
759
808
  }
760
- // Arm backend safety net: if prompt_done doesn't arrive within the
761
- // configured timeout, force-clear busy so the UI unstalls. Replaces
762
- // the old frontend-side cancel timer.
809
+ // If prompt_done does not arrive, expose the lack of acknowledgement
810
+ // instead of pretending the prompt stopped.
763
811
  const cancelTimeout = deps.limits.cancel_timeout ?? 0;
764
- if (sessions && cancelTimeout > 0)
812
+ const busyAfterCancel = sessions?.state.getState(sessionId).runtime.busy;
813
+ const cancelPending = hadAgentPrompt &&
814
+ sessions?.activePrompts.has(sessionId) === true &&
815
+ busyAfterCancel?.kind === "agent" &&
816
+ busyAfterCancel.promptId === cancelledPromptId;
817
+ if (cancelPending && cancelTimeout > 0)
765
818
  sessions.state.armCancelSafety(sessionId, cancelTimeout);
766
819
  sessions?.syncBusy(sessionId);
767
- const okBody = { ok: true };
768
- saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, okBody);
769
- json(res, HTTP_STATUS.OK, okBody);
820
+ const workPending = cancelPending || hadBash;
821
+ const replacementPromptActive = hadAgentPrompt &&
822
+ ((sessions?.activePrompts.has(sessionId) === true &&
823
+ busyAfterCancel?.kind === "agent" &&
824
+ busyAfterCancel.promptId !== cancelledPromptId) ||
825
+ sessions?.pendingPromptSubmissions.has(sessionId) === true);
826
+ const status = workPending || replacementPromptActive
827
+ ? HTTP_STATUS.ACCEPTED
828
+ : HTTP_STATUS.OK;
829
+ const okBody = {
830
+ ok: true,
831
+ status: workPending
832
+ ? "cancelling"
833
+ : replacementPromptActive
834
+ ? "superseded"
835
+ : "cancelled",
836
+ };
837
+ saveClientOpResult(store, opId, sessionId, status, okBody);
838
+ json(res, status, okBody);
770
839
  return;
771
840
  }
772
841
  // --- GET /api/v1/sessions/:id/status ---
@@ -834,6 +903,7 @@ export function createRequestHandler(deps) {
834
903
  id: session.id,
835
904
  title: session.title,
836
905
  cwd: session.cwd,
906
+ cwdDisplay: abbreviateHomePath(session.cwd),
837
907
  model: session.model,
838
908
  mode: session.mode,
839
909
  createdAt: session.created_at,
@@ -888,11 +958,43 @@ export function createRequestHandler(deps) {
888
958
  const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
889
959
  if (replayed)
890
960
  return;
961
+ const promptSubmissionId = sessions.reservePromptSubmission(sessionId);
962
+ if (promptSubmissionId === null) {
963
+ const busyKind = sessions.getBusyKind(sessionId);
964
+ logPromptRejectBeforeSave({
965
+ sessionId,
966
+ status: HTTP_STATUS.CONFLICT,
967
+ reason: "session_busy",
968
+ opId,
969
+ busyKind: busyKind ?? undefined,
970
+ });
971
+ json(res, HTTP_STATUS.CONFLICT, {
972
+ error: "Session is busy",
973
+ busyKind,
974
+ });
975
+ return;
976
+ }
977
+ const requestState = { aborted: false };
978
+ const isRequestAborted = () => requestState.aborted;
979
+ const abortPromptSubmission = () => {
980
+ requestState.aborted = true;
981
+ sessions.releasePromptSubmission(sessionId, promptSubmissionId);
982
+ };
983
+ res.once("finish", () => {
984
+ sessions.releasePromptSubmission(sessionId, promptSubmissionId);
985
+ });
986
+ req.once("aborted", abortPromptSubmission);
987
+ res.once("close", () => {
988
+ if (!res.writableEnded)
989
+ abortPromptSubmission();
990
+ });
891
991
  // Ensure session is live in ACP before prompting (awaits in-flight resume)
892
992
  try {
893
993
  await sessions.ensureResumed(bridge, sessionId);
894
994
  }
895
995
  catch (err) {
996
+ if (isRequestAborted())
997
+ return;
896
998
  logPromptRejectBeforeSave({
897
999
  sessionId,
898
1000
  status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
@@ -905,19 +1007,16 @@ export function createRequestHandler(deps) {
905
1007
  });
906
1008
  return;
907
1009
  }
908
- // Check if session is busy
909
- const busyKind = sessions.getBusyKind(sessionId);
910
- if (busyKind) {
1010
+ if (isRequestAborted() ||
1011
+ sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
911
1012
  logPromptRejectBeforeSave({
912
1013
  sessionId,
913
1014
  status: HTTP_STATUS.CONFLICT,
914
- reason: "session_busy",
1015
+ reason: "prompt_cancelled_before_start",
915
1016
  opId,
916
- busyKind,
917
1017
  });
918
1018
  json(res, HTTP_STATUS.CONFLICT, {
919
- error: "Session is busy",
920
- busyKind,
1019
+ error: "Prompt was cancelled before start",
921
1020
  });
922
1021
  return;
923
1022
  }
@@ -926,6 +1025,8 @@ export function createRequestHandler(deps) {
926
1025
  body = JSON.parse(await readBody(req));
927
1026
  }
928
1027
  catch {
1028
+ if (isRequestAborted())
1029
+ return;
929
1030
  logPromptRejectBeforeSave({
930
1031
  sessionId,
931
1032
  status: HTTP_STATUS.BAD_REQUEST,
@@ -935,6 +1036,13 @@ export function createRequestHandler(deps) {
935
1036
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
936
1037
  return;
937
1038
  }
1039
+ if (isRequestAborted() ||
1040
+ sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
1041
+ json(res, HTTP_STATUS.CONFLICT, {
1042
+ error: "Prompt was cancelled before start",
1043
+ });
1044
+ return;
1045
+ }
938
1046
  if (!body.text) {
939
1047
  logPromptRejectBeforeSave({
940
1048
  sessionId,
@@ -1057,8 +1165,15 @@ export function createRequestHandler(deps) {
1057
1165
  },
1058
1166
  ];
1059
1167
  });
1168
+ // A background task can trigger unsolicited Main-agent output after
1169
+ // the foreground ACP prompt has ended. Its chunks remain buffered
1170
+ // until a real protocol boundary arrives; seal them before this user
1171
+ // row so they cannot merge into the next turn's assistant response.
1172
+ sessions.flushBuffers(sessionId);
1173
+ const eventClientOpId = opId ?? randomUUID();
1060
1174
  store.saveEvent(sessionId, "user_message", {
1061
1175
  text: body.text,
1176
+ clientOpId: eventClientOpId,
1062
1177
  ...(storedAttachments?.length
1063
1178
  ? { attachments: storedAttachments }
1064
1179
  : {}),
@@ -1069,6 +1184,7 @@ export function createRequestHandler(deps) {
1069
1184
  type: "user_message",
1070
1185
  sessionId,
1071
1186
  text: body.text,
1187
+ clientOpId: eventClientOpId,
1072
1188
  attachments: storedAttachments,
1073
1189
  };
1074
1190
  sseManager.broadcast(userMsgEvent);
@@ -1086,14 +1202,21 @@ export function createRequestHandler(deps) {
1086
1202
  });
1087
1203
  }
1088
1204
  // Fire prompt asynchronously (don't await — response is 202)
1205
+ sessions.releasePromptSubmission(sessionId, promptSubmissionId, false);
1089
1206
  sessions.activePrompts.add(sessionId);
1090
1207
  sessions.syncBusy(sessionId);
1208
+ const promptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
1209
+ undefined;
1091
1210
  bridge
1092
- .prompt(sessionId, agentText, attachments)
1211
+ .prompt(sessionId, agentText, attachments, promptId)
1093
1212
  .catch((err) => {
1094
1213
  plog.error("error", { sessionId, error: err });
1095
1214
  })
1096
1215
  .finally(() => {
1216
+ // A turn that outlived its own supersession must not clear the
1217
+ // busy state of the turn that replaced it.
1218
+ if (!sessions.isCurrentPrompt(sessionId, promptId))
1219
+ return;
1097
1220
  sessions.activePrompts.delete(sessionId);
1098
1221
  sessions.syncBusy(sessionId);
1099
1222
  });
@@ -1327,6 +1450,76 @@ export function createRequestHandler(deps) {
1327
1450
  json(res, HTTP_STATUS.OK, { title: body.value });
1328
1451
  return;
1329
1452
  }
1453
+ // POST /api/v1/sessions/bootstrap — atomically return the current
1454
+ // agent's latest session, creating one only when none exists.
1455
+ if (url === "/api/v1/sessions/bootstrap" && req.method === "POST") {
1456
+ const bridge = getBridge?.();
1457
+ if (!bridge) {
1458
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1459
+ error: "Agent not ready yet",
1460
+ });
1461
+ return;
1462
+ }
1463
+ if (!sessions) {
1464
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1465
+ error: "Session manager not available",
1466
+ });
1467
+ return;
1468
+ }
1469
+ const sessionManager = sessions;
1470
+ bootstrapSessionPromise ??= (async () => {
1471
+ const existing = store.listSessions().at(0);
1472
+ if (existing) {
1473
+ return {
1474
+ id: existing.id,
1475
+ cwd: existing.cwd,
1476
+ cwdDisplay: abbreviateHomePath(existing.cwd),
1477
+ title: existing.title,
1478
+ source: existing.source,
1479
+ configOptions: [],
1480
+ agentCommands: sessionManager.getAgentCommands(existing.id),
1481
+ created: false,
1482
+ };
1483
+ }
1484
+ const { sessionId, configOptions } = await sessionManager.createSession(bridge);
1485
+ const session = store.getSession(sessionId);
1486
+ const result = {
1487
+ id: sessionId,
1488
+ cwd: session?.cwd ?? deps.dataDir,
1489
+ cwdDisplay: abbreviateHomePath(session?.cwd ?? deps.dataDir),
1490
+ title: session?.title ?? null,
1491
+ source: session?.source ?? "auto",
1492
+ configOptions,
1493
+ agentCommands: sessionManager.getAgentCommands(sessionId),
1494
+ created: true,
1495
+ };
1496
+ sseManager.broadcast({
1497
+ type: "session_created",
1498
+ sessionId,
1499
+ cwd: result.cwd,
1500
+ cwdDisplay: result.cwdDisplay,
1501
+ title: result.title,
1502
+ configOptions,
1503
+ agentCommands: result.agentCommands,
1504
+ });
1505
+ return result;
1506
+ })().finally(() => {
1507
+ bootstrapSessionPromise = null;
1508
+ });
1509
+ try {
1510
+ const result = await bootstrapSessionPromise;
1511
+ json(res, HTTP_STATUS.OK, {
1512
+ ...result,
1513
+ clientOpId: getClientOpId(req) ?? undefined,
1514
+ });
1515
+ }
1516
+ catch (err) {
1517
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
1518
+ error: err instanceof Error ? err.message : String(err),
1519
+ });
1520
+ }
1521
+ return;
1522
+ }
1330
1523
  // --- Session CRUD: /api/v1/sessions/:id ---
1331
1524
  const sessionIdMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/?(\?.*)?$/);
1332
1525
  if (sessionIdMatch) {
@@ -1377,33 +1570,12 @@ export function createRequestHandler(deps) {
1377
1570
  });
1378
1571
  })
1379
1572
  .catch(() => { });
1380
- // Auto-retry if the last turn was interrupted (must wait for resume)
1381
- const hasInterrupted = store.hasInterruptedTurn(sessionId);
1382
- if (hasInterrupted) {
1383
- // Optimistically mark busy so concurrent POST sees the session as active
1384
- sessions.activePrompts.add(sessionId);
1385
- sessions.syncBusy(sessionId);
1386
- void resumePromise
1387
- .then(() => {
1388
- if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
1389
- // Retry not needed after all — release the optimistic lock
1390
- sessions.activePrompts.delete(sessionId);
1391
- sessions.syncBusy(sessionId);
1392
- }
1393
- })
1394
- .catch(() => {
1395
- sessions.activePrompts.delete(sessionId);
1396
- sessions.syncBusy(sessionId);
1397
- });
1398
- }
1399
- else {
1400
- resumePromise.catch((err) => {
1401
- slog.error("background resume failed", {
1402
- sessionId: sessionId.slice(0, 8) + "…",
1403
- error: err,
1404
- });
1573
+ resumePromise.catch((err) => {
1574
+ slog.error("background resume failed", {
1575
+ sessionId: sessionId.slice(0, 8) + "…",
1576
+ error: err,
1405
1577
  });
1406
- }
1578
+ });
1407
1579
  }
1408
1580
  }
1409
1581
  // If cache is cold and we kicked off a resume, wait briefly so the
@@ -1445,6 +1617,7 @@ export function createRequestHandler(deps) {
1445
1617
  json(res, HTTP_STATUS.OK, {
1446
1618
  id: freshSession.id,
1447
1619
  cwd: freshSession.cwd,
1620
+ cwdDisplay: abbreviateHomePath(freshSession.cwd),
1448
1621
  title: freshSession.title,
1449
1622
  source: freshSession.source,
1450
1623
  model: freshSession.model,
@@ -1460,6 +1633,12 @@ export function createRequestHandler(deps) {
1460
1633
  json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1461
1634
  return;
1462
1635
  }
1636
+ if (sessions && sessions.getBusyKind(sessionId) !== null) {
1637
+ json(res, HTTP_STATUS.CONFLICT, {
1638
+ error: "Cancel active work before deleting the session",
1639
+ });
1640
+ return;
1641
+ }
1463
1642
  if (sessions) {
1464
1643
  sessions.deleteSession(sessionId);
1465
1644
  }
@@ -1474,6 +1653,7 @@ export function createRequestHandler(deps) {
1474
1653
  }
1475
1654
  // POST /api/v1/sessions (create new session)
1476
1655
  if (url === "/api/v1/sessions" && req.method === "POST") {
1656
+ const clientOpId = getClientOpId(req);
1477
1657
  const bridge = getBridge?.();
1478
1658
  if (!bridge) {
1479
1659
  json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
@@ -1503,9 +1683,13 @@ export function createRequestHandler(deps) {
1503
1683
  type: "session_created",
1504
1684
  sessionId,
1505
1685
  cwd: session?.cwd,
1686
+ cwdDisplay: session?.cwd
1687
+ ? abbreviateHomePath(session.cwd)
1688
+ : undefined,
1506
1689
  title: session?.title,
1507
1690
  configOptions,
1508
1691
  agentCommands: sessions.getAgentCommands(sessionId),
1692
+ clientOpId: clientOpId ?? undefined,
1509
1693
  };
1510
1694
  sseManager.broadcast(sessionCreatedEvent);
1511
1695
  // ACP's session_created event fires before inheritance runs, so
@@ -1520,15 +1704,19 @@ export function createRequestHandler(deps) {
1520
1704
  json(res, HTTP_STATUS.CREATED, {
1521
1705
  id: sessionId,
1522
1706
  cwd: session?.cwd ?? body.cwd,
1707
+ cwdDisplay: session?.cwd
1708
+ ? abbreviateHomePath(session.cwd)
1709
+ : undefined,
1523
1710
  title: session?.title ?? null,
1524
1711
  source: session?.source ?? source,
1525
1712
  configOptions,
1526
1713
  agentCommands: sessions.getAgentCommands(sessionId),
1714
+ clientOpId: clientOpId ?? undefined,
1527
1715
  });
1528
1716
  }
1529
1717
  catch (err) {
1530
1718
  const msg = err instanceof Error ? err.message : String(err);
1531
- if (msg.includes("does not exist")) {
1719
+ if (err instanceof InvalidSessionDirectoryError) {
1532
1720
  json(res, HTTP_STATUS.BAD_REQUEST, { error: msg });
1533
1721
  }
1534
1722
  else {
@@ -1563,14 +1751,15 @@ export function createRequestHandler(deps) {
1563
1751
  let streamingThinking = false;
1564
1752
  let streamingAssistant = false;
1565
1753
  if (sessions) {
1566
- if (sessions.thinkingBuffers.has(sessionId)) {
1567
- streamingThinking = true;
1568
- sessions.flushThinkingBuffer(sessionId);
1569
- }
1570
- if (sessions.assistantBuffers.has(sessionId)) {
1571
- streamingAssistant = true;
1572
- sessions.flushAssistantBuffer(sessionId);
1573
- }
1754
+ const runtimeStreaming = sessions.state.peekStreaming(sessionId);
1755
+ streamingThinking =
1756
+ runtimeStreaming.thinking ||
1757
+ Boolean(sessions.thinkingBuffers.get(sessionId));
1758
+ streamingAssistant =
1759
+ runtimeStreaming.assistant ||
1760
+ Boolean(sessions.assistantBuffers.get(sessionId));
1761
+ sessions.flushThinkingBuffer(sessionId);
1762
+ sessions.flushAssistantBuffer(sessionId);
1574
1763
  }
1575
1764
  const events = store.getEvents(sessionId, {
1576
1765
  excludeThinking,
@@ -1653,6 +1842,7 @@ export function createRequestHandler(deps) {
1653
1842
  type: "connected",
1654
1843
  clientId,
1655
1844
  debugLevel: deps.debugLevel ?? "off",
1845
+ pendingCount: store.countUnprocessed(),
1656
1846
  });
1657
1847
  sseManager.writeHeartbeat(client);
1658
1848
  return;
@@ -1696,6 +1886,7 @@ export function createRequestHandler(deps) {
1696
1886
  type: "connected",
1697
1887
  clientId,
1698
1888
  debugLevel: deps.debugLevel ?? "off",
1889
+ pendingCount: store.countUnprocessed(),
1699
1890
  });
1700
1891
  sseManager.writeHeartbeat(client);
1701
1892
  // Replay events from Last-Event-ID if provided
@@ -1906,6 +2097,7 @@ export function createRequestHandler(deps) {
1906
2097
  created_at: Date.now(),
1907
2098
  });
1908
2099
  sseManager.broadcast({ type: "message_created", messageId: id });
2100
+ broadcastInboxCount(store, sseManager);
1909
2101
  if (deps.pushService) {
1910
2102
  void deps.pushService.sendForMessage({
1911
2103
  id,
@@ -1937,23 +2129,58 @@ export function createRequestHandler(deps) {
1937
2129
  const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
1938
2130
  if (consumeMatch && req.method === "POST") {
1939
2131
  const id = decodeURIComponent(consumeMatch[1]);
1940
- const newSid = randomUUID();
2132
+ let inheritFromSessionId;
2133
+ try {
2134
+ const rawBody = await readBody(req);
2135
+ if (rawBody) {
2136
+ const body = JSON.parse(rawBody);
2137
+ if (body.inheritFromSessionId !== undefined &&
2138
+ typeof body.inheritFromSessionId !== "string") {
2139
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2140
+ error: "inheritFromSessionId must be a string",
2141
+ });
2142
+ return;
2143
+ }
2144
+ inheritFromSessionId = body.inheritFromSessionId;
2145
+ }
2146
+ }
2147
+ catch {
2148
+ json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
2149
+ return;
2150
+ }
2151
+ const bridge = getBridge?.();
2152
+ if (!sessions || !bridge) {
2153
+ json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
2154
+ error: "Agent not available",
2155
+ });
2156
+ return;
2157
+ }
1941
2158
  let out;
1942
2159
  try {
1943
- out = store.consumeMessageTx(id, { sessionId: newSid });
2160
+ out = await sessions.consumeMessage(bridge, id, inheritFromSessionId);
1944
2161
  }
1945
2162
  catch (err) {
1946
- if (/message not found/.test(errorMessage(err))) {
1947
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
2163
+ if (err instanceof InvalidSessionDirectoryError) {
2164
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2165
+ error: err.message,
2166
+ });
1948
2167
  return;
1949
2168
  }
1950
- throw err;
2169
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
2170
+ error: errorMessage(err),
2171
+ });
2172
+ return;
2173
+ }
2174
+ if (!out) {
2175
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
2176
+ return;
1951
2177
  }
1952
2178
  sseManager.broadcast({
1953
2179
  type: "message_consumed",
1954
2180
  messageId: id,
1955
2181
  sessionId: out.sessionId,
1956
2182
  });
2183
+ broadcastInboxCount(store, sseManager);
1957
2184
  if (!out.alreadyConsumed && deps.pushService) {
1958
2185
  void deps.pushService.sendClose(id);
1959
2186
  }
@@ -1980,6 +2207,7 @@ export function createRequestHandler(deps) {
1980
2207
  return;
1981
2208
  }
1982
2209
  sseManager.broadcast({ type: "message_acked", messageId: id });
2210
+ broadcastInboxCount(store, sseManager);
1983
2211
  if (deps.pushService)
1984
2212
  void deps.pushService.sendClose(id);
1985
2213
  mlog.info("ack", { msg_id: id });
@@ -2034,8 +2262,20 @@ export function createRequestHandler(deps) {
2034
2262
  return;
2035
2263
  }
2036
2264
  const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
2037
- const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
2265
+ const { sessionId, configOptions } = await sessions.createSession(bridge, cwd, undefined, "auto");
2038
2266
  const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
2267
+ const session = store.getSession(sessionId);
2268
+ sseManager.broadcast({
2269
+ type: "session_created",
2270
+ sessionId,
2271
+ cwd: session?.cwd,
2272
+ cwdDisplay: session?.cwd
2273
+ ? abbreviateHomePath(session.cwd)
2274
+ : undefined,
2275
+ title: session?.title,
2276
+ configOptions,
2277
+ agentCommands: sessions.getAgentCommands(sessionId),
2278
+ });
2039
2279
  json(res, HTTP_STATUS.ACCEPTED, { sessionId, streamUrl });
2040
2280
  // Fire-and-forget: send the prompt asynchronously, tracking busy state
2041
2281
  sessions.activePrompts.add(sessionId);
@@ -2051,10 +2291,14 @@ export function createRequestHandler(deps) {
2051
2291
  sseManager.broadcast(titleEvent);
2052
2292
  });
2053
2293
  }
2294
+ const betaPromptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
2295
+ undefined;
2054
2296
  bridge
2055
- .prompt(sessionId, text)
2297
+ .prompt(sessionId, text, undefined, betaPromptId)
2056
2298
  .catch(() => { })
2057
2299
  .finally(() => {
2300
+ if (!sessions.isCurrentPrompt(sessionId, betaPromptId))
2301
+ return;
2058
2302
  sessions.activePrompts.delete(sessionId);
2059
2303
  sessions.syncBusy(sessionId);
2060
2304
  });
@@ -2233,7 +2477,7 @@ export function createRequestHandler(deps) {
2233
2477
  return;
2234
2478
  }
2235
2479
  // --- Static files ---
2236
- let staticPath = url;
2480
+ let staticPath = url.split("?")[0] ?? url;
2237
2481
  const htmlEntry = HTML_ENTRYPOINTS.find((e) => e.urlPath === staticPath);
2238
2482
  if (htmlEntry)
2239
2483
  staticPath = "/" + htmlEntry.file;