@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/README.md +4 -1
- package/dist/index.html +31 -6
- package/dist/js/app.QC7IRDTP.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{chunk.3CCHZCG7.js → chunk.S5LRNRJI.js} +42 -41
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.GP5VXAUY.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +4 -4
- package/dist/{styles.01ky3cex.css → styles.00nlhhf3.css} +261 -6
- package/dist/sw.js +19 -9
- package/lib/agent-key.js +6 -0
- package/lib/attachments.js +44 -7
- package/lib/auth-middleware.js +9 -2
- package/lib/bridge.js +164 -33
- package/lib/event-handler.js +91 -18
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- package/lib/message-cleanup.js +5 -4
- package/lib/routes.js +316 -72
- package/lib/server.js +26 -23
- package/lib/session-manager.js +217 -44
- package/lib/session-state.js +99 -5
- package/lib/share/routes.js +12 -0
- package/lib/sse-manager.js +31 -7
- package/lib/store.js +124 -41
- package/lib/title-service.js +3 -3
- package/package.json +2 -1
- package/dist/js/app.KFQAFHA3.js +0 -4
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.T32M5AZZ.js +0 -1
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
|
-
|
|
753
|
-
|
|
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
|
-
//
|
|
756
|
-
|
|
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.
|
|
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
|
-
//
|
|
761
|
-
//
|
|
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
|
-
|
|
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
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
if (busyKind) {
|
|
1010
|
+
if (isRequestAborted() ||
|
|
1011
|
+
sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
|
|
911
1012
|
logPromptRejectBeforeSave({
|
|
912
1013
|
sessionId,
|
|
913
1014
|
status: HTTP_STATUS.CONFLICT,
|
|
914
|
-
reason: "
|
|
1015
|
+
reason: "prompt_cancelled_before_start",
|
|
915
1016
|
opId,
|
|
916
|
-
busyKind,
|
|
917
1017
|
});
|
|
918
1018
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
919
|
-
error: "
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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 (
|
|
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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
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 =
|
|
2160
|
+
out = await sessions.consumeMessage(bridge, id, inheritFromSessionId);
|
|
1944
2161
|
}
|
|
1945
2162
|
catch (err) {
|
|
1946
|
-
if (
|
|
1947
|
-
json(res, HTTP_STATUS.
|
|
2163
|
+
if (err instanceof InvalidSessionDirectoryError) {
|
|
2164
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
2165
|
+
error: err.message,
|
|
2166
|
+
});
|
|
1948
2167
|
return;
|
|
1949
2168
|
}
|
|
1950
|
-
|
|
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;
|