@lelouchhe/webagent 0.7.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,7 +4,7 @@ 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";
@@ -22,6 +22,12 @@ import { readImageDimensions } from "./image-dimensions.js";
22
22
  import { HTTP_STATUS } from "./http-status.js";
23
23
  const IS_WIN = process.platform === "win32";
24
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
+ }
25
31
  const MIME = {
26
32
  ".html": "text/html; charset=utf-8",
27
33
  ".js": "application/javascript; charset=utf-8",
@@ -736,37 +742,85 @@ export function createRequestHandler(deps) {
736
742
  json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
737
743
  return;
738
744
  }
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
745
  const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
747
746
  if (replayed)
748
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;
749
760
  // Kill running bash process if any
750
761
  const proc = sessions?.runningBashProcs.get(sessionId);
751
762
  if (proc) {
752
- interruptBashProc(proc);
753
- sessions.runningBashProcs.delete(sessionId);
763
+ const force = sessions.interruptedBashProcs.has(proc);
764
+ interruptBashProc(proc, force);
765
+ sessions.interruptedBashProcs.add(proc);
754
766
  }
755
- // Cancel agent prompt
756
- if (sessions?.activePrompts.has(sessionId)) {
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;
773
+ }
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
+ });
757
785
  await bridge.cancel(sessionId);
758
- 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
+ }
759
793
  }
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.
794
+ // If prompt_done does not arrive, expose the lack of acknowledgement
795
+ // instead of pretending the prompt stopped.
763
796
  const cancelTimeout = deps.limits.cancel_timeout ?? 0;
764
- 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)
765
803
  sessions.state.armCancelSafety(sessionId, cancelTimeout);
766
804
  sessions?.syncBusy(sessionId);
767
- const okBody = { ok: true };
768
- saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, okBody);
769
- json(res, HTTP_STATUS.OK, 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);
770
824
  return;
771
825
  }
772
826
  // --- GET /api/v1/sessions/:id/status ---
@@ -888,11 +942,43 @@ export function createRequestHandler(deps) {
888
942
  const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
889
943
  if (replayed)
890
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
+ });
891
975
  // Ensure session is live in ACP before prompting (awaits in-flight resume)
892
976
  try {
893
977
  await sessions.ensureResumed(bridge, sessionId);
894
978
  }
