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