895
979
  catch (err) {
980
+ if (isRequestAborted())
981
+ return;
896
982
  logPromptRejectBeforeSave({
897
983
  sessionId,
898
984
  status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
@@ -905,19 +991,16 @@ export function createRequestHandler(deps) {
905
991
  });
906
992
  return;
907
993
  }
908
- // Check if session is busy
909
- const busyKind = sessions.getBusyKind(sessionId);
910
- if (busyKind) {
994
+ if (isRequestAborted() ||
995
+ sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
911
996
  logPromptRejectBeforeSave({
912
997
  sessionId,
913
998
  status: HTTP_STATUS.CONFLICT,
914
- reason: "session_busy",
999
+ reason: "prompt_cancelled_before_start",
915
1000
  opId,
916
- busyKind,
917
1001
  });
918
1002
  json(res, HTTP_STATUS.CONFLICT, {
919
- error: "Session is busy",
920
- busyKind,
1003
+ error: "Prompt was cancelled before start",
921
1004
  });
922
1005
  return;
923
1006
  }
@@ -926,6 +1009,8 @@ export function createRequestHandler(deps) {
926
1009
  body = JSON.parse(await readBody(req));
927
1010
  }
928
1011
  catch {
1012
+ if (isRequestAborted())
1013
+ return;
929
1014
  logPromptRejectBeforeSave({
930
1015
  sessionId,
931
1016
  status: HTTP_STATUS.BAD_REQUEST,
@@ -935,6 +1020,13 @@ export function createRequestHandler(deps) {
935
1020
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
936
1021
  return;
937
1022
  }
1023
+ if (isRequestAborted() ||
1024
+ sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
1025
+ json(res, HTTP_STATUS.CONFLICT, {
1026
+ error: "Prompt was cancelled before start",
1027
+ });
1028
+ return;
1029
+ }
938
1030
  if (!body.text) {
939
1031
  logPromptRejectBeforeSave({
940
1032
  sessionId,
@@ -1057,8 +1149,10 @@ export function createRequestHandler(deps) {
1057
1149
  },
1058
1150
  ];
1059
1151
  });
1152
+ const eventClientOpId = opId ?? randomUUID();
1060
1153
  store.saveEvent(sessionId, "user_message", {
1061
1154
  text: body.text,
1155
+ clientOpId: eventClientOpId,
1062
1156
  ...(storedAttachments?.length
1063
1157
  ? { attachments: storedAttachments }
1064
1158
  : {}),
@@ -1069,6 +1163,7 @@ export function createRequestHandler(deps) {
1069
1163
  type: "user_message",
1070
1164
  sessionId,
1071
1165
  text: body.text,
1166
+ clientOpId: eventClientOpId,
1072
1167
  attachments: storedAttachments,
1073
1168
  };
1074
1169
  sseManager.broadcast(userMsgEvent);
@@ -1086,14 +1181,21 @@ export function createRequestHandler(deps) {
1086
1181
  });
1087
1182
  }
1088
1183
  // Fire prompt asynchronously (don't await — response is 202)
1184
+ sessions.releasePromptSubmission(sessionId, promptSubmissionId, false);
1089
1185
  sessions.activePrompts.add(sessionId);
1090
1186
  sessions.syncBusy(sessionId);
1187
+ const promptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
1188
+ undefined;
1091
1189
  bridge
1092
- .prompt(sessionId, agentText, attachments)
1190
+ .prompt(sessionId, agentText, attachments, promptId)
1093
1191
  .catch((err) => {
1094
1192
  plog.error("error", { sessionId, error: err });
1095
1193
  })
1096
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;
1097
1199
  sessions.activePrompts.delete(sessionId);
1098
1200
  sessions.syncBusy(sessionId);
1099
1201
  });
@@ -1460,6 +1562,12 @@ export function createRequestHandler(deps) {
1460
1562
  json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
1461
1563
  return;
1462
1564
  }
1565
+ if (sessions && sessions.getBusyKind(sessionId) !== null) {
1566
+ json(res, HTTP_STATUS.CONFLICT, {
1567
+ error: "Cancel active work before deleting the session",
1568
+ });
1569
+ return;
1570
+ }
1463
1571
  if (sessions) {
1464
1572
  sessions.deleteSession(sessionId);
1465
1573
  }
@@ -1474,6 +1582,7 @@ export function createRequestHandler(deps) {
1474
1582
  }
1475
1583
  // POST /api/v1/sessions (create new session)
1476
1584
  if (url === "/api/v1/sessions" && req.method === "POST") {
1585
+ const clientOpId = getClientOpId(req);
1477
1586
  const bridge = getBridge?.();
1478
1587
  if (!bridge) {
1479
1588
  json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
@@ -1506,6 +1615,7 @@ export function createRequestHandler(deps) {
1506
1615
  title: session?.title,
1507
1616
  configOptions,
1508
1617
  agentCommands: sessions.getAgentCommands(sessionId),
1618
+ clientOpId: clientOpId ?? undefined,
1509
1619
  };
1510
1620
  sseManager.broadcast(sessionCreatedEvent);
1511
1621
  // ACP's session_created event fires before inheritance runs, so
@@ -1524,11 +1634,12 @@ export function createRequestHandler(deps) {
1524
1634
  source: session?.source ?? source,
1525
1635
  configOptions,
1526
1636
  agentCommands: sessions.getAgentCommands(sessionId),
1637
+ clientOpId: clientOpId ?? undefined,
1527
1638
  });
1528
1639
  }
1529
1640
  catch (err) {
1530
1641
  const msg = err instanceof Error ? err.message : String(err);
1531
- if (msg.includes("does not exist")) {
1642
+ if (err instanceof InvalidSessionDirectoryError) {
1532
1643
  json(res, HTTP_STATUS.BAD_REQUEST, { error: msg });
1533
1644
  }
1534
1645
  else {
@@ -1563,14 +1674,15 @@ export function createRequestHandler(deps) {
1563
1674
  let streamingThinking = false;
1564
1675
  let streamingAssistant = false;
1565
1676
  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
- }
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);
1574
1686
  }
1575
1687
  const events = store.getEvents(sessionId, {
1576
1688
  excludeThinking,
@@ -1653,6 +1765,7 @@ export function createRequestHandler(deps) {
1653
1765
  type: "connected",
1654
1766
  clientId,
1655
1767
  debugLevel: deps.debugLevel ?? "off",
1768
+ pendingCount: store.countUnprocessed(),
1656
1769
  });
1657
1770
  sseManager.writeHeartbeat(client);
1658
1771
  return;
@@ -1696,6 +1809,7 @@ export function createRequestHandler(deps) {
1696
1809
  type: "connected",
1697
1810
  clientId,
1698
1811
  debugLevel: deps.debugLevel ?? "off",
1812
+ pendingCount: store.countUnprocessed(),
1699
1813
  });
1700
1814
  sseManager.writeHeartbeat(client);
1701
1815
  // Replay events from Last-Event-ID if provided
@@ -1906,6 +2020,7 @@ export function createRequestHandler(deps) {
1906
2020
  created_at: Date.now(),
1907
2021
  });
1908
2022
  sseManager.broadcast({ type: "message_created", messageId: id });
2023
+ broadcastInboxCount(store, sseManager);
1909
2024
  if (deps.pushService) {
1910
2025
  void deps.pushService.sendForMessage({
1911
2026
  id,
@@ -1937,23 +2052,58 @@ export function createRequestHandler(deps) {
1937
2052
  const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
1938
2053
  if (consumeMatch && req.method === "POST") {
1939
2054
  const id = decodeURIComponent(consumeMatch[1]);
1940
- 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
+ }
1941
2081
  let out;
1942
2082
  try {
1943
- out = store.consumeMessageTx(id, { sessionId: newSid });
2083
+ out = await sessions.consumeMessage(bridge, id, inheritFromSessionId);
1944
2084
  }
1945
2085
  catch (err) {
1946
- if (/message not found/.test(errorMessage(err))) {
1947
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Message not found" });
2086
+ if (err instanceof InvalidSessionDirectoryError) {
2087
+ json(res, HTTP_STATUS.BAD_REQUEST, {
2088
+ error: err.message,
2089
+ });
1948
2090
  return;
1949
2091
  }
1950
- 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;
1951
2100
  }
1952
2101
  sseManager.broadcast({
1953
2102
  type: "message_consumed",
1954
2103
  messageId: id,
1955
2104
  sessionId: out.sessionId,
1956
2105
  });
2106
+ broadcastInboxCount(store, sseManager);
1957
2107
  if (!out.alreadyConsumed && deps.pushService) {
1958
2108
  void deps.pushService.sendClose(id);
1959
2109
  }
@@ -1980,6 +2130,7 @@ export function createRequestHandler(deps) {
1980
2130
  return;
1981
2131
  }
1982
2132
  sseManager.broadcast({ type: "message_acked", messageId: id });
2133
+ broadcastInboxCount(store, sseManager);
1983
2134
  if (deps.pushService)
1984
2135
  void deps.pushService.sendClose(id);
1985
2136
  mlog.info("ack", { msg_id: id });
@@ -2051,10 +2202,14 @@ export function createRequestHandler(deps) {
2051
2202
  sseManager.broadcast(titleEvent);
2052
2203
  });
2053
2204
  }
2205
+ const betaPromptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
2206
+ undefined;
2054
2207
  bridge
2055
- .prompt(sessionId, text)
2208
+ .prompt(sessionId, text, undefined, betaPromptId)
2056
2209
  .catch(() => { })
2057
2210
  .finally(() => {
2211
+ if (!sessions.isCurrentPrompt(sessionId, betaPromptId))
2212
+ return;
2058
2213
  sessions.activePrompts.delete(sessionId);
2059
2214
  sessions.syncBusy(sessionId);
2060
2215
  });
@@ -2233,7 +2388,7 @@ export function createRequestHandler(deps) {
2233
2388
  return;
2234
2389
  }
2235
2390
  // --- Static files ---
2236
- let staticPath = url;
2391
+ let staticPath = url.split("?")[0] ?? url;
2237
2392
  const htmlEntry = HTML_ENTRYPOINTS.find((e) => e.urlPath === staticPath);
2238
2393
  if (htmlEntry)
2239
2394
  staticPath = "/" + htmlEntry.file;
package/lib/server.js CHANGED
@@ -104,7 +104,9 @@ sessions.state.onPatch((event) => {
104
104
  sseManager.broadcast(event);
105
105
  });
106
106
  let bridge = null;
107
- let messageCleanup = null;
107
+ const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
108
+ sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
109
+ });
108
110
  let sharePreviewCleanup = null;
109
111
  // --- HTTP server ---
110
112
  const server = createServer((req, res) => {
@@ -153,7 +155,7 @@ async function initBridge(agentCmd) {
153
155
  async function shutdown() {
154
156
  console.log("\n[server] shutting down...");
155
157
  sseManager.stopHeartbeat();
156
- messageCleanup?.stop();
158
+ messageCleanup.stop();
157
159
  sharePreviewCleanup?.stop();
158
160
  sessions.killAllBashProcs();
159
161
  await bridge?.shutdown();
@@ -185,7 +187,6 @@ server.listen(config.port, config.host, () => {
185
187
  // will use. If the gate ran, auth.json exists and has ≥ 1 token.
186
188
  await authStore.load();
187
189
  console.log(`[server] listening on http://localhost:${config.port}`);
188
- messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days);
189
190
  if (config.share.enabled) {
190
191
  sharePreviewCleanup = startSharePreviewCleanup(store);
191
192
  console.log(`[share] preview gc armed (24h interval)`);