@lelouchhe/webagent 0.9.0 → 0.10.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 +39 -14
- package/config.toml +7 -27
- package/dist/index.html +4 -4
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +5 -5
- package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
- package/dist/sw.js +6 -6
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +25 -0
- package/lib/auth-middleware.js +2 -2
- package/lib/auth.js +2 -2
- package/lib/bridge.js +109 -83
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +143 -90
- package/lib/files/routes.js +1 -1
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +947 -489
- package/lib/server.js +64 -16
- package/lib/share/routes.js +88 -88
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +941 -314
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +64 -41
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +7 -1
- package/dist/js/app.QC7IRDTP.js +0 -5
- package/dist/js/viewer.GP5VXAUY.js +0 -1
- package/lib/session-manager.js +0 -638
- package/lib/title-service.js +0 -95
package/lib/routes.js
CHANGED
|
@@ -3,8 +3,9 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { join, extname, basename } from "node:path";
|
|
4
4
|
import { gzipSync } from "node:zlib";
|
|
5
5
|
import busboy from "busboy";
|
|
6
|
+
import { ROOT_TASK_ID } from "./store.js";
|
|
6
7
|
import { errorMessage, MessageIngressSchema } from "./types.js";
|
|
7
|
-
import { interruptBashProc,
|
|
8
|
+
import { interruptBashProc, AgentNotReadyError, InvalidTaskDirectoryError, TaskBusyError, TaskNotFoundError, TaskTreeBusyError, } from "./task-manager.js";
|
|
8
9
|
import { randomUUID } from "node:crypto";
|
|
9
10
|
import { createWriteStream } from "node:fs";
|
|
10
11
|
import { handleShareRoutes } from "./share/routes.js";
|
|
@@ -14,10 +15,45 @@ import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
|
|
|
14
15
|
import { agentCommandToken, resolveAgentCommand } from "./agent-commands.js";
|
|
15
16
|
import { abbreviateHomePath } from "./home-path.js";
|
|
16
17
|
import { log } from "./log.js";
|
|
18
|
+
import { isLocalCollaborationTarget } from "./task-collaboration.js";
|
|
19
|
+
import { formatTaskReference } from "./shared/task-reference.js";
|
|
17
20
|
const rlog = log.scope("routes");
|
|
18
21
|
const plog = rlog.scope("prompt");
|
|
19
|
-
const slog = rlog.scope("
|
|
22
|
+
const slog = rlog.scope("task");
|
|
20
23
|
const mlog = rlog.scope("msg");
|
|
24
|
+
const COMPACT_SUMMARY_PROMPT = [
|
|
25
|
+
"Prepare a concise context handoff for a fresh execution of this WebAgent task.",
|
|
26
|
+
"Summarize the user's current goal, completed work, current state, key decisions,",
|
|
27
|
+
"unresolved blockers, and the exact next action. Do not use tools, modify files,",
|
|
28
|
+
"or continue the task. Output only the handoff summary in plain text or Markdown.",
|
|
29
|
+
].join(" ");
|
|
30
|
+
function buildCompactSummaryPrompt(guidance) {
|
|
31
|
+
const trimmed = guidance?.trim();
|
|
32
|
+
if (!trimmed)
|
|
33
|
+
return COMPACT_SUMMARY_PROMPT;
|
|
34
|
+
return [
|
|
35
|
+
COMPACT_SUMMARY_PROMPT,
|
|
36
|
+
"",
|
|
37
|
+
"Compaction guidance from the user:",
|
|
38
|
+
trimmed,
|
|
39
|
+
"",
|
|
40
|
+
"Use this guidance to prioritize what to preserve.",
|
|
41
|
+
"The guidance controls summarization priority; it is not a task to execute.",
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
function prependCompactSummary(summary, userText) {
|
|
45
|
+
return [
|
|
46
|
+
"The following is an agent-generated context handoff from the previous execution.",
|
|
47
|
+
"It is background context, not a new user request. Use it to understand continuity.",
|
|
48
|
+
"",
|
|
49
|
+
"--- previous execution summary ---",
|
|
50
|
+
summary,
|
|
51
|
+
"--- end previous execution summary ---",
|
|
52
|
+
"",
|
|
53
|
+
"The user's new request is:",
|
|
54
|
+
userText,
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
21
57
|
import { signAttachmentUrl, verifyAttachmentSig, reSignAttachmentUrlsInJson, } from "./auth.js";
|
|
22
58
|
import { buildContentDisposition, classifyKind, isInlineMime, mimeToExt, normalizeDisplayName, sniffMime, } from "./attachments.js";
|
|
23
59
|
import { readImageDimensions } from "./image-dimensions.js";
|
|
@@ -109,7 +145,7 @@ function getClientOpId(req) {
|
|
|
109
145
|
}
|
|
110
146
|
function logPromptRejectBeforeSave(fields) {
|
|
111
147
|
plog.warn("rejected before save", {
|
|
112
|
-
|
|
148
|
+
taskId: fields.taskId.slice(0, 8),
|
|
113
149
|
status: fields.status,
|
|
114
150
|
reason: fields.reason,
|
|
115
151
|
...(fields.opId ? { opId: fields.opId } : {}),
|
|
@@ -121,11 +157,11 @@ function logPromptRejectBeforeSave(fields) {
|
|
|
121
157
|
...(fields.error ? { error: fields.error } : {}),
|
|
122
158
|
});
|
|
123
159
|
}
|
|
124
|
-
function tryReplayClientOp(req, res, store,
|
|
160
|
+
function tryReplayClientOp(req, res, store, taskId) {
|
|
125
161
|
const opId = getClientOpId(req);
|
|
126
162
|
if (!opId)
|
|
127
163
|
return { opId: null, replayed: false };
|
|
128
|
-
const cached = store.getClientOp(
|
|
164
|
+
const cached = store.getClientOp(taskId, opId);
|
|
129
165
|
if (cached &&
|
|
130
166
|
typeof cached === "object" &&
|
|
131
167
|
"status" in cached &&
|
|
@@ -135,10 +171,18 @@ function tryReplayClientOp(req, res, store, sessionId) {
|
|
|
135
171
|
}
|
|
136
172
|
return { opId, replayed: false };
|
|
137
173
|
}
|
|
138
|
-
function saveClientOpResult(store, opId,
|
|
174
|
+
function saveClientOpResult(store, opId, taskId, status, body) {
|
|
139
175
|
if (!opId)
|
|
140
176
|
return;
|
|
141
|
-
store.saveClientOp(
|
|
177
|
+
store.saveClientOp(taskId, opId, { status, body });
|
|
178
|
+
}
|
|
179
|
+
function validateCollaborationTitle(title) {
|
|
180
|
+
if (typeof title !== "string")
|
|
181
|
+
return null;
|
|
182
|
+
if (!title.trim() || title.includes("/") || title === "." || title === "..") {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return title;
|
|
142
186
|
}
|
|
143
187
|
/** Send a JSON response, gzip-compressed when the client supports it. */
|
|
144
188
|
function json(res, status, data, req) {
|
|
@@ -167,7 +211,7 @@ export function getPrincipal(req) {
|
|
|
167
211
|
}
|
|
168
212
|
/**
|
|
169
213
|
* Multipart upload handler. Streams the `file` field straight to disk under
|
|
170
|
-
* <data_dir>/
|
|
214
|
+
* <data_dir>/tasks/<sid>/attachments/<uuid>.<ext>.tmp, atomic-renames on
|
|
171
215
|
* success, deletes on any failure path. Inserts an attachments row with the
|
|
172
216
|
* resolved realpath so the bridge / permission interceptor can match it
|
|
173
217
|
* later.
|
|
@@ -178,14 +222,14 @@ export function getPrincipal(req) {
|
|
|
178
222
|
* - Optional text fields are ignored — displayName comes from the file
|
|
179
223
|
* part's filename header, classification comes from its content-type.
|
|
180
224
|
*/
|
|
181
|
-
async function handleAttachmentUpload(req, res,
|
|
182
|
-
const { store, dataDir, limits,
|
|
225
|
+
async function handleAttachmentUpload(req, res, taskId, deps) {
|
|
226
|
+
const { store, dataDir, limits, tasks } = deps;
|
|
183
227
|
const fileUploadLimit = limits.file_upload ?? 52_428_800;
|
|
184
|
-
if (!store.
|
|
185
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
228
|
+
if (!store.getTask(taskId)) {
|
|
229
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
186
230
|
return;
|
|
187
231
|
}
|
|
188
|
-
const dir = join(dataDir, "
|
|
232
|
+
const dir = join(dataDir, "tasks", taskId, "attachments");
|
|
189
233
|
await mkdir(dir, { recursive: true });
|
|
190
234
|
const uploadId = randomUUID();
|
|
191
235
|
let tmpPath = null;
|
|
@@ -360,7 +404,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
360
404
|
const rp = await realpath(finalPath);
|
|
361
405
|
const row = store.insertAttachment({
|
|
362
406
|
id: uploadId,
|
|
363
|
-
|
|
407
|
+
taskId,
|
|
364
408
|
kind,
|
|
365
409
|
name: displayName,
|
|
366
410
|
mime: fileMime,
|
|
@@ -369,13 +413,13 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
369
413
|
width: imageDimensions?.width ?? null,
|
|
370
414
|
height: imageDimensions?.height ?? null,
|
|
371
415
|
});
|
|
372
|
-
// Invalidate the per-
|
|
416
|
+
// Invalidate the per-task attachment label cache so the
|
|
373
417
|
// next egress (SSE broadcast or replay) sees this new row.
|
|
374
|
-
|
|
418
|
+
tasks?.invalidateLabelCache(taskId);
|
|
375
419
|
const fileName = `${row.id}.${fileExt}`;
|
|
376
|
-
const basePath = `/api/v1/
|
|
420
|
+
const basePath = `/api/v1/tasks/${taskId}/attachments/${fileName}`;
|
|
377
421
|
// 1h signed URL — long enough that the browser holds the rendered
|
|
378
|
-
// image in <img> cache for the full
|
|
422
|
+
// image in <img> cache for the full task lifetime, short enough
|
|
379
423
|
// that a leaked URL (screenshot, link share) expires within the day.
|
|
380
424
|
const fileUrl = deps.attachmentSecret
|
|
381
425
|
? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
|
|
@@ -388,7 +432,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
388
432
|
width: row.width,
|
|
389
433
|
height: row.height,
|
|
390
434
|
kind: row.kind,
|
|
391
|
-
path: `
|
|
435
|
+
path: `tasks/${taskId}/attachments/${fileName}`,
|
|
392
436
|
url: fileUrl,
|
|
393
437
|
});
|
|
394
438
|
}
|
|
@@ -404,11 +448,15 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
404
448
|
});
|
|
405
449
|
}
|
|
406
450
|
export function createRequestHandler(deps) {
|
|
407
|
-
const { store,
|
|
408
|
-
let
|
|
451
|
+
const { store, tasks, getBridge, sseManager } = deps;
|
|
452
|
+
let bootstrapTaskPromise = null;
|
|
409
453
|
// eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
|
|
410
454
|
return async (req, res) => {
|
|
411
455
|
const url = req.url ?? "/";
|
|
456
|
+
// --- MCP server: claims /mcp before the auth gate (the endpoint
|
|
457
|
+
// is outside /api/** and authenticates by per-task capability).
|
|
458
|
+
if (deps.mcpEndpoint && (await deps.mcpEndpoint(req, res)))
|
|
459
|
+
return;
|
|
412
460
|
// --- Auth gate: any /api/** outside whitelist requires Bearer ---
|
|
413
461
|
if (deps.authStore && url.startsWith("/api/")) {
|
|
414
462
|
const path = url.split("?")[0] ?? url;
|
|
@@ -430,20 +478,20 @@ export function createRequestHandler(deps) {
|
|
|
430
478
|
// their URL space before the generic /api/v1 branch. When
|
|
431
479
|
// `shareConfig.enabled === false` handleShareRoutes is a no-op.
|
|
432
480
|
// The auth gate above has already enforced Bearer on owner endpoints
|
|
433
|
-
// (/api/v1/
|
|
481
|
+
// (/api/v1/tasks/:id/share*, /api/v1/shares); viewer endpoints
|
|
434
482
|
// (/s/:token, /api/v1/shared/:token/events) must be whitelisted in
|
|
435
483
|
// auth-middleware.ts so they remain public.
|
|
436
484
|
if (deps.shareConfig &&
|
|
437
485
|
(await handleShareRoutes(req, res, {
|
|
438
486
|
store,
|
|
439
|
-
|
|
487
|
+
tasks,
|
|
440
488
|
config: deps.shareConfig,
|
|
441
489
|
dataDir: deps.dataDir,
|
|
442
490
|
publicDir: deps.publicDir,
|
|
443
491
|
}))) {
|
|
444
492
|
return;
|
|
445
493
|
}
|
|
446
|
-
// File viewer —
|
|
494
|
+
// File viewer — task-less read-only access to arbitrary local paths.
|
|
447
495
|
// Claims /api/v1/files/{info,list,content} before the generic /api/v1
|
|
448
496
|
// branch. info/list use the Bearer gate above; content is whitelisted
|
|
449
497
|
// only because its handler requires an HMAC-signed URL for headerless
|
|
@@ -459,7 +507,7 @@ export function createRequestHandler(deps) {
|
|
|
459
507
|
json(res, HTTP_STATUS.OK, {
|
|
460
508
|
version: "v1",
|
|
461
509
|
endpoints: {
|
|
462
|
-
|
|
510
|
+
tasks: "/api/v1/tasks",
|
|
463
511
|
paths: "/api/v1/recent-paths",
|
|
464
512
|
files: "/api/v1/files",
|
|
465
513
|
config: "/api/v1/config",
|
|
@@ -471,19 +519,31 @@ export function createRequestHandler(deps) {
|
|
|
471
519
|
});
|
|
472
520
|
return;
|
|
473
521
|
}
|
|
474
|
-
// GET /api/v1/
|
|
475
|
-
if (url.startsWith("/api/v1/
|
|
476
|
-
!url.slice("/api/v1/
|
|
522
|
+
// GET /api/v1/tasks
|
|
523
|
+
if (url.startsWith("/api/v1/tasks") &&
|
|
524
|
+
!url.slice("/api/v1/tasks".length).match(/^\//) &&
|
|
477
525
|
req.method === "GET") {
|
|
478
526
|
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
479
527
|
const source = params.get("source") ?? undefined;
|
|
480
|
-
|
|
528
|
+
const publicTasks = store
|
|
529
|
+
.listTasks(source ? { source } : undefined)
|
|
530
|
+
.map((task) => {
|
|
531
|
+
const { pending_compact_summary: _pendingCompactSummary, has_user_input: hasUserInput, ...publicTask } = task;
|
|
532
|
+
// Home-abbreviated display form for menus and lists; the raw cwd
|
|
533
|
+
// stays the canonical round-trip value.
|
|
534
|
+
return {
|
|
535
|
+
...publicTask,
|
|
536
|
+
cwdDisplay: abbreviateHomePath(task.cwd),
|
|
537
|
+
hasUserInput: Boolean(hasUserInput),
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
res.end(JSON.stringify(publicTasks));
|
|
481
541
|
return;
|
|
482
542
|
}
|
|
483
543
|
// --- GET /api/v1/config ---
|
|
484
544
|
if (url === "/api/v1/config" && req.method === "GET") {
|
|
485
545
|
json(res, HTTP_STATUS.OK, {
|
|
486
|
-
configOptions:
|
|
546
|
+
configOptions: tasks?.cachedConfigOptions ?? [],
|
|
487
547
|
cancelTimeout: deps.limits.cancel_timeout ?? 0,
|
|
488
548
|
recentPathsLimit: deps.limits.recent_paths ?? 10,
|
|
489
549
|
});
|
|
@@ -509,7 +569,7 @@ export function createRequestHandler(deps) {
|
|
|
509
569
|
if (url === "/api/v1/version" && req.method === "GET") {
|
|
510
570
|
json(res, HTTP_STATUS.OK, {
|
|
511
571
|
server: deps.serverVersion ?? "unknown",
|
|
512
|
-
agent:
|
|
572
|
+
agent: tasks?.agentInfo ?? null,
|
|
513
573
|
});
|
|
514
574
|
return;
|
|
515
575
|
}
|
|
@@ -661,7 +721,7 @@ export function createRequestHandler(deps) {
|
|
|
661
721
|
return;
|
|
662
722
|
}
|
|
663
723
|
try {
|
|
664
|
-
await bridge.restart(
|
|
724
|
+
await bridge.restart(tasks);
|
|
665
725
|
json(res, HTTP_STATUS.OK, { ok: true });
|
|
666
726
|
}
|
|
667
727
|
catch (err) {
|
|
@@ -671,30 +731,30 @@ export function createRequestHandler(deps) {
|
|
|
671
731
|
}
|
|
672
732
|
return;
|
|
673
733
|
}
|
|
674
|
-
// --- Permissions (
|
|
675
|
-
// GET /api/v1/
|
|
676
|
-
const permListMatch = url.match(/^\/api\/v1\/
|
|
734
|
+
// --- Permissions (task-scoped) ---
|
|
735
|
+
// GET /api/v1/tasks/:id/permissions
|
|
736
|
+
const permListMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/permissions\/?(\?.*)?$/);
|
|
677
737
|
if (permListMatch && req.method === "GET") {
|
|
678
|
-
const
|
|
679
|
-
const perms =
|
|
738
|
+
const taskId = decodeURIComponent(permListMatch[1]);
|
|
739
|
+
const perms = tasks?.getPendingPermissions(taskId) ?? [];
|
|
680
740
|
json(res, HTTP_STATUS.OK, perms);
|
|
681
741
|
return;
|
|
682
742
|
}
|
|
683
|
-
// POST /api/v1/
|
|
684
|
-
const permActionMatch = url.match(/^\/api\/v1\/
|
|
743
|
+
// POST /api/v1/tasks/:id/permissions/:reqId
|
|
744
|
+
const permActionMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/permissions\/([^/?]+)\/?$/);
|
|
685
745
|
if (permActionMatch && req.method === "POST") {
|
|
686
|
-
const
|
|
746
|
+
const taskId = decodeURIComponent(permActionMatch[1]);
|
|
687
747
|
const requestId = decodeURIComponent(permActionMatch[2]);
|
|
688
|
-
const { opId, replayed } = tryReplayClientOp(req, res, store,
|
|
748
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
|
|
689
749
|
if (replayed)
|
|
690
750
|
return;
|
|
691
|
-
const perm =
|
|
751
|
+
const perm = tasks?.pendingPermissions.get(requestId);
|
|
692
752
|
if (!perm) {
|
|
693
753
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "Permission not found" });
|
|
694
754
|
return;
|
|
695
755
|
}
|
|
696
|
-
if (perm.
|
|
697
|
-
json(res, HTTP_STATUS.BAD_REQUEST, { error: "
|
|
756
|
+
if (perm.taskId !== taskId) {
|
|
757
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "Task ID mismatch" });
|
|
698
758
|
return;
|
|
699
759
|
}
|
|
700
760
|
const bridge = getBridge?.();
|
|
@@ -727,128 +787,80 @@ export function createRequestHandler(deps) {
|
|
|
727
787
|
else {
|
|
728
788
|
bridge.resolvePermission(requestId, optionId);
|
|
729
789
|
}
|
|
730
|
-
|
|
731
|
-
|
|
790
|
+
tasks.pendingPermissions.delete(requestId);
|
|
791
|
+
tasks.syncPendingPermissions(taskId);
|
|
732
792
|
// Store event and broadcast (same type so SSE drops are recoverable via sync)
|
|
733
793
|
const permEventData = { requestId, optionName, denied };
|
|
734
|
-
store.saveEvent(perm.
|
|
794
|
+
store.saveEvent(perm.taskId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
|
|
735
795
|
sseManager.broadcast({
|
|
736
796
|
type: "permission_response",
|
|
737
|
-
|
|
797
|
+
taskId: perm.taskId,
|
|
738
798
|
...permEventData,
|
|
739
799
|
});
|
|
740
800
|
// Cross-device banner recall: close the permission banner on
|
|
741
801
|
// every subscribed endpoint now that the permission has been
|
|
742
802
|
// handled by this client.
|
|
743
803
|
if (deps.pushService) {
|
|
744
|
-
void deps.pushService.sendClose(`sess-${perm.
|
|
804
|
+
void deps.pushService.sendClose(`sess-${perm.taskId}-perm-${requestId}`);
|
|
745
805
|
}
|
|
746
806
|
const okBody = { ok: true };
|
|
747
|
-
saveClientOpResult(store, opId,
|
|
807
|
+
saveClientOpResult(store, opId, taskId, HTTP_STATUS.OK, okBody);
|
|
748
808
|
json(res, HTTP_STATUS.OK, okBody);
|
|
749
809
|
return;
|
|
750
810
|
}
|
|
751
|
-
// --- POST /api/v1/
|
|
752
|
-
const cancelMatch = url.match(/^\/api\/v1\/
|
|
811
|
+
// --- POST /api/v1/tasks/:id/cancel ---
|
|
812
|
+
const cancelMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/cancel\/?$/);
|
|
753
813
|
if (cancelMatch && req.method === "POST") {
|
|
754
|
-
const
|
|
755
|
-
const
|
|
756
|
-
if (!
|
|
757
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
814
|
+
const taskId = decodeURIComponent(cancelMatch[1]);
|
|
815
|
+
const task = store.getTask(taskId);
|
|
816
|
+
if (!task) {
|
|
817
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
758
818
|
return;
|
|
759
819
|
}
|
|
760
|
-
const { opId, replayed } = tryReplayClientOp(req, res, store,
|
|
820
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
|
|
761
821
|
if (replayed)
|
|
762
822
|
return;
|
|
763
|
-
|
|
764
|
-
const hadPendingPrompt = sessions?.cancelPendingPromptSubmission(sessionId) ?? false;
|
|
765
|
-
const hadBash = sessions?.runningBashProcs.has(sessionId) ?? false;
|
|
766
|
-
if (!hadAgentPrompt && !hadPendingPrompt && !hadBash) {
|
|
823
|
+
if (!tasks) {
|
|
767
824
|
const idleBody = { ok: true, status: "idle" };
|
|
768
|
-
saveClientOpResult(store, opId,
|
|
825
|
+
saveClientOpResult(store, opId, taskId, HTTP_STATUS.OK, idleBody);
|
|
769
826
|
json(res, HTTP_STATUS.OK, idleBody);
|
|
770
827
|
return;
|
|
771
828
|
}
|
|
772
|
-
const
|
|
773
|
-
? (
|
|
829
|
+
const bridge = tasks.activePrompts.has(taskId)
|
|
830
|
+
? (getBridge?.() ?? null)
|
|
774
831
|
: null;
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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;
|
|
832
|
+
let result;
|
|
833
|
+
try {
|
|
834
|
+
result = await tasks.cancelTaskExecution(taskId, bridge, deps.limits.cancel_timeout ?? 0);
|
|
788
835
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
rlog.info("cancel requested", {
|
|
796
|
-
sessionId: sessionId.slice(0, 8),
|
|
797
|
-
retry: previousCancelStatus !== null,
|
|
798
|
-
previousStatus: previousCancelStatus,
|
|
799
|
-
});
|
|
800
|
-
await bridge.cancel(sessionId);
|
|
801
|
-
const busy = sessions.state.getState(sessionId).runtime.busy;
|
|
802
|
-
const stillCancellingSamePrompt = sessions.activePrompts.has(sessionId) &&
|
|
803
|
-
busy?.kind === "agent" &&
|
|
804
|
-
busy.promptId === cancelledPromptId;
|
|
805
|
-
if (stillCancellingSamePrompt) {
|
|
806
|
-
sessions.state.markCancelRequested(sessionId);
|
|
836
|
+
catch (error) {
|
|
837
|
+
if (error instanceof AgentNotReadyError) {
|
|
838
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
839
|
+
error: "Agent not ready yet",
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
807
842
|
}
|
|
843
|
+
throw error;
|
|
808
844
|
}
|
|
809
|
-
|
|
810
|
-
// instead of pretending the prompt stopped.
|
|
811
|
-
const cancelTimeout = deps.limits.cancel_timeout ?? 0;
|
|
812
|
-
const busyAfterCancel = sessions?.state.getState(sessionId).runtime.busy;
|
|
813
|
-
const cancelPending = hadAgentPrompt &&
|
|
814
|
-
sessions?.activePrompts.has(sessionId) === true &&
|
|
815
|
-
busyAfterCancel?.kind === "agent" &&
|
|
816
|
-
busyAfterCancel.promptId === cancelledPromptId;
|
|
817
|
-
if (cancelPending && cancelTimeout > 0)
|
|
818
|
-
sessions.state.armCancelSafety(sessionId, cancelTimeout);
|
|
819
|
-
sessions?.syncBusy(sessionId);
|
|
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
|
|
845
|
+
const status = result.status === "cancelling" || result.status === "superseded"
|
|
827
846
|
? HTTP_STATUS.ACCEPTED
|
|
828
847
|
: HTTP_STATUS.OK;
|
|
829
|
-
const okBody = {
|
|
830
|
-
|
|
831
|
-
status: workPending
|
|
832
|
-
? "cancelling"
|
|
833
|
-
: replacementPromptActive
|
|
834
|
-
? "superseded"
|
|
835
|
-
: "cancelled",
|
|
836
|
-
};
|
|
837
|
-
saveClientOpResult(store, opId, sessionId, status, okBody);
|
|
848
|
+
const okBody = { ok: true, status: result.status };
|
|
849
|
+
saveClientOpResult(store, opId, taskId, status, okBody);
|
|
838
850
|
json(res, status, okBody);
|
|
839
851
|
return;
|
|
840
852
|
}
|
|
841
|
-
// --- GET /api/v1/
|
|
842
|
-
const statusMatch = url.match(/^\/api\/v1\/
|
|
853
|
+
// --- GET /api/v1/tasks/:id/status ---
|
|
854
|
+
const statusMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/status\/?$/);
|
|
843
855
|
if (statusMatch && req.method === "GET") {
|
|
844
|
-
const
|
|
845
|
-
const
|
|
846
|
-
if (!
|
|
847
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
856
|
+
const taskId = decodeURIComponent(statusMatch[1]);
|
|
857
|
+
const task = store.getTask(taskId);
|
|
858
|
+
if (!task) {
|
|
859
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
848
860
|
return;
|
|
849
861
|
}
|
|
850
|
-
const busyKind =
|
|
851
|
-
const pendingPerms =
|
|
862
|
+
const busyKind = tasks?.getBusyKind(taskId) ?? null;
|
|
863
|
+
const pendingPerms = tasks?.getPendingPermissions(taskId) ?? [];
|
|
852
864
|
json(res, HTTP_STATUS.OK, {
|
|
853
865
|
busy: busyKind != null,
|
|
854
866
|
busyKind,
|
|
@@ -856,84 +868,184 @@ export function createRequestHandler(deps) {
|
|
|
856
868
|
});
|
|
857
869
|
return;
|
|
858
870
|
}
|
|
859
|
-
// --- GET /api/v1/
|
|
871
|
+
// --- GET /api/v1/tasks/:id/snapshot ---
|
|
860
872
|
// client-server-split M1: single source of truth for "what state is
|
|
861
|
-
// this
|
|
873
|
+
// this task in right now". Frontend calls this on connect / reconnect
|
|
862
874
|
// / after long backgrounding, then applies incremental `state_patch`
|
|
863
875
|
// SSE events.
|
|
864
|
-
const snapshotMatch = url.match(/^\/api\/v1\/
|
|
876
|
+
const snapshotMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/snapshot\/?$/);
|
|
865
877
|
if (snapshotMatch && req.method === "GET") {
|
|
866
|
-
const
|
|
867
|
-
const
|
|
868
|
-
if (!
|
|
869
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
878
|
+
const taskId = decodeURIComponent(snapshotMatch[1]);
|
|
879
|
+
const task = store.getTask(taskId);
|
|
880
|
+
if (!task) {
|
|
881
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
870
882
|
return;
|
|
871
883
|
}
|
|
872
|
-
if (!
|
|
884
|
+
if (!tasks) {
|
|
873
885
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
874
|
-
error: "
|
|
886
|
+
error: "Task manager not available",
|
|
875
887
|
});
|
|
876
888
|
return;
|
|
877
889
|
}
|
|
878
890
|
const bridge = getBridge?.();
|
|
879
|
-
if (bridge && !
|
|
891
|
+
if (bridge && !tasks.liveTasks.has(taskId)) {
|
|
880
892
|
try {
|
|
881
|
-
// Command discovery happens during
|
|
893
|
+
// Command discovery happens during task/load. Snapshot is the
|
|
882
894
|
// authoritative hydration boundary, so it must join any in-flight
|
|
883
|
-
// restore before reading the per-
|
|
884
|
-
await
|
|
895
|
+
// restore before reading the per-task command state.
|
|
896
|
+
await tasks.ensureResumed(bridge, taskId);
|
|
885
897
|
}
|
|
886
898
|
catch {
|
|
887
899
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
888
|
-
error: "Failed to restore
|
|
900
|
+
error: "Failed to restore task",
|
|
889
901
|
});
|
|
890
902
|
return;
|
|
891
903
|
}
|
|
892
904
|
}
|
|
893
905
|
// Make sure runtime reflects the current activePrompts/bash state even
|
|
894
|
-
// if no patch has been emitted yet for this
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
const runtimeState =
|
|
898
|
-
const lastEventSeq = store.getLastEventSeq(
|
|
906
|
+
// if no patch has been emitted yet for this task.
|
|
907
|
+
tasks.syncBusy(taskId);
|
|
908
|
+
tasks.syncPendingPermissions(taskId);
|
|
909
|
+
const runtimeState = tasks.state.getState(taskId);
|
|
910
|
+
const lastEventSeq = store.getLastEventSeq(taskId);
|
|
899
911
|
json(res, HTTP_STATUS.OK, {
|
|
900
912
|
version: 1,
|
|
901
913
|
seq: runtimeState.seq,
|
|
902
|
-
|
|
903
|
-
id:
|
|
904
|
-
title:
|
|
905
|
-
cwd:
|
|
906
|
-
cwdDisplay: abbreviateHomePath(
|
|
907
|
-
model:
|
|
908
|
-
mode:
|
|
909
|
-
createdAt:
|
|
914
|
+
task: {
|
|
915
|
+
id: task.id,
|
|
916
|
+
title: task.title,
|
|
917
|
+
cwd: task.cwd,
|
|
918
|
+
cwdDisplay: abbreviateHomePath(task.cwd),
|
|
919
|
+
model: task.model,
|
|
920
|
+
mode: task.mode,
|
|
921
|
+
createdAt: task.created_at,
|
|
910
922
|
lastEventSeq,
|
|
911
923
|
},
|
|
912
924
|
runtime: runtimeState.runtime,
|
|
913
|
-
agentCommands:
|
|
925
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
914
926
|
}, req);
|
|
915
927
|
return;
|
|
916
928
|
}
|
|
917
|
-
// --- POST /api/v1/
|
|
918
|
-
const
|
|
929
|
+
// --- POST /api/v1/tasks/:sourceTaskId/messages ---
|
|
930
|
+
const collaborationMessageMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/messages\/?(?:\?.*)?$/);
|
|
931
|
+
if (collaborationMessageMatch && req.method === "POST") {
|
|
932
|
+
const sourceTaskId = decodeURIComponent(collaborationMessageMatch[1]);
|
|
933
|
+
const sourceTask = store.getTask(sourceTaskId);
|
|
934
|
+
if (!sourceTask) {
|
|
935
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Source task not found" });
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const bridge = getBridge?.();
|
|
939
|
+
if (!bridge || !tasks) {
|
|
940
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
941
|
+
error: "Agent not ready yet",
|
|
942
|
+
});
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sourceTaskId);
|
|
946
|
+
if (replayed)
|
|
947
|
+
return;
|
|
948
|
+
let body;
|
|
949
|
+
try {
|
|
950
|
+
body = JSON.parse(await readBody(req));
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (typeof body.targetTaskId !== "string" ||
|
|
957
|
+
typeof body.body !== "string" ||
|
|
958
|
+
!body.body.trim()) {
|
|
959
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
960
|
+
error: "targetTaskId and non-empty body are required",
|
|
961
|
+
});
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const targetTask = store.getTask(body.targetTaskId);
|
|
965
|
+
if (!targetTask) {
|
|
966
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Target task not found" });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (!isLocalCollaborationTarget(sourceTask, targetTask)) {
|
|
970
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
971
|
+
error: "Target task is outside the local collaboration scope",
|
|
972
|
+
});
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const messageId = randomUUID();
|
|
976
|
+
const deliveryId = randomUUID();
|
|
977
|
+
// A store-level rejection here must answer a JSON error, never an
|
|
978
|
+
// unhandled rejection: the request handler is fired with `void` and
|
|
979
|
+
// has no outer catch, so a throw would take the process down.
|
|
980
|
+
let created;
|
|
981
|
+
try {
|
|
982
|
+
created = store.createCollaborationMessage({
|
|
983
|
+
id: messageId,
|
|
984
|
+
deliveryId,
|
|
985
|
+
sourceTaskId,
|
|
986
|
+
directTargetTaskId: targetTask.id,
|
|
987
|
+
sourceActor: "user",
|
|
988
|
+
body: body.body,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
catch (err) {
|
|
992
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
993
|
+
if (msg.startsWith("Live task not found")) {
|
|
994
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: msg });
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: msg });
|
|
998
|
+
}
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
// A user-originated collaboration action counts as activity on the
|
|
1002
|
+
// Task where the user entered it, not only on the recipient.
|
|
1003
|
+
store.updateTaskLastActive(sourceTaskId);
|
|
1004
|
+
const collaborationTitle = `${formatTaskReference(sourceTask.title ?? sourceTask.id.slice(0, 8))} sent ${formatTaskReference(targetTask.title ?? targetTask.id.slice(0, 8))}`;
|
|
1005
|
+
for (const projection of store.listCollaborationProjections(messageId)) {
|
|
1006
|
+
sseManager.broadcast({
|
|
1007
|
+
type: "system_message",
|
|
1008
|
+
taskId: projection.task_id,
|
|
1009
|
+
kind: "collaboration",
|
|
1010
|
+
messageId,
|
|
1011
|
+
sourceTaskId,
|
|
1012
|
+
targetTaskId: targetTask.id,
|
|
1013
|
+
role: projection.role,
|
|
1014
|
+
title: collaborationTitle,
|
|
1015
|
+
body: created.message.body,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
const result = {
|
|
1019
|
+
messageId,
|
|
1020
|
+
deliveryId,
|
|
1021
|
+
status: "queued",
|
|
1022
|
+
clientOpId: opId ?? undefined,
|
|
1023
|
+
};
|
|
1024
|
+
saveClientOpResult(store, opId, sourceTaskId, HTTP_STATUS.ACCEPTED, result);
|
|
1025
|
+
json(res, HTTP_STATUS.ACCEPTED, result, req);
|
|
1026
|
+
void tasks.drainCollaborationDeliveries(bridge, targetTask.id);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
// --- POST /api/v1/tasks/:id/prompt ---
|
|
1030
|
+
const promptMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/prompt\/?(\?.*)?$/);
|
|
919
1031
|
if (promptMatch && req.method === "POST") {
|
|
920
|
-
const
|
|
1032
|
+
const taskId = decodeURIComponent(promptMatch[1]);
|
|
921
1033
|
const requestOpId = getClientOpId(req);
|
|
922
|
-
const
|
|
923
|
-
if (!
|
|
1034
|
+
const task = store.getTask(taskId);
|
|
1035
|
+
if (!task) {
|
|
924
1036
|
logPromptRejectBeforeSave({
|
|
925
|
-
|
|
1037
|
+
taskId,
|
|
926
1038
|
status: HTTP_STATUS.NOT_FOUND,
|
|
927
|
-
reason: "
|
|
1039
|
+
reason: "task_not_found",
|
|
928
1040
|
opId: requestOpId,
|
|
929
1041
|
});
|
|
930
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
1042
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
931
1043
|
return;
|
|
932
1044
|
}
|
|
933
1045
|
const bridge = getBridge?.();
|
|
934
1046
|
if (!bridge) {
|
|
935
1047
|
logPromptRejectBeforeSave({
|
|
936
|
-
|
|
1048
|
+
taskId,
|
|
937
1049
|
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
938
1050
|
reason: "agent_not_ready",
|
|
939
1051
|
opId: requestOpId,
|
|
@@ -943,33 +1055,33 @@ export function createRequestHandler(deps) {
|
|
|
943
1055
|
});
|
|
944
1056
|
return;
|
|
945
1057
|
}
|
|
946
|
-
if (!
|
|
1058
|
+
if (!tasks) {
|
|
947
1059
|
logPromptRejectBeforeSave({
|
|
948
|
-
|
|
1060
|
+
taskId,
|
|
949
1061
|
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
950
|
-
reason: "
|
|
1062
|
+
reason: "task_manager_unavailable",
|
|
951
1063
|
opId: requestOpId,
|
|
952
1064
|
});
|
|
953
1065
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
954
|
-
error: "
|
|
1066
|
+
error: "Task manager not available",
|
|
955
1067
|
});
|
|
956
1068
|
return;
|
|
957
1069
|
}
|
|
958
|
-
const { opId, replayed } = tryReplayClientOp(req, res, store,
|
|
1070
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
|
|
959
1071
|
if (replayed)
|
|
960
1072
|
return;
|
|
961
|
-
const promptSubmissionId =
|
|
1073
|
+
const promptSubmissionId = tasks.reservePromptSubmission(taskId);
|
|
962
1074
|
if (promptSubmissionId === null) {
|
|
963
|
-
const busyKind =
|
|
1075
|
+
const busyKind = tasks.getBusyKind(taskId);
|
|
964
1076
|
logPromptRejectBeforeSave({
|
|
965
|
-
|
|
1077
|
+
taskId,
|
|
966
1078
|
status: HTTP_STATUS.CONFLICT,
|
|
967
|
-
reason: "
|
|
1079
|
+
reason: "task_busy",
|
|
968
1080
|
opId,
|
|
969
1081
|
busyKind: busyKind ?? undefined,
|
|
970
1082
|
});
|
|
971
1083
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
972
|
-
error: "
|
|
1084
|
+
error: "Task is busy",
|
|
973
1085
|
busyKind,
|
|
974
1086
|
});
|
|
975
1087
|
return;
|
|
@@ -978,39 +1090,39 @@ export function createRequestHandler(deps) {
|
|
|
978
1090
|
const isRequestAborted = () => requestState.aborted;
|
|
979
1091
|
const abortPromptSubmission = () => {
|
|
980
1092
|
requestState.aborted = true;
|
|
981
|
-
|
|
1093
|
+
tasks.releasePromptSubmission(taskId, promptSubmissionId);
|
|
982
1094
|
};
|
|
983
1095
|
res.once("finish", () => {
|
|
984
|
-
|
|
1096
|
+
tasks.releasePromptSubmission(taskId, promptSubmissionId);
|
|
985
1097
|
});
|
|
986
1098
|
req.once("aborted", abortPromptSubmission);
|
|
987
1099
|
res.once("close", () => {
|
|
988
1100
|
if (!res.writableEnded)
|
|
989
1101
|
abortPromptSubmission();
|
|
990
1102
|
});
|
|
991
|
-
// Ensure
|
|
1103
|
+
// Ensure task is live in ACP before prompting (awaits in-flight resume)
|
|
992
1104
|
try {
|
|
993
|
-
await
|
|
1105
|
+
await tasks.ensureResumed(bridge, taskId);
|
|
994
1106
|
}
|
|
995
1107
|
catch (err) {
|
|
996
1108
|
if (isRequestAborted())
|
|
997
1109
|
return;
|
|
998
1110
|
logPromptRejectBeforeSave({
|
|
999
|
-
|
|
1111
|
+
taskId,
|
|
1000
1112
|
status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
|
|
1001
1113
|
reason: "resume_failed",
|
|
1002
1114
|
opId,
|
|
1003
1115
|
error: errorMessage(err),
|
|
1004
1116
|
});
|
|
1005
1117
|
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
1006
|
-
error: `Failed to resume
|
|
1118
|
+
error: `Failed to resume task: ${err instanceof Error ? err.message : String(err)}`,
|
|
1007
1119
|
});
|
|
1008
1120
|
return;
|
|
1009
1121
|
}
|
|
1010
1122
|
if (isRequestAborted() ||
|
|
1011
|
-
|
|
1123
|
+
tasks.isPromptSubmissionCancelled(promptSubmissionId)) {
|
|
1012
1124
|
logPromptRejectBeforeSave({
|
|
1013
|
-
|
|
1125
|
+
taskId,
|
|
1014
1126
|
status: HTTP_STATUS.CONFLICT,
|
|
1015
1127
|
reason: "prompt_cancelled_before_start",
|
|
1016
1128
|
opId,
|
|
@@ -1028,7 +1140,7 @@ export function createRequestHandler(deps) {
|
|
|
1028
1140
|
if (isRequestAborted())
|
|
1029
1141
|
return;
|
|
1030
1142
|
logPromptRejectBeforeSave({
|
|
1031
|
-
|
|
1143
|
+
taskId,
|
|
1032
1144
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1033
1145
|
reason: "invalid_json",
|
|
1034
1146
|
opId,
|
|
@@ -1037,7 +1149,7 @@ export function createRequestHandler(deps) {
|
|
|
1037
1149
|
return;
|
|
1038
1150
|
}
|
|
1039
1151
|
if (isRequestAborted() ||
|
|
1040
|
-
|
|
1152
|
+
tasks.isPromptSubmissionCancelled(promptSubmissionId)) {
|
|
1041
1153
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
1042
1154
|
error: "Prompt was cancelled before start",
|
|
1043
1155
|
});
|
|
@@ -1045,7 +1157,7 @@ export function createRequestHandler(deps) {
|
|
|
1045
1157
|
}
|
|
1046
1158
|
if (!body.text) {
|
|
1047
1159
|
logPromptRejectBeforeSave({
|
|
1048
|
-
|
|
1160
|
+
taskId,
|
|
1049
1161
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1050
1162
|
reason: "missing_text",
|
|
1051
1163
|
opId,
|
|
@@ -1066,7 +1178,7 @@ export function createRequestHandler(deps) {
|
|
|
1066
1178
|
if (attachments) {
|
|
1067
1179
|
if (!Array.isArray(attachments)) {
|
|
1068
1180
|
logPromptRejectBeforeSave({
|
|
1069
|
-
|
|
1181
|
+
taskId,
|
|
1070
1182
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1071
1183
|
reason: "attachments_not_array",
|
|
1072
1184
|
opId,
|
|
@@ -1086,7 +1198,7 @@ export function createRequestHandler(deps) {
|
|
|
1086
1198
|
typeof att.displayName !== "string" ||
|
|
1087
1199
|
typeof att.mimeType !== "string") {
|
|
1088
1200
|
logPromptRejectBeforeSave({
|
|
1089
|
-
|
|
1201
|
+
taskId,
|
|
1090
1202
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1091
1203
|
reason: "invalid_attachment_entry",
|
|
1092
1204
|
opId,
|
|
@@ -1104,7 +1216,7 @@ export function createRequestHandler(deps) {
|
|
|
1104
1216
|
typeof att.width === "number" ||
|
|
1105
1217
|
typeof att.height === "number") {
|
|
1106
1218
|
logPromptRejectBeforeSave({
|
|
1107
|
-
|
|
1219
|
+
taskId,
|
|
1108
1220
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1109
1221
|
reason: "client_supplied_attachment_data",
|
|
1110
1222
|
opId,
|
|
@@ -1120,11 +1232,11 @@ export function createRequestHandler(deps) {
|
|
|
1120
1232
|
}
|
|
1121
1233
|
let agentText = body.text;
|
|
1122
1234
|
if (body.text.startsWith("//")) {
|
|
1123
|
-
const resolved = resolveAgentCommand(body.text,
|
|
1235
|
+
const resolved = resolveAgentCommand(body.text, tasks.getAgentCommands(taskId).commands);
|
|
1124
1236
|
if (!resolved) {
|
|
1125
1237
|
const command = agentCommandToken(body.text);
|
|
1126
1238
|
logPromptRejectBeforeSave({
|
|
1127
|
-
|
|
1239
|
+
taskId,
|
|
1128
1240
|
status: HTTP_STATUS.UNPROCESSABLE_CONTENT,
|
|
1129
1241
|
reason: "unknown_command",
|
|
1130
1242
|
opId,
|
|
@@ -1140,15 +1252,19 @@ export function createRequestHandler(deps) {
|
|
|
1140
1252
|
}
|
|
1141
1253
|
agentText = resolved.agentText;
|
|
1142
1254
|
}
|
|
1255
|
+
const pendingCompactSummary = store.getPendingCompactSummary(taskId);
|
|
1256
|
+
if (pendingCompactSummary) {
|
|
1257
|
+
agentText = prependCompactSummary(pendingCompactSummary, agentText);
|
|
1258
|
+
}
|
|
1143
1259
|
// Stored shape mirrors the wire shape PLUS a server-derived `path`
|
|
1144
1260
|
// for renderers. The path is the unsigned base URL
|
|
1145
|
-
// (`/api/v1/
|
|
1261
|
+
// (`/api/v1/tasks/<sid>/attachments/<filename>`); reSign on
|
|
1146
1262
|
// egress (history GET + SSE broadcast) appends a fresh `?sig=&exp=`.
|
|
1147
1263
|
// Renderers use it to mount `<img>` (kind=image) or `<a>` (kind=file).
|
|
1148
1264
|
// Refs whose attachment row is missing are dropped (defense — the
|
|
1149
1265
|
// dispatcher's [attachment removed] fallback covers that case).
|
|
1150
1266
|
const storedAttachments = attachments?.flatMap((a) => {
|
|
1151
|
-
const row = store.getAttachment(
|
|
1267
|
+
const row = store.getAttachment(taskId, a.attachmentId);
|
|
1152
1268
|
if (!row)
|
|
1153
1269
|
return [];
|
|
1154
1270
|
const fileName = basename(row.realpath);
|
|
@@ -1158,7 +1274,7 @@ export function createRequestHandler(deps) {
|
|
|
1158
1274
|
attachmentId: a.attachmentId,
|
|
1159
1275
|
displayName: a.displayName,
|
|
1160
1276
|
mimeType: a.mimeType,
|
|
1161
|
-
path: `/api/v1/
|
|
1277
|
+
path: `/api/v1/tasks/${taskId}/attachments/${fileName}`,
|
|
1162
1278
|
...(row.width != null && row.height != null
|
|
1163
1279
|
? { width: row.width, height: row.height }
|
|
1164
1280
|
: {}),
|
|
@@ -1169,80 +1285,74 @@ export function createRequestHandler(deps) {
|
|
|
1169
1285
|
// the foreground ACP prompt has ended. Its chunks remain buffered
|
|
1170
1286
|
// until a real protocol boundary arrives; seal them before this user
|
|
1171
1287
|
// row so they cannot merge into the next turn's assistant response.
|
|
1172
|
-
|
|
1288
|
+
tasks.flushBuffers(taskId);
|
|
1173
1289
|
const eventClientOpId = opId ?? randomUUID();
|
|
1174
|
-
store.saveEvent(
|
|
1290
|
+
store.saveEvent(taskId, "user_message", {
|
|
1175
1291
|
text: body.text,
|
|
1176
1292
|
clientOpId: eventClientOpId,
|
|
1177
1293
|
...(storedAttachments?.length
|
|
1178
1294
|
? { attachments: storedAttachments }
|
|
1179
1295
|
: {}),
|
|
1180
1296
|
}, { from_ref: "user" });
|
|
1181
|
-
store.
|
|
1182
|
-
store.touchRecentPath(
|
|
1297
|
+
store.updateTaskLastActive(taskId);
|
|
1298
|
+
store.touchRecentPath(task.cwd);
|
|
1183
1299
|
const userMsgEvent = {
|
|
1184
1300
|
type: "user_message",
|
|
1185
|
-
|
|
1301
|
+
taskId,
|
|
1186
1302
|
text: body.text,
|
|
1187
1303
|
clientOpId: eventClientOpId,
|
|
1188
1304
|
attachments: storedAttachments,
|
|
1189
1305
|
};
|
|
1190
1306
|
sseManager.broadcast(userMsgEvent);
|
|
1191
|
-
// Generate title (fire-and-forget)
|
|
1192
|
-
if (titleService &&
|
|
1193
|
-
sessions && // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- optional dep
|
|
1194
|
-
!sessions.sessionHasTitle.has(sessionId)) {
|
|
1195
|
-
titleService.generate(bridge, body.text, sessionId, (title) => {
|
|
1196
|
-
const titleEvent = {
|
|
1197
|
-
type: "session_title_updated",
|
|
1198
|
-
sessionId,
|
|
1199
|
-
title,
|
|
1200
|
-
};
|
|
1201
|
-
sseManager.broadcast(titleEvent);
|
|
1202
|
-
});
|
|
1203
|
-
}
|
|
1204
1307
|
// Fire prompt asynchronously (don't await — response is 202)
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
const promptId =
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1308
|
+
tasks.releasePromptSubmission(taskId, promptSubmissionId, false);
|
|
1309
|
+
tasks.activePrompts.add(taskId);
|
|
1310
|
+
tasks.syncBusy(taskId);
|
|
1311
|
+
const promptId = tasks.state.getState(taskId).runtime.busy?.promptId ?? undefined;
|
|
1312
|
+
const promptPromise = bridge.prompt(taskId, agentText, attachments, promptId);
|
|
1313
|
+
// Clear the one-shot handoff only after the ACP prompt promise settles
|
|
1314
|
+
// successfully. If the bridge rejects before it reaches ACP, retain
|
|
1315
|
+
// the summary so the next real prompt can retry the handoff.
|
|
1316
|
+
promptPromise
|
|
1317
|
+
.then(() => {
|
|
1318
|
+
if (pendingCompactSummary) {
|
|
1319
|
+
store.clearPendingCompactSummary(taskId, pendingCompactSummary);
|
|
1320
|
+
}
|
|
1321
|
+
})
|
|
1212
1322
|
.catch((err) => {
|
|
1213
|
-
plog.error("error", {
|
|
1323
|
+
plog.error("error", { taskId, error: err });
|
|
1214
1324
|
})
|
|
1215
1325
|
.finally(() => {
|
|
1216
1326
|
// A turn that outlived its own supersession must not clear the
|
|
1217
1327
|
// busy state of the turn that replaced it.
|
|
1218
|
-
if (!
|
|
1328
|
+
if (!tasks.isCurrentPrompt(taskId, promptId))
|
|
1219
1329
|
return;
|
|
1220
|
-
|
|
1221
|
-
|
|
1330
|
+
tasks.activePrompts.delete(taskId);
|
|
1331
|
+
tasks.syncBusy(taskId);
|
|
1222
1332
|
});
|
|
1223
1333
|
const acceptedBody = { status: "accepted" };
|
|
1224
|
-
saveClientOpResult(store, opId,
|
|
1334
|
+
saveClientOpResult(store, opId, taskId, HTTP_STATUS.ACCEPTED, acceptedBody);
|
|
1225
1335
|
json(res, HTTP_STATUS.ACCEPTED, acceptedBody);
|
|
1226
1336
|
return;
|
|
1227
1337
|
}
|
|
1228
|
-
// --- POST /api/v1/
|
|
1229
|
-
const bashMatch = url.match(/^\/api\/v1\/
|
|
1338
|
+
// --- POST /api/v1/tasks/:id/bash ---
|
|
1339
|
+
const bashMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/bash\/?$/);
|
|
1230
1340
|
if (bashMatch && req.method === "POST") {
|
|
1231
|
-
const
|
|
1232
|
-
const
|
|
1233
|
-
if (!
|
|
1234
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
1341
|
+
const taskId = decodeURIComponent(bashMatch[1]);
|
|
1342
|
+
const task = store.getTask(taskId);
|
|
1343
|
+
if (!task) {
|
|
1344
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1235
1345
|
return;
|
|
1236
1346
|
}
|
|
1237
|
-
if (!
|
|
1347
|
+
if (!tasks) {
|
|
1238
1348
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1239
|
-
error: "
|
|
1349
|
+
error: "Task manager not available",
|
|
1240
1350
|
});
|
|
1241
1351
|
return;
|
|
1242
1352
|
}
|
|
1243
|
-
if (
|
|
1353
|
+
if (tasks.runningBashProcs.has(taskId)) {
|
|
1244
1354
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
1245
|
-
error: "A bash command is already running in this
|
|
1355
|
+
error: "A bash command is already running in this task",
|
|
1246
1356
|
});
|
|
1247
1357
|
return;
|
|
1248
1358
|
}
|
|
@@ -1260,11 +1370,11 @@ export function createRequestHandler(deps) {
|
|
|
1260
1370
|
});
|
|
1261
1371
|
return;
|
|
1262
1372
|
}
|
|
1263
|
-
const cwd =
|
|
1264
|
-
store.saveEvent(
|
|
1373
|
+
const cwd = tasks.getTaskCwd(taskId);
|
|
1374
|
+
store.saveEvent(taskId, "bash_command", { command: body.command }, { from_ref: "user" });
|
|
1265
1375
|
const bashCmdEvent = {
|
|
1266
1376
|
type: "bash_command",
|
|
1267
|
-
|
|
1377
|
+
taskId,
|
|
1268
1378
|
command: body.command,
|
|
1269
1379
|
};
|
|
1270
1380
|
sseManager.broadcast(bashCmdEvent);
|
|
@@ -1280,8 +1390,8 @@ export function createRequestHandler(deps) {
|
|
|
1280
1390
|
env: { ...process.env, TERM: "dumb" },
|
|
1281
1391
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1282
1392
|
});
|
|
1283
|
-
|
|
1284
|
-
|
|
1393
|
+
tasks.runningBashProcs.set(taskId, child);
|
|
1394
|
+
tasks.syncBusy(taskId);
|
|
1285
1395
|
let output = "";
|
|
1286
1396
|
let outputTruncated = false;
|
|
1287
1397
|
const limit = deps.limits.bash_output;
|
|
@@ -1299,7 +1409,7 @@ export function createRequestHandler(deps) {
|
|
|
1299
1409
|
}
|
|
1300
1410
|
const bashOutEvent = {
|
|
1301
1411
|
type: "bash_output",
|
|
1302
|
-
|
|
1412
|
+
taskId,
|
|
1303
1413
|
text,
|
|
1304
1414
|
stream,
|
|
1305
1415
|
};
|
|
@@ -1308,26 +1418,26 @@ export function createRequestHandler(deps) {
|
|
|
1308
1418
|
child.stdout.on("data", onData("stdout"));
|
|
1309
1419
|
child.stderr.on("data", onData("stderr"));
|
|
1310
1420
|
child.on("close", (code, signal) => {
|
|
1311
|
-
|
|
1312
|
-
|
|
1421
|
+
tasks.runningBashProcs.delete(taskId);
|
|
1422
|
+
tasks.syncBusy(taskId);
|
|
1313
1423
|
const stored = outputTruncated ? "[truncated]\n" + output : output;
|
|
1314
|
-
store.saveEvent(
|
|
1424
|
+
store.saveEvent(taskId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
|
|
1315
1425
|
const bashDoneEvent = {
|
|
1316
1426
|
type: "bash_done",
|
|
1317
|
-
|
|
1427
|
+
taskId,
|
|
1318
1428
|
code,
|
|
1319
1429
|
signal,
|
|
1320
1430
|
};
|
|
1321
1431
|
sseManager.broadcast(bashDoneEvent);
|
|
1322
1432
|
});
|
|
1323
1433
|
child.on("error", (err) => {
|
|
1324
|
-
|
|
1325
|
-
|
|
1434
|
+
tasks.runningBashProcs.delete(taskId);
|
|
1435
|
+
tasks.syncBusy(taskId);
|
|
1326
1436
|
const errMsg = errorMessage(err);
|
|
1327
|
-
store.saveEvent(
|
|
1437
|
+
store.saveEvent(taskId, "bash_result", { output: errMsg, code: -1, signal: null }, { from_ref: "system" });
|
|
1328
1438
|
const bashErrEvent = {
|
|
1329
1439
|
type: "bash_done",
|
|
1330
|
-
|
|
1440
|
+
taskId,
|
|
1331
1441
|
code: -1,
|
|
1332
1442
|
signal: null,
|
|
1333
1443
|
error: errMsg,
|
|
@@ -1337,31 +1447,31 @@ export function createRequestHandler(deps) {
|
|
|
1337
1447
|
json(res, HTTP_STATUS.ACCEPTED, { status: "accepted" });
|
|
1338
1448
|
return;
|
|
1339
1449
|
}
|
|
1340
|
-
// --- POST /api/v1/
|
|
1341
|
-
const bashCancelMatch = url.match(/^\/api\/v1\/
|
|
1450
|
+
// --- POST /api/v1/tasks/:id/bash/cancel ---
|
|
1451
|
+
const bashCancelMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/bash\/cancel\/?$/);
|
|
1342
1452
|
if (bashCancelMatch && req.method === "POST") {
|
|
1343
|
-
const
|
|
1344
|
-
const
|
|
1345
|
-
if (!
|
|
1346
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
1453
|
+
const taskId = decodeURIComponent(bashCancelMatch[1]);
|
|
1454
|
+
const task = store.getTask(taskId);
|
|
1455
|
+
if (!task) {
|
|
1456
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1347
1457
|
return;
|
|
1348
1458
|
}
|
|
1349
|
-
interruptBashProc(
|
|
1459
|
+
interruptBashProc(tasks?.runningBashProcs.get(taskId));
|
|
1350
1460
|
json(res, HTTP_STATUS.OK, { ok: true });
|
|
1351
1461
|
return;
|
|
1352
1462
|
}
|
|
1353
|
-
// --- PUT /api/v1/
|
|
1354
|
-
// --- PUT /api/v1/
|
|
1355
|
-
const legacyConfigPutMatch = url.match(/^\/api\/v1\/
|
|
1356
|
-
const genericConfigPutMatch = url.match(/^\/api\/v1\/
|
|
1463
|
+
// --- PUT /api/v1/tasks/:id/{model,mode,reasoning-effort} ---
|
|
1464
|
+
// --- PUT /api/v1/tasks/:id/config/:configId ---
|
|
1465
|
+
const legacyConfigPutMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/(model|mode|reasoning-effort)\/?$/);
|
|
1466
|
+
const genericConfigPutMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/config\/([^/]+)\/?$/);
|
|
1357
1467
|
const configPutMatch = legacyConfigPutMatch ?? genericConfigPutMatch;
|
|
1358
1468
|
if (configPutMatch && req.method === "PUT") {
|
|
1359
|
-
const
|
|
1469
|
+
const taskId = decodeURIComponent(configPutMatch[1]);
|
|
1360
1470
|
const configPath = decodeURIComponent(configPutMatch[2]);
|
|
1361
1471
|
const configId = configPath.replace(/-/g, "_");
|
|
1362
|
-
const
|
|
1363
|
-
if (!
|
|
1364
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
1472
|
+
const task = store.getTask(taskId);
|
|
1473
|
+
if (!task) {
|
|
1474
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1365
1475
|
return;
|
|
1366
1476
|
}
|
|
1367
1477
|
const bridge = getBridge?.();
|
|
@@ -1386,20 +1496,20 @@ export function createRequestHandler(deps) {
|
|
|
1386
1496
|
return;
|
|
1387
1497
|
}
|
|
1388
1498
|
try {
|
|
1389
|
-
const configOptions = await bridge.setConfigOption(
|
|
1499
|
+
const configOptions = await bridge.setConfigOption(taskId, configId, body.value);
|
|
1390
1500
|
for (const opt of configOptions) {
|
|
1391
1501
|
if (typeof opt.currentValue === "string") {
|
|
1392
|
-
store.
|
|
1502
|
+
store.updateTaskConfig(taskId, opt.id, opt.currentValue);
|
|
1393
1503
|
}
|
|
1394
1504
|
}
|
|
1395
1505
|
sseManager.broadcast({
|
|
1396
1506
|
type: "config_option_update",
|
|
1397
|
-
|
|
1507
|
+
taskId,
|
|
1398
1508
|
configOptions,
|
|
1399
1509
|
});
|
|
1400
1510
|
sseManager.broadcast({
|
|
1401
1511
|
type: "config_set",
|
|
1402
|
-
|
|
1512
|
+
taskId,
|
|
1403
1513
|
configId,
|
|
1404
1514
|
value: body.value,
|
|
1405
1515
|
});
|
|
@@ -1412,13 +1522,13 @@ export function createRequestHandler(deps) {
|
|
|
1412
1522
|
}
|
|
1413
1523
|
return;
|
|
1414
1524
|
}
|
|
1415
|
-
// --- PUT /api/v1/
|
|
1416
|
-
const titlePutMatch = url.match(/^\/api\/v1\/
|
|
1525
|
+
// --- PUT /api/v1/tasks/:id/title ---
|
|
1526
|
+
const titlePutMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/title\/?$/);
|
|
1417
1527
|
if (titlePutMatch && req.method === "PUT") {
|
|
1418
|
-
const
|
|
1419
|
-
const
|
|
1420
|
-
if (!
|
|
1421
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
1528
|
+
const taskId = decodeURIComponent(titlePutMatch[1]);
|
|
1529
|
+
const task = store.getTask(taskId);
|
|
1530
|
+
if (!task) {
|
|
1531
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1422
1532
|
return;
|
|
1423
1533
|
}
|
|
1424
1534
|
let body;
|
|
@@ -1435,24 +1545,40 @@ export function createRequestHandler(deps) {
|
|
|
1435
1545
|
});
|
|
1436
1546
|
return;
|
|
1437
1547
|
}
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
const
|
|
1442
|
-
if (
|
|
1443
|
-
|
|
1548
|
+
// The live sibling-title unique index turns an unguarded rename into
|
|
1549
|
+
// a thrown constraint error; validate the same grammar as creation
|
|
1550
|
+
// and reject collisions before the store sees them.
|
|
1551
|
+
const title = validateCollaborationTitle(body.value);
|
|
1552
|
+
if (title === null) {
|
|
1553
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
1554
|
+
error: "Title must be non-empty and must not be '.', '..', or contain '/'",
|
|
1555
|
+
});
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
if (task.parent_id !== null &&
|
|
1559
|
+
store
|
|
1560
|
+
.listTasks()
|
|
1561
|
+
.some((live) => live.parent_id === task.parent_id &&
|
|
1562
|
+
live.id !== task.id &&
|
|
1563
|
+
live.title === title)) {
|
|
1564
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
1565
|
+
error: "A sibling task already has that title",
|
|
1566
|
+
});
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
store.updateTaskTitle(taskId, title);
|
|
1444
1570
|
const titleEvent = {
|
|
1445
|
-
type: "
|
|
1446
|
-
|
|
1447
|
-
title
|
|
1571
|
+
type: "task_title_updated",
|
|
1572
|
+
taskId,
|
|
1573
|
+
title,
|
|
1448
1574
|
};
|
|
1449
1575
|
sseManager.broadcast(titleEvent);
|
|
1450
|
-
json(res, HTTP_STATUS.OK, { title
|
|
1576
|
+
json(res, HTTP_STATUS.OK, { title });
|
|
1451
1577
|
return;
|
|
1452
1578
|
}
|
|
1453
|
-
// POST /api/v1/
|
|
1454
|
-
// agent's latest
|
|
1455
|
-
if (url === "/api/v1/
|
|
1579
|
+
// POST /api/v1/tasks/bootstrap — atomically return the current
|
|
1580
|
+
// agent's latest task, creating one only when none exists.
|
|
1581
|
+
if (url === "/api/v1/tasks/bootstrap" && req.method === "POST") {
|
|
1456
1582
|
const bridge = getBridge?.();
|
|
1457
1583
|
if (!bridge) {
|
|
1458
1584
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
@@ -1460,15 +1586,15 @@ export function createRequestHandler(deps) {
|
|
|
1460
1586
|
});
|
|
1461
1587
|
return;
|
|
1462
1588
|
}
|
|
1463
|
-
if (!
|
|
1589
|
+
if (!tasks) {
|
|
1464
1590
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1465
|
-
error: "
|
|
1591
|
+
error: "Task manager not available",
|
|
1466
1592
|
});
|
|
1467
1593
|
return;
|
|
1468
1594
|
}
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1471
|
-
const existing = store.
|
|
1595
|
+
const taskManager = tasks;
|
|
1596
|
+
bootstrapTaskPromise ??= (async () => {
|
|
1597
|
+
const existing = store.listTasks().at(0);
|
|
1472
1598
|
if (existing) {
|
|
1473
1599
|
return {
|
|
1474
1600
|
id: existing.id,
|
|
@@ -1477,25 +1603,25 @@ export function createRequestHandler(deps) {
|
|
|
1477
1603
|
title: existing.title,
|
|
1478
1604
|
source: existing.source,
|
|
1479
1605
|
configOptions: [],
|
|
1480
|
-
agentCommands:
|
|
1606
|
+
agentCommands: taskManager.getAgentCommands(existing.id),
|
|
1481
1607
|
created: false,
|
|
1482
1608
|
};
|
|
1483
1609
|
}
|
|
1484
|
-
const {
|
|
1485
|
-
const
|
|
1610
|
+
const { taskId, configOptions } = await taskManager.createTask(bridge);
|
|
1611
|
+
const task = store.getTask(taskId);
|
|
1486
1612
|
const result = {
|
|
1487
|
-
id:
|
|
1488
|
-
cwd:
|
|
1489
|
-
cwdDisplay: abbreviateHomePath(
|
|
1490
|
-
title:
|
|
1491
|
-
source:
|
|
1613
|
+
id: taskId,
|
|
1614
|
+
cwd: task?.cwd ?? deps.dataDir,
|
|
1615
|
+
cwdDisplay: abbreviateHomePath(task?.cwd ?? deps.dataDir),
|
|
1616
|
+
title: task?.title ?? null,
|
|
1617
|
+
source: task?.source ?? "auto",
|
|
1492
1618
|
configOptions,
|
|
1493
|
-
agentCommands:
|
|
1619
|
+
agentCommands: taskManager.getAgentCommands(taskId),
|
|
1494
1620
|
created: true,
|
|
1495
1621
|
};
|
|
1496
1622
|
sseManager.broadcast({
|
|
1497
|
-
type: "
|
|
1498
|
-
|
|
1623
|
+
type: "task_created",
|
|
1624
|
+
taskId,
|
|
1499
1625
|
cwd: result.cwd,
|
|
1500
1626
|
cwdDisplay: result.cwdDisplay,
|
|
1501
1627
|
title: result.title,
|
|
@@ -1504,10 +1630,10 @@ export function createRequestHandler(deps) {
|
|
|
1504
1630
|
});
|
|
1505
1631
|
return result;
|
|
1506
1632
|
})().finally(() => {
|
|
1507
|
-
|
|
1633
|
+
bootstrapTaskPromise = null;
|
|
1508
1634
|
});
|
|
1509
1635
|
try {
|
|
1510
|
-
const result = await
|
|
1636
|
+
const result = await bootstrapTaskPromise;
|
|
1511
1637
|
json(res, HTTP_STATUS.OK, {
|
|
1512
1638
|
...result,
|
|
1513
1639
|
clientOpId: getClientOpId(req) ?? undefined,
|
|
@@ -1520,17 +1646,230 @@ export function createRequestHandler(deps) {
|
|
|
1520
1646
|
}
|
|
1521
1647
|
return;
|
|
1522
1648
|
}
|
|
1523
|
-
//
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1649
|
+
// POST /api/v1/tasks/:id/compact — generate a visible assistant
|
|
1650
|
+
// handoff, rotate the ACP execution, and defer injecting the handoff
|
|
1651
|
+
// until the next real user prompt.
|
|
1652
|
+
const compactTaskMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/compact\/?$/);
|
|
1653
|
+
if (compactTaskMatch && req.method === "POST") {
|
|
1654
|
+
const taskId = decodeURIComponent(compactTaskMatch[1]);
|
|
1655
|
+
const task = store.getTask(taskId);
|
|
1656
|
+
if (!task) {
|
|
1657
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
let compactGuidance;
|
|
1661
|
+
try {
|
|
1662
|
+
const body = JSON.parse(await readBody(req));
|
|
1663
|
+
if (body.prompt !== undefined && typeof body.prompt !== "string") {
|
|
1664
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
1665
|
+
error: "Compact prompt must be a string",
|
|
1666
|
+
});
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
compactGuidance = body.prompt;
|
|
1670
|
+
}
|
|
1671
|
+
catch {
|
|
1672
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const bridge = getBridge?.();
|
|
1676
|
+
if (!bridge || !tasks || !bridge.promptForText) {
|
|
1677
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1678
|
+
error: "Agent not ready yet",
|
|
1679
|
+
});
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (tasks.getBusyKind(taskId) !== null) {
|
|
1683
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
1684
|
+
error: "Cancel active work before compacting the task",
|
|
1685
|
+
});
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
if (store.getPendingCompactSummary(taskId)) {
|
|
1689
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
1690
|
+
error: "Task already has a pending compact summary",
|
|
1691
|
+
});
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
const submissionId = tasks.reservePromptSubmission(taskId);
|
|
1695
|
+
if (submissionId === null) {
|
|
1696
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
1697
|
+
error: "Task is busy",
|
|
1698
|
+
busyKind: tasks.getBusyKind(taskId),
|
|
1699
|
+
});
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
try {
|
|
1703
|
+
await tasks.ensureResumed(bridge, taskId);
|
|
1704
|
+
}
|
|
1705
|
+
catch (err) {
|
|
1706
|
+
tasks.releasePromptSubmission(taskId, submissionId);
|
|
1707
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
1708
|
+
error: `Failed to resume task: ${errorMessage(err)}`,
|
|
1709
|
+
});
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const agentSessionId = store.getAgentSessionId(taskId);
|
|
1713
|
+
if (!agentSessionId) {
|
|
1714
|
+
tasks.releasePromptSubmission(taskId, submissionId);
|
|
1715
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1716
|
+
error: "Task is not available for the current agent",
|
|
1717
|
+
});
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const promptForText = bridge.promptForText;
|
|
1721
|
+
tasks.compactingTasks.add(taskId);
|
|
1722
|
+
tasks.releasePromptSubmission(taskId, submissionId, false);
|
|
1723
|
+
tasks.activePrompts.add(taskId);
|
|
1724
|
+
tasks.syncBusy(taskId);
|
|
1725
|
+
const previousAgentSessionId = agentSessionId;
|
|
1726
|
+
void (async () => {
|
|
1727
|
+
let summary = "";
|
|
1728
|
+
try {
|
|
1729
|
+
summary = (await promptForText.call(bridge, agentSessionId, buildCompactSummaryPrompt(compactGuidance))).trim();
|
|
1730
|
+
if (!summary)
|
|
1731
|
+
throw new Error("Agent returned an empty summary");
|
|
1732
|
+
const summaryEvent = store.saveCompactSummary(taskId, summary);
|
|
1733
|
+
sseManager.broadcast({
|
|
1734
|
+
type: "assistant_message",
|
|
1735
|
+
taskId,
|
|
1736
|
+
text: summary,
|
|
1737
|
+
seq: summaryEvent.seq,
|
|
1738
|
+
});
|
|
1739
|
+
const clearResult = await tasks.clearTask(bridge, taskId, undefined, {
|
|
1740
|
+
preservePendingCompactSummary: true,
|
|
1741
|
+
preserveRuntimeState: true,
|
|
1742
|
+
});
|
|
1743
|
+
tasks.state.patch(taskId, {
|
|
1744
|
+
runtime: {
|
|
1745
|
+
busy: null,
|
|
1746
|
+
streaming: { assistant: false, thinking: false },
|
|
1747
|
+
pendingPermissions: [],
|
|
1748
|
+
plan: null,
|
|
1749
|
+
contextUsage: null,
|
|
1750
|
+
},
|
|
1751
|
+
});
|
|
1752
|
+
const fresh = store.getTask(taskId);
|
|
1753
|
+
if (!fresh)
|
|
1754
|
+
throw new Error("Task disappeared during compaction");
|
|
1755
|
+
sseManager.broadcast({
|
|
1756
|
+
type: "task_created",
|
|
1757
|
+
taskId,
|
|
1758
|
+
cwd: fresh.cwd,
|
|
1759
|
+
cwdDisplay: abbreviateHomePath(fresh.cwd),
|
|
1760
|
+
title: fresh.title,
|
|
1761
|
+
configOptions: clearResult.configOptions,
|
|
1762
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
catch (err) {
|
|
1766
|
+
// If rotation did not move the binding, the old ACP execution is
|
|
1767
|
+
// still current and must not receive this handoff on its next turn.
|
|
1768
|
+
if (summary &&
|
|
1769
|
+
store.getAgentSessionId(taskId) === previousAgentSessionId) {
|
|
1770
|
+
store.clearPendingCompactSummary(taskId, summary);
|
|
1771
|
+
}
|
|
1772
|
+
const message = `Compact failed: ${errorMessage(err)}`;
|
|
1773
|
+
// The task may have been deleted mid-compaction (cascade); the
|
|
1774
|
+
// FK rejects writes to a removed row, so make the error event
|
|
1775
|
+
// optional and keep the SSE broadcast authoritative.
|
|
1776
|
+
try {
|
|
1777
|
+
store.saveEvent(taskId, "error", { message }, {
|
|
1778
|
+
from_ref: "system",
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
catch {
|
|
1782
|
+
// Task is gone; nothing durable to write.
|
|
1783
|
+
}
|
|
1784
|
+
sseManager.broadcast({
|
|
1785
|
+
type: "error",
|
|
1786
|
+
taskId,
|
|
1787
|
+
message,
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
finally {
|
|
1791
|
+
tasks.activePrompts.delete(taskId);
|
|
1792
|
+
tasks.compactingTasks.delete(taskId);
|
|
1793
|
+
tasks.syncBusy(taskId);
|
|
1794
|
+
}
|
|
1795
|
+
})();
|
|
1796
|
+
json(res, HTTP_STATUS.ACCEPTED, { status: "accepted" });
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
// POST /api/v1/tasks/:id/clear — rotate the ACP execution while
|
|
1800
|
+
// preserving the stable WebAgent task identity and its records.
|
|
1801
|
+
const clearTaskMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/clear\/?$/);
|
|
1802
|
+
if (clearTaskMatch && req.method === "POST") {
|
|
1803
|
+
const taskId = decodeURIComponent(clearTaskMatch[1]);
|
|
1804
|
+
const task = store.getTask(taskId);
|
|
1805
|
+
if (!task) {
|
|
1806
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
const bridge = getBridge?.();
|
|
1810
|
+
if (!bridge || !tasks) {
|
|
1811
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1812
|
+
error: "Agent not ready yet",
|
|
1813
|
+
});
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
if (tasks.getBusyKind(taskId) !== null) {
|
|
1817
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
1818
|
+
error: "Cancel active work before clearing the task",
|
|
1819
|
+
});
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
let body = {};
|
|
1823
|
+
try {
|
|
1824
|
+
const raw = await readBody(req);
|
|
1825
|
+
if (raw)
|
|
1826
|
+
body = JSON.parse(raw);
|
|
1827
|
+
}
|
|
1828
|
+
catch {
|
|
1829
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid JSON" });
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
try {
|
|
1833
|
+
const result = await tasks.clearTask(bridge, taskId, body.cwd);
|
|
1834
|
+
const fresh = store.getTask(taskId);
|
|
1835
|
+
const event = {
|
|
1836
|
+
type: "task_created",
|
|
1837
|
+
taskId,
|
|
1838
|
+
cwd: fresh.cwd,
|
|
1839
|
+
cwdDisplay: abbreviateHomePath(fresh.cwd),
|
|
1840
|
+
title: fresh.title,
|
|
1841
|
+
configOptions: result.configOptions,
|
|
1842
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
1843
|
+
};
|
|
1844
|
+
sseManager.broadcast(event);
|
|
1845
|
+
json(res, HTTP_STATUS.OK, {
|
|
1846
|
+
id: taskId,
|
|
1847
|
+
cwd: fresh.cwd,
|
|
1848
|
+
cwdDisplay: abbreviateHomePath(fresh.cwd),
|
|
1849
|
+
title: fresh.title,
|
|
1850
|
+
source: fresh.source,
|
|
1851
|
+
configOptions: result.configOptions,
|
|
1852
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
catch (err) {
|
|
1856
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
1857
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
// --- Task CRUD: /api/v1/tasks/:id ---
|
|
1863
|
+
const taskIdMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/?(\?.*)?$/);
|
|
1864
|
+
if (taskIdMatch) {
|
|
1865
|
+
const taskId = decodeURIComponent(taskIdMatch[1]);
|
|
1866
|
+
// POST /api/v1/tasks (create) — handled below since :id would match "tasks" literally
|
|
1867
|
+
// This match is for /api/v1/tasks/:id only (not /api/tasks)
|
|
1868
|
+
// GET /api/v1/tasks/:id
|
|
1530
1869
|
if (req.method === "GET") {
|
|
1531
|
-
const
|
|
1532
|
-
if (!
|
|
1533
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
1870
|
+
const task = store.getTask(taskId);
|
|
1871
|
+
if (!task) {
|
|
1872
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1534
1873
|
return;
|
|
1535
1874
|
}
|
|
1536
1875
|
// Start resume in background (non-blocking) so the client gets metadata fast.
|
|
@@ -1538,21 +1877,21 @@ export function createRequestHandler(deps) {
|
|
|
1538
1877
|
// configOptions returns inline. This eliminates the race where the
|
|
1539
1878
|
// post-resume `config_option_update` broadcast can arrive before the
|
|
1540
1879
|
// client's SSE is fully wired up — a flake observed on slow CI runners.
|
|
1541
|
-
const wasLive =
|
|
1880
|
+
const wasLive = tasks?.liveTasks.has(taskId) ?? true;
|
|
1542
1881
|
let resumePromise = null;
|
|
1543
|
-
if (
|
|
1882
|
+
if (tasks && getBridge && !wasLive) {
|
|
1544
1883
|
const bridge = getBridge();
|
|
1545
1884
|
if (bridge) {
|
|
1546
|
-
resumePromise =
|
|
1885
|
+
resumePromise = tasks.ensureResumed(bridge, taskId);
|
|
1547
1886
|
// Broadcast config_option_update too, so any OTHER client viewing
|
|
1548
|
-
// the same
|
|
1887
|
+
// the same task (whose own GET may have raced and returned cold)
|
|
1549
1888
|
// also picks up the warm values.
|
|
1550
1889
|
resumePromise
|
|
1551
1890
|
.then(() => {
|
|
1552
|
-
const cur = store.
|
|
1553
|
-
if (!cur || !
|
|
1891
|
+
const cur = store.getTask(taskId);
|
|
1892
|
+
if (!cur || !tasks.cachedConfigOptions.length)
|
|
1554
1893
|
return;
|
|
1555
|
-
const opts =
|
|
1894
|
+
const opts = tasks.cachedConfigOptions.map((opt) => {
|
|
1556
1895
|
const stored = {
|
|
1557
1896
|
model: cur.model,
|
|
1558
1897
|
mode: cur.mode,
|
|
@@ -1565,14 +1904,14 @@ export function createRequestHandler(deps) {
|
|
|
1565
1904
|
});
|
|
1566
1905
|
sseManager.broadcast({
|
|
1567
1906
|
type: "config_option_update",
|
|
1568
|
-
|
|
1907
|
+
taskId,
|
|
1569
1908
|
configOptions: opts,
|
|
1570
1909
|
});
|
|
1571
1910
|
})
|
|
1572
1911
|
.catch(() => { });
|
|
1573
1912
|
resumePromise.catch((err) => {
|
|
1574
1913
|
slog.error("background resume failed", {
|
|
1575
|
-
|
|
1914
|
+
taskId: taskId.slice(0, 8) + "…",
|
|
1576
1915
|
error: err,
|
|
1577
1916
|
});
|
|
1578
1917
|
});
|
|
@@ -1582,7 +1921,7 @@ export function createRequestHandler(deps) {
|
|
|
1582
1921
|
// GET response includes warm configOptions. Bounded to 8s — past that
|
|
1583
1922
|
// we fall back to returning empty configOptions and rely on the
|
|
1584
1923
|
// broadcast above. The frontend retries (re-fetches) on broadcast.
|
|
1585
|
-
if (resumePromise && !
|
|
1924
|
+
if (resumePromise && !tasks?.cachedConfigOptions.length) {
|
|
1586
1925
|
let timer = null;
|
|
1587
1926
|
const timeoutPromise = new Promise((resolve) => {
|
|
1588
1927
|
timer = setTimeout(resolve, 8000);
|
|
@@ -1595,16 +1934,16 @@ export function createRequestHandler(deps) {
|
|
|
1595
1934
|
timeoutPromise,
|
|
1596
1935
|
]).catch(() => { });
|
|
1597
1936
|
}
|
|
1598
|
-
// Re-read
|
|
1599
|
-
const
|
|
1600
|
-
const configOptions =
|
|
1937
|
+
// Re-read task in case resume mutated stored config
|
|
1938
|
+
const freshTask = store.getTask(taskId) ?? task;
|
|
1939
|
+
const configOptions = tasks
|
|
1601
1940
|
? (() => {
|
|
1602
1941
|
// Build configOptions from cached + stored overrides
|
|
1603
|
-
const opts =
|
|
1942
|
+
const opts = tasks.cachedConfigOptions.map((opt) => {
|
|
1604
1943
|
const stored = {
|
|
1605
|
-
model:
|
|
1606
|
-
mode:
|
|
1607
|
-
reasoning_effort:
|
|
1944
|
+
model: freshTask.model,
|
|
1945
|
+
mode: freshTask.mode,
|
|
1946
|
+
reasoning_effort: freshTask.reasoning_effort,
|
|
1608
1947
|
};
|
|
1609
1948
|
const override = stored[opt.id];
|
|
1610
1949
|
return override && "options" in opt
|
|
@@ -1615,44 +1954,113 @@ export function createRequestHandler(deps) {
|
|
|
1615
1954
|
})()
|
|
1616
1955
|
: [];
|
|
1617
1956
|
json(res, HTTP_STATUS.OK, {
|
|
1618
|
-
id:
|
|
1619
|
-
cwd:
|
|
1620
|
-
cwdDisplay: abbreviateHomePath(
|
|
1621
|
-
title:
|
|
1622
|
-
source:
|
|
1623
|
-
model:
|
|
1624
|
-
mode:
|
|
1957
|
+
id: freshTask.id,
|
|
1958
|
+
cwd: freshTask.cwd,
|
|
1959
|
+
cwdDisplay: abbreviateHomePath(freshTask.cwd),
|
|
1960
|
+
title: freshTask.title,
|
|
1961
|
+
source: freshTask.source,
|
|
1962
|
+
model: freshTask.model,
|
|
1963
|
+
mode: freshTask.mode,
|
|
1964
|
+
parentId: freshTask.parent_id,
|
|
1625
1965
|
configOptions,
|
|
1626
1966
|
}, req);
|
|
1627
1967
|
return;
|
|
1628
1968
|
}
|
|
1629
|
-
// DELETE /api/v1/
|
|
1969
|
+
// DELETE /api/v1/tasks/:id
|
|
1630
1970
|
if (req.method === "DELETE") {
|
|
1631
|
-
const
|
|
1632
|
-
|
|
1633
|
-
|
|
1971
|
+
const clientOpId = getClientOpId(req);
|
|
1972
|
+
const task = store.getTask(taskId);
|
|
1973
|
+
if (!task) {
|
|
1974
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1634
1975
|
return;
|
|
1635
1976
|
}
|
|
1636
|
-
if (
|
|
1977
|
+
if (tasks && tasks.getBusyKind(taskId) !== null) {
|
|
1978
|
+
const busyPath = store.getTaskPath(taskId) ?? taskId;
|
|
1637
1979
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
1638
|
-
error:
|
|
1980
|
+
error: `Cancel active work in ${busyPath} before deleting this task`,
|
|
1639
1981
|
});
|
|
1640
1982
|
return;
|
|
1641
1983
|
}
|
|
1642
|
-
|
|
1643
|
-
|
|
1984
|
+
// The cascade removes descendants too; gate on their busy state as
|
|
1985
|
+
// well so deleting an idle parent cannot silently abort an
|
|
1986
|
+
// in-flight prompt or bash run in a child. Name that child by its
|
|
1987
|
+
// path: the user clicked a different task and has to know where the
|
|
1988
|
+
// active work actually is.
|
|
1989
|
+
if (tasks) {
|
|
1990
|
+
const busyDescendant = store
|
|
1991
|
+
.getDescendantTaskIds(taskId)
|
|
1992
|
+
.find((descendantId) => tasks.getBusyKind(descendantId) !== null);
|
|
1993
|
+
if (busyDescendant) {
|
|
1994
|
+
const busyPath = store.getTaskPath(busyDescendant) ?? busyDescendant;
|
|
1995
|
+
json(res, HTTP_STATUS.CONFLICT, {
|
|
1996
|
+
error: `Cancel active work in ${busyPath} before deleting this task`,
|
|
1997
|
+
});
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
1644
2000
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
2001
|
+
try {
|
|
2002
|
+
if (taskId === ROOT_TASK_ID) {
|
|
2003
|
+
const bridge = getBridge?.();
|
|
2004
|
+
if (!tasks || !bridge) {
|
|
2005
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
2006
|
+
error: "Agent not ready yet",
|
|
2007
|
+
});
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
const result = await tasks.resetRootTask(bridge);
|
|
2011
|
+
for (const entry of result.affected) {
|
|
2012
|
+
sseManager.broadcast({
|
|
2013
|
+
type: "task_deleted",
|
|
2014
|
+
taskId: entry.id,
|
|
2015
|
+
parentId: ROOT_TASK_ID,
|
|
2016
|
+
...(clientOpId ? { clientOpId } : {}),
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
sseManager.broadcast({
|
|
2020
|
+
type: "task_reset",
|
|
2021
|
+
taskId: ROOT_TASK_ID,
|
|
2022
|
+
...(clientOpId ? { clientOpId } : {}),
|
|
2023
|
+
});
|
|
2024
|
+
json(res, HTTP_STATUS.OK, {
|
|
2025
|
+
taskId: ROOT_TASK_ID,
|
|
2026
|
+
reset: true,
|
|
2027
|
+
...(clientOpId ? { clientOpId } : {}),
|
|
2028
|
+
});
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
const affectedStore = tasks
|
|
2032
|
+
? await tasks.deleteTask(getBridge?.() ?? undefined, taskId)
|
|
2033
|
+
: store.deleteTask(taskId);
|
|
2034
|
+
for (const entry of affectedStore.affected) {
|
|
2035
|
+
sseManager.broadcast({
|
|
2036
|
+
type: "task_deleted",
|
|
2037
|
+
taskId: entry.id,
|
|
2038
|
+
...(entry.id === taskId ? { parentId: task.parent_id } : {}),
|
|
2039
|
+
...(entry.id === taskId && clientOpId ? { clientOpId } : {}),
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
json(res, HTTP_STATUS.OK, {
|
|
2043
|
+
taskId,
|
|
2044
|
+
parentId: task.parent_id,
|
|
2045
|
+
reset: false,
|
|
2046
|
+
...(clientOpId ? { clientOpId } : {}),
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
catch (err) {
|
|
2050
|
+
const status = err instanceof TaskNotFoundError
|
|
2051
|
+
? HTTP_STATUS.NOT_FOUND
|
|
2052
|
+
: err instanceof TaskBusyError
|
|
2053
|
+
? HTTP_STATUS.CONFLICT
|
|
2054
|
+
: HTTP_STATUS.BAD_REQUEST;
|
|
2055
|
+
json(res, status, {
|
|
2056
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2057
|
+
});
|
|
1647
2058
|
}
|
|
1648
|
-
sseManager.broadcast({ type: "session_deleted", sessionId });
|
|
1649
|
-
res.writeHead(HTTP_STATUS.NO_CONTENT);
|
|
1650
|
-
res.end();
|
|
1651
2059
|
return;
|
|
1652
2060
|
}
|
|
1653
2061
|
}
|
|
1654
|
-
// POST /api/v1/
|
|
1655
|
-
if (url === "/api/v1/
|
|
2062
|
+
// POST /api/v1/tasks (create new task)
|
|
2063
|
+
if (url === "/api/v1/tasks" && req.method === "POST") {
|
|
1656
2064
|
const clientOpId = getClientOpId(req);
|
|
1657
2065
|
const bridge = getBridge?.();
|
|
1658
2066
|
if (!bridge) {
|
|
@@ -1661,9 +2069,9 @@ export function createRequestHandler(deps) {
|
|
|
1661
2069
|
});
|
|
1662
2070
|
return;
|
|
1663
2071
|
}
|
|
1664
|
-
if (!
|
|
2072
|
+
if (!tasks) {
|
|
1665
2073
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1666
|
-
error: "
|
|
2074
|
+
error: "Task manager not available",
|
|
1667
2075
|
});
|
|
1668
2076
|
return;
|
|
1669
2077
|
}
|
|
@@ -1676,59 +2084,124 @@ export function createRequestHandler(deps) {
|
|
|
1676
2084
|
return;
|
|
1677
2085
|
}
|
|
1678
2086
|
const source = body.source ?? "auto";
|
|
2087
|
+
const hasCollaborationFields = body.title !== undefined || body.brief !== undefined;
|
|
2088
|
+
const title = hasCollaborationFields
|
|
2089
|
+
? validateCollaborationTitle(body.title)
|
|
2090
|
+
: undefined;
|
|
2091
|
+
const hasBrief = typeof body.brief === "string" && body.brief.trim().length > 0;
|
|
2092
|
+
if (hasCollaborationFields &&
|
|
2093
|
+
(title === null ||
|
|
2094
|
+
!body.parentId ||
|
|
2095
|
+
(body.brief !== undefined && !hasBrief))) {
|
|
2096
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
2097
|
+
error: "a collaboration child needs a title and parent; the brief is optional",
|
|
2098
|
+
});
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
// The parent must exist and be live (not tombstoned); the FK would
|
|
2102
|
+
// reject a dangling reference with a raw database error otherwise.
|
|
2103
|
+
// A live parent row is enough — it need not have an ACP binding yet.
|
|
2104
|
+
if (body.parentId) {
|
|
2105
|
+
const parent = store.getTaskIncludingDeleted(body.parentId);
|
|
2106
|
+
if (parent?.deleted_at !== null) {
|
|
2107
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
2108
|
+
error: "Parent task not found",
|
|
2109
|
+
});
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
const initialMessageId = title && hasBrief ? randomUUID() : undefined;
|
|
2114
|
+
const initialDeliveryId = title && hasBrief ? randomUUID() : undefined;
|
|
1679
2115
|
try {
|
|
1680
|
-
const {
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
2116
|
+
const { taskId, configOptions } = await tasks.createTask(bridge, body.cwd, body.inheritFromTaskId, source, {
|
|
2117
|
+
parentId: body.parentId,
|
|
2118
|
+
title: title ?? undefined,
|
|
2119
|
+
brief: hasBrief ? body.brief : undefined,
|
|
2120
|
+
workflowStatus: title
|
|
2121
|
+
? hasBrief
|
|
2122
|
+
? "running"
|
|
2123
|
+
: "idle"
|
|
2124
|
+
: undefined,
|
|
2125
|
+
initialMessage: title &&
|
|
2126
|
+
hasBrief &&
|
|
2127
|
+
initialMessageId &&
|
|
2128
|
+
initialDeliveryId &&
|
|
2129
|
+
body.parentId
|
|
2130
|
+
? {
|
|
2131
|
+
id: initialMessageId,
|
|
2132
|
+
deliveryId: initialDeliveryId,
|
|
2133
|
+
sourceTaskId: body.parentId,
|
|
2134
|
+
sourceActor: "user",
|
|
2135
|
+
body: body.brief,
|
|
2136
|
+
}
|
|
1688
2137
|
: undefined,
|
|
1689
|
-
|
|
2138
|
+
});
|
|
2139
|
+
const task = store.getTask(taskId);
|
|
2140
|
+
const taskCreatedEvent = {
|
|
2141
|
+
type: "task_created",
|
|
2142
|
+
taskId,
|
|
2143
|
+
cwd: task?.cwd,
|
|
2144
|
+
cwdDisplay: task?.cwd ? abbreviateHomePath(task.cwd) : undefined,
|
|
2145
|
+
title: task?.title,
|
|
1690
2146
|
configOptions,
|
|
1691
|
-
agentCommands:
|
|
2147
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
1692
2148
|
clientOpId: clientOpId ?? undefined,
|
|
1693
2149
|
};
|
|
1694
|
-
sseManager.broadcast(
|
|
1695
|
-
// ACP's
|
|
2150
|
+
sseManager.broadcast(taskCreatedEvent);
|
|
2151
|
+
// ACP's task_created event fires before inheritance runs, so
|
|
1696
2152
|
// broadcast final configOptions so SSE clients get the inherited values.
|
|
1697
2153
|
if (configOptions.length) {
|
|
1698
2154
|
sseManager.broadcast({
|
|
1699
2155
|
type: "config_option_update",
|
|
1700
|
-
|
|
2156
|
+
taskId,
|
|
1701
2157
|
configOptions,
|
|
1702
2158
|
});
|
|
1703
2159
|
}
|
|
1704
2160
|
json(res, HTTP_STATUS.CREATED, {
|
|
1705
|
-
id:
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
2161
|
+
id: taskId,
|
|
2162
|
+
...(initialMessageId && initialDeliveryId
|
|
2163
|
+
? { initialMessageId, initialDeliveryId }
|
|
2164
|
+
: {}),
|
|
2165
|
+
cwd: task?.cwd ?? body.cwd,
|
|
2166
|
+
cwdDisplay: task?.cwd ? abbreviateHomePath(task.cwd) : undefined,
|
|
2167
|
+
title: task?.title ?? null,
|
|
2168
|
+
brief: task?.brief ?? "",
|
|
2169
|
+
workflowStatus: task?.workflow_status ?? "idle",
|
|
2170
|
+
source: task?.source ?? source,
|
|
2171
|
+
parentId: task?.parent_id ?? null,
|
|
1712
2172
|
configOptions,
|
|
1713
|
-
agentCommands:
|
|
2173
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
1714
2174
|
clientOpId: clientOpId ?? undefined,
|
|
1715
2175
|
});
|
|
2176
|
+
if (initialMessageId)
|
|
2177
|
+
void tasks.drainCollaborationDeliveries(bridge, taskId);
|
|
1716
2178
|
}
|
|
1717
2179
|
catch (err) {
|
|
1718
2180
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1719
|
-
if (err instanceof
|
|
2181
|
+
if (err instanceof InvalidTaskDirectoryError ||
|
|
2182
|
+
err instanceof TaskNotFoundError) {
|
|
1720
2183
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: msg });
|
|
1721
2184
|
}
|
|
2185
|
+
else if (err instanceof TaskTreeBusyError) {
|
|
2186
|
+
json(res, HTTP_STATUS.CONFLICT, { error: msg });
|
|
2187
|
+
}
|
|
2188
|
+
else if (msg.includes("UNIQUE constraint failed: tasks.parent_id, tasks.title")) {
|
|
2189
|
+
// The store's live sibling-title unique index is the authority;
|
|
2190
|
+
// surface it as a client error instead of a raw 500.
|
|
2191
|
+
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
2192
|
+
error: "A sibling task already has that title",
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
1722
2195
|
else {
|
|
1723
2196
|
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: msg });
|
|
1724
2197
|
}
|
|
1725
2198
|
}
|
|
1726
2199
|
return;
|
|
1727
2200
|
}
|
|
1728
|
-
// GET /api/v1/
|
|
1729
|
-
const eventsMatch = url.match(/^\/api\/v1\/
|
|
2201
|
+
// GET /api/v1/tasks/:id/events?thinking=0|1&limit=N&before=SEQ&after=SEQ
|
|
2202
|
+
const eventsMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/events(\?.*)?$/);
|
|
1730
2203
|
if (eventsMatch && req.method === "GET") {
|
|
1731
|
-
const
|
|
2204
|
+
const taskId = decodeURIComponent(eventsMatch[1]);
|
|
1732
2205
|
const queryPart = eventsMatch[2];
|
|
1733
2206
|
const params = new URLSearchParams(queryPart ? queryPart.slice(1) : "");
|
|
1734
2207
|
const excludeThinking = params.get("thinking") === "0";
|
|
@@ -1740,9 +2213,9 @@ export function createRequestHandler(deps) {
|
|
|
1740
2213
|
const limit = limitRaw != null
|
|
1741
2214
|
? Math.max(1, Math.min(10000, Number(limitRaw)))
|
|
1742
2215
|
: undefined;
|
|
1743
|
-
const
|
|
1744
|
-
if (!
|
|
1745
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
2216
|
+
const task = store.getTask(taskId);
|
|
2217
|
+
if (!task) {
|
|
2218
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1746
2219
|
return;
|
|
1747
2220
|
}
|
|
1748
2221
|
// Flush pending buffers so their content becomes part of the event list.
|
|
@@ -1750,18 +2223,18 @@ export function createRequestHandler(deps) {
|
|
|
1750
2223
|
// last thinking/assistant element "open" for continued live streaming.
|
|
1751
2224
|
let streamingThinking = false;
|
|
1752
2225
|
let streamingAssistant = false;
|
|
1753
|
-
if (
|
|
1754
|
-
const runtimeStreaming =
|
|
2226
|
+
if (tasks) {
|
|
2227
|
+
const runtimeStreaming = tasks.state.peekStreaming(taskId);
|
|
1755
2228
|
streamingThinking =
|
|
1756
2229
|
runtimeStreaming.thinking ||
|
|
1757
|
-
Boolean(
|
|
2230
|
+
Boolean(tasks.thinkingBuffers.get(taskId));
|
|
1758
2231
|
streamingAssistant =
|
|
1759
2232
|
runtimeStreaming.assistant ||
|
|
1760
|
-
Boolean(
|
|
1761
|
-
|
|
1762
|
-
|
|
2233
|
+
Boolean(tasks.assistantBuffers.get(taskId));
|
|
2234
|
+
tasks.flushThinkingBuffer(taskId);
|
|
2235
|
+
tasks.flushAssistantBuffer(taskId);
|
|
1763
2236
|
}
|
|
1764
|
-
const events = store.getEvents(
|
|
2237
|
+
const events = store.getEvents(taskId, {
|
|
1765
2238
|
excludeThinking,
|
|
1766
2239
|
afterSeq,
|
|
1767
2240
|
beforeSeq,
|
|
@@ -1770,8 +2243,8 @@ export function createRequestHandler(deps) {
|
|
|
1770
2243
|
// Replace internal uuid attachment paths with `<name> [#<id4>]`
|
|
1771
2244
|
// labels at egress (CLAUDE.md "Attachment label egress
|
|
1772
2245
|
// rewrite"). DB rows still hold raw paths.
|
|
1773
|
-
if (
|
|
1774
|
-
enrichStoredEventsForDisplay(events,
|
|
2246
|
+
if (tasks) {
|
|
2247
|
+
enrichStoredEventsForDisplay(events, tasks.getLabelMap(taskId));
|
|
1775
2248
|
}
|
|
1776
2249
|
// Re-sign image URLs at egress so 1h-old stored URLs become valid
|
|
1777
2250
|
// again — the user can reload history days later and images still
|
|
@@ -1793,9 +2266,9 @@ export function createRequestHandler(deps) {
|
|
|
1793
2266
|
},
|
|
1794
2267
|
};
|
|
1795
2268
|
if (limit != null) {
|
|
1796
|
-
const total = store.getEventCount(
|
|
2269
|
+
const total = store.getEventCount(taskId, { excludeThinking });
|
|
1797
2270
|
const hasMore = events.length > 0
|
|
1798
|
-
? store.getEvents(
|
|
2271
|
+
? store.getEvents(taskId, {
|
|
1799
2272
|
excludeThinking,
|
|
1800
2273
|
beforeSeq: events[0].seq,
|
|
1801
2274
|
limit: 1,
|
|
@@ -1847,10 +2320,10 @@ export function createRequestHandler(deps) {
|
|
|
1847
2320
|
sseManager.writeHeartbeat(client);
|
|
1848
2321
|
return;
|
|
1849
2322
|
}
|
|
1850
|
-
// GET /api/v1/
|
|
1851
|
-
const
|
|
1852
|
-
if (
|
|
1853
|
-
const
|
|
2323
|
+
// GET /api/v1/tasks/:id/events/stream — per-task SSE stream
|
|
2324
|
+
const sseTaskMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/events\/stream(\?.*)?$/);
|
|
2325
|
+
if (sseTaskMatch && req.method === "GET") {
|
|
2326
|
+
const taskId = decodeURIComponent(sseTaskMatch[1]);
|
|
1854
2327
|
let tokenName;
|
|
1855
2328
|
if (deps.authStore) {
|
|
1856
2329
|
const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
|
|
@@ -1863,9 +2336,9 @@ export function createRequestHandler(deps) {
|
|
|
1863
2336
|
}
|
|
1864
2337
|
tokenName = principal.tokenName;
|
|
1865
2338
|
}
|
|
1866
|
-
const
|
|
1867
|
-
if (!
|
|
1868
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
2339
|
+
const task = store.getTask(taskId);
|
|
2340
|
+
if (!task) {
|
|
2341
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
1869
2342
|
return;
|
|
1870
2343
|
}
|
|
1871
2344
|
const clientId = sseManager.generateClientId();
|
|
@@ -1877,7 +2350,7 @@ export function createRequestHandler(deps) {
|
|
|
1877
2350
|
const client = {
|
|
1878
2351
|
id: clientId,
|
|
1879
2352
|
res,
|
|
1880
|
-
|
|
2353
|
+
taskId,
|
|
1881
2354
|
tokenName,
|
|
1882
2355
|
};
|
|
1883
2356
|
sseManager.add(client);
|
|
@@ -1894,7 +2367,7 @@ export function createRequestHandler(deps) {
|
|
|
1894
2367
|
if (lastEventId) {
|
|
1895
2368
|
const afterSeq = parseInt(lastEventId, 10);
|
|
1896
2369
|
if (!isNaN(afterSeq)) {
|
|
1897
|
-
const events = store.getEvents(
|
|
2370
|
+
const events = store.getEvents(taskId, { afterSeq });
|
|
1898
2371
|
for (const evt of events) {
|
|
1899
2372
|
try {
|
|
1900
2373
|
sseManager.sendEvent(client, {
|
|
@@ -1910,19 +2383,19 @@ export function createRequestHandler(deps) {
|
|
|
1910
2383
|
}
|
|
1911
2384
|
return;
|
|
1912
2385
|
}
|
|
1913
|
-
// --- Attachments (
|
|
1914
|
-
// POST /api/v1/
|
|
2386
|
+
// --- Attachments (task-scoped) ---
|
|
2387
|
+
// POST /api/v1/tasks/:id/attachments — multipart/form-data upload.
|
|
1915
2388
|
//
|
|
1916
2389
|
// Wire format: a single `file` field. busboy streams chunks straight
|
|
1917
|
-
// to <data_dir>/
|
|
2390
|
+
// to <data_dir>/tasks/<sid>/attachments/<uuid>.<ext>.tmp; on close
|
|
1918
2391
|
// we atomic-rename to the final name and insert an attachments row.
|
|
1919
2392
|
// Aborts / mid-stream errors / oversize / wrong field name all leave
|
|
1920
2393
|
// the .tmp removed before responding.
|
|
1921
|
-
const imgUploadMatch = url.match(/^\/api\/v1\/
|
|
2394
|
+
const imgUploadMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/attachments\/?$/);
|
|
1922
2395
|
if (imgUploadMatch && req.method === "POST") {
|
|
1923
|
-
const
|
|
1924
|
-
if (!SAFE_ID.test(
|
|
1925
|
-
json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid
|
|
2396
|
+
const taskId = decodeURIComponent(imgUploadMatch[1]);
|
|
2397
|
+
if (!SAFE_ID.test(taskId)) {
|
|
2398
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid task ID" });
|
|
1926
2399
|
return;
|
|
1927
2400
|
}
|
|
1928
2401
|
const ctype = req.headers["content-type"] ?? "";
|
|
@@ -1932,16 +2405,16 @@ export function createRequestHandler(deps) {
|
|
|
1932
2405
|
});
|
|
1933
2406
|
return;
|
|
1934
2407
|
}
|
|
1935
|
-
await handleAttachmentUpload(req, res,
|
|
2408
|
+
await handleAttachmentUpload(req, res, taskId, deps);
|
|
1936
2409
|
return;
|
|
1937
2410
|
}
|
|
1938
|
-
// GET /api/v1/
|
|
2411
|
+
// GET /api/v1/tasks/:id/attachments/:file — serve a previously
|
|
1939
2412
|
// uploaded attachment. Mime + displayName are looked up from the
|
|
1940
2413
|
// attachments table so the response carries the original filename
|
|
1941
2414
|
// (RFC 5987) and the per-mime inline/attachment disposition.
|
|
1942
|
-
const imgGetMatch = url.match(/^\/api\/v1\/
|
|
2415
|
+
const imgGetMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
|
|
1943
2416
|
if (imgGetMatch && req.method === "GET") {
|
|
1944
|
-
const
|
|
2417
|
+
const taskId = decodeURIComponent(imgGetMatch[1]);
|
|
1945
2418
|
const file = decodeURIComponent(imgGetMatch[2]);
|
|
1946
2419
|
// When secret is configured, GET requires sig+exp in query — there
|
|
1947
2420
|
// is no Bearer fallback because <img src=...> / <a href=...> can't
|
|
@@ -1950,7 +2423,7 @@ export function createRequestHandler(deps) {
|
|
|
1950
2423
|
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
1951
2424
|
const sig = params.get("sig") ?? "";
|
|
1952
2425
|
const exp = params.get("exp") ?? "";
|
|
1953
|
-
const basePath = `/api/v1/
|
|
2426
|
+
const basePath = `/api/v1/tasks/${taskId}/attachments/${file}`;
|
|
1954
2427
|
if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
|
|
1955
2428
|
res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
|
|
1956
2429
|
"Content-Type": "application/json",
|
|
@@ -1959,8 +2432,8 @@ export function createRequestHandler(deps) {
|
|
|
1959
2432
|
return;
|
|
1960
2433
|
}
|
|
1961
2434
|
}
|
|
1962
|
-
const filePath = join(deps.dataDir, "
|
|
1963
|
-
if (!filePath.startsWith(join(deps.dataDir, "
|
|
2435
|
+
const filePath = join(deps.dataDir, "tasks", taskId, "attachments", file);
|
|
2436
|
+
if (!filePath.startsWith(join(deps.dataDir, "tasks"))) {
|
|
1964
2437
|
res.writeHead(HTTP_STATUS.FORBIDDEN);
|
|
1965
2438
|
res.end("Forbidden");
|
|
1966
2439
|
return;
|
|
@@ -1968,7 +2441,7 @@ export function createRequestHandler(deps) {
|
|
|
1968
2441
|
// Look up the row to recover the original mime + display name.
|
|
1969
2442
|
// Pre-attachments-table uploads (none in v0.4+) would miss here;
|
|
1970
2443
|
// we degrade gracefully to extension-based mime.
|
|
1971
|
-
const row = store.getAttachmentByFile(
|
|
2444
|
+
const row = store.getAttachmentByFile(taskId, file);
|
|
1972
2445
|
try {
|
|
1973
2446
|
const fileData = await readFile(filePath);
|
|
1974
2447
|
const mime = row?.mime ??
|
|
@@ -1995,7 +2468,7 @@ export function createRequestHandler(deps) {
|
|
|
1995
2468
|
// POST /api/v1/messages — create ingress message
|
|
1996
2469
|
if (url === "/api/v1/messages" && req.method === "POST") {
|
|
1997
2470
|
// client-server-split M2: idempotency for the ingress message
|
|
1998
|
-
// creator. /messages has no real
|
|
2471
|
+
// creator. /messages has no real task id, so we scope the
|
|
1999
2472
|
// cache under the synthetic key "__ingress__".
|
|
2000
2473
|
const { opId, replayed } = tryReplayClientOp(req, res, store, "__ingress__");
|
|
2001
2474
|
if (replayed)
|
|
@@ -2026,14 +2499,14 @@ export function createRequestHandler(deps) {
|
|
|
2026
2499
|
}
|
|
2027
2500
|
const input = validation.data;
|
|
2028
2501
|
const id = `msg-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
2029
|
-
if (input.to.startsWith("
|
|
2030
|
-
const targetSid = input.to.slice("
|
|
2031
|
-
const
|
|
2032
|
-
if (!
|
|
2033
|
-
json(res, HTTP_STATUS.BAD_REQUEST, { error: "
|
|
2502
|
+
if (input.to.startsWith("task:")) {
|
|
2503
|
+
const targetSid = input.to.slice("task:".length);
|
|
2504
|
+
const task = store.getTask(targetSid);
|
|
2505
|
+
if (!task) {
|
|
2506
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "task_not_found" });
|
|
2034
2507
|
return;
|
|
2035
2508
|
}
|
|
2036
|
-
|
|
2509
|
+
tasks?.flushBuffers(targetSid);
|
|
2037
2510
|
const data = {
|
|
2038
2511
|
message_id: id,
|
|
2039
2512
|
from_ref: input.from_ref,
|
|
@@ -2047,7 +2520,7 @@ export function createRequestHandler(deps) {
|
|
|
2047
2520
|
});
|
|
2048
2521
|
sseManager.broadcast({
|
|
2049
2522
|
type: "message",
|
|
2050
|
-
|
|
2523
|
+
taskId: targetSid,
|
|
2051
2524
|
...data,
|
|
2052
2525
|
});
|
|
2053
2526
|
if (deps.pushService) {
|
|
@@ -2065,7 +2538,7 @@ export function createRequestHandler(deps) {
|
|
|
2065
2538
|
msg_id: id,
|
|
2066
2539
|
sess_id: targetSid.slice(0, 8),
|
|
2067
2540
|
});
|
|
2068
|
-
const boundBody = { id, delivered: "
|
|
2541
|
+
const boundBody = { id, delivered: "task" };
|
|
2069
2542
|
saveClientOpResult(store, opId, "__ingress__", HTTP_STATUS.OK, boundBody);
|
|
2070
2543
|
json(res, HTTP_STATUS.OK, boundBody);
|
|
2071
2544
|
return;
|
|
@@ -2129,19 +2602,19 @@ export function createRequestHandler(deps) {
|
|
|
2129
2602
|
const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
|
|
2130
2603
|
if (consumeMatch && req.method === "POST") {
|
|
2131
2604
|
const id = decodeURIComponent(consumeMatch[1]);
|
|
2132
|
-
let
|
|
2605
|
+
let inheritFromTaskId;
|
|
2133
2606
|
try {
|
|
2134
2607
|
const rawBody = await readBody(req);
|
|
2135
2608
|
if (rawBody) {
|
|
2136
2609
|
const body = JSON.parse(rawBody);
|
|
2137
|
-
if (body.
|
|
2138
|
-
typeof body.
|
|
2610
|
+
if (body.inheritFromTaskId !== undefined &&
|
|
2611
|
+
typeof body.inheritFromTaskId !== "string") {
|
|
2139
2612
|
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
2140
|
-
error: "
|
|
2613
|
+
error: "inheritFromTaskId must be a string",
|
|
2141
2614
|
});
|
|
2142
2615
|
return;
|
|
2143
2616
|
}
|
|
2144
|
-
|
|
2617
|
+
inheritFromTaskId = body.inheritFromTaskId;
|
|
2145
2618
|
}
|
|
2146
2619
|
}
|
|
2147
2620
|
catch {
|
|
@@ -2149,7 +2622,7 @@ export function createRequestHandler(deps) {
|
|
|
2149
2622
|
return;
|
|
2150
2623
|
}
|
|
2151
2624
|
const bridge = getBridge?.();
|
|
2152
|
-
if (!
|
|
2625
|
+
if (!tasks || !bridge) {
|
|
2153
2626
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
2154
2627
|
error: "Agent not available",
|
|
2155
2628
|
});
|
|
@@ -2157,10 +2630,10 @@ export function createRequestHandler(deps) {
|
|
|
2157
2630
|
}
|
|
2158
2631
|
let out;
|
|
2159
2632
|
try {
|
|
2160
|
-
out = await
|
|
2633
|
+
out = await tasks.consumeMessage(bridge, id, inheritFromTaskId);
|
|
2161
2634
|
}
|
|
2162
2635
|
catch (err) {
|
|
2163
|
-
if (err instanceof
|
|
2636
|
+
if (err instanceof InvalidTaskDirectoryError) {
|
|
2164
2637
|
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
2165
2638
|
error: err.message,
|
|
2166
2639
|
});
|
|
@@ -2178,7 +2651,7 @@ export function createRequestHandler(deps) {
|
|
|
2178
2651
|
sseManager.broadcast({
|
|
2179
2652
|
type: "message_consumed",
|
|
2180
2653
|
messageId: id,
|
|
2181
|
-
|
|
2654
|
+
taskId: out.taskId,
|
|
2182
2655
|
});
|
|
2183
2656
|
broadcastInboxCount(store, sseManager);
|
|
2184
2657
|
if (!out.alreadyConsumed && deps.pushService) {
|
|
@@ -2186,11 +2659,11 @@ export function createRequestHandler(deps) {
|
|
|
2186
2659
|
}
|
|
2187
2660
|
mlog.info("consume", {
|
|
2188
2661
|
msg_id: id,
|
|
2189
|
-
sess_id: out.
|
|
2662
|
+
sess_id: out.taskId.slice(0, 8),
|
|
2190
2663
|
already_consumed: out.alreadyConsumed,
|
|
2191
2664
|
});
|
|
2192
2665
|
json(res, HTTP_STATUS.OK, {
|
|
2193
|
-
|
|
2666
|
+
taskId: out.taskId,
|
|
2194
2667
|
alreadyConsumed: out.alreadyConsumed,
|
|
2195
2668
|
});
|
|
2196
2669
|
return;
|
|
@@ -2231,9 +2704,9 @@ export function createRequestHandler(deps) {
|
|
|
2231
2704
|
// --- Beta API routes ---
|
|
2232
2705
|
if (url.startsWith("/api/beta/")) {
|
|
2233
2706
|
res.setHeader("Content-Type", "application/json");
|
|
2234
|
-
// POST /api/beta/prompt — quick one-shot prompt (create temp
|
|
2707
|
+
// POST /api/beta/prompt — quick one-shot prompt (create temp task + send)
|
|
2235
2708
|
if (url === "/api/beta/prompt" && req.method === "POST") {
|
|
2236
|
-
if (!
|
|
2709
|
+
if (!tasks || !getBridge) {
|
|
2237
2710
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
2238
2711
|
error: "Agent not available",
|
|
2239
2712
|
});
|
|
@@ -2262,45 +2735,31 @@ export function createRequestHandler(deps) {
|
|
|
2262
2735
|
return;
|
|
2263
2736
|
}
|
|
2264
2737
|
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
|
|
2265
|
-
const {
|
|
2266
|
-
const streamUrl = `/api/v1/
|
|
2267
|
-
const
|
|
2738
|
+
const { taskId, configOptions } = await tasks.createTask(bridge, cwd, undefined, "auto");
|
|
2739
|
+
const streamUrl = `/api/v1/tasks/${taskId}/events/stream`;
|
|
2740
|
+
const task = store.getTask(taskId);
|
|
2268
2741
|
sseManager.broadcast({
|
|
2269
|
-
type: "
|
|
2270
|
-
|
|
2271
|
-
cwd:
|
|
2272
|
-
cwdDisplay:
|
|
2273
|
-
|
|
2274
|
-
: undefined,
|
|
2275
|
-
title: session?.title,
|
|
2742
|
+
type: "task_created",
|
|
2743
|
+
taskId,
|
|
2744
|
+
cwd: task?.cwd,
|
|
2745
|
+
cwdDisplay: task?.cwd ? abbreviateHomePath(task.cwd) : undefined,
|
|
2746
|
+
title: task?.title,
|
|
2276
2747
|
configOptions,
|
|
2277
|
-
agentCommands:
|
|
2748
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
2278
2749
|
});
|
|
2279
|
-
json(res, HTTP_STATUS.ACCEPTED, {
|
|
2750
|
+
json(res, HTTP_STATUS.ACCEPTED, { taskId, streamUrl });
|
|
2280
2751
|
// Fire-and-forget: send the prompt asynchronously, tracking busy state
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
|
|
2285
|
-
titleService.generate(bridge, text, sessionId, (title) => {
|
|
2286
|
-
const titleEvent = {
|
|
2287
|
-
type: "session_title_updated",
|
|
2288
|
-
sessionId,
|
|
2289
|
-
title,
|
|
2290
|
-
};
|
|
2291
|
-
sseManager.broadcast(titleEvent);
|
|
2292
|
-
});
|
|
2293
|
-
}
|
|
2294
|
-
const betaPromptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
|
|
2295
|
-
undefined;
|
|
2752
|
+
tasks.activePrompts.add(taskId);
|
|
2753
|
+
tasks.syncBusy(taskId);
|
|
2754
|
+
const betaPromptId = tasks.state.getState(taskId).runtime.busy?.promptId ?? undefined;
|
|
2296
2755
|
bridge
|
|
2297
|
-
.prompt(
|
|
2756
|
+
.prompt(taskId, text, undefined, betaPromptId)
|
|
2298
2757
|
.catch(() => { })
|
|
2299
2758
|
.finally(() => {
|
|
2300
|
-
if (!
|
|
2759
|
+
if (!tasks.isCurrentPrompt(taskId, betaPromptId))
|
|
2301
2760
|
return;
|
|
2302
|
-
|
|
2303
|
-
|
|
2761
|
+
tasks.activePrompts.delete(taskId);
|
|
2762
|
+
tasks.syncBusy(taskId);
|
|
2304
2763
|
});
|
|
2305
2764
|
return;
|
|
2306
2765
|
}
|
|
@@ -2333,23 +2792,22 @@ export function createRequestHandler(deps) {
|
|
|
2333
2792
|
});
|
|
2334
2793
|
return;
|
|
2335
2794
|
}
|
|
2336
|
-
//
|
|
2795
|
+
// taskId patch semantics: absent = preserve, null = clear,
|
|
2337
2796
|
// string = replace. Zod can't distinguish omitted from explicit
|
|
2338
2797
|
// null after parse, so branch on raw body key.
|
|
2339
|
-
const
|
|
2340
|
-
let
|
|
2341
|
-
if (!
|
|
2342
|
-
|
|
2798
|
+
const hasTaskIdKey = Object.prototype.hasOwnProperty.call(body, "taskId");
|
|
2799
|
+
let taskIdPatch;
|
|
2800
|
+
if (!hasTaskIdKey) {
|
|
2801
|
+
taskIdPatch = undefined;
|
|
2343
2802
|
}
|
|
2344
|
-
else if (body.
|
|
2345
|
-
|
|
2803
|
+
else if (body.taskId === null) {
|
|
2804
|
+
taskIdPatch = null;
|
|
2346
2805
|
}
|
|
2347
|
-
else if (typeof body.
|
|
2348
|
-
body.
|
|
2349
|
-
sessionIdPatch = body.sessionId;
|
|
2806
|
+
else if (typeof body.taskId === "string" && body.taskId.length > 0) {
|
|
2807
|
+
taskIdPatch = body.taskId;
|
|
2350
2808
|
}
|
|
2351
2809
|
else {
|
|
2352
|
-
|
|
2810
|
+
taskIdPatch = null;
|
|
2353
2811
|
}
|
|
2354
2812
|
if (deps.clientRegistry) {
|
|
2355
2813
|
// setVisibility no-ops on unknown clients; auto-register here so
|
|
@@ -2360,17 +2818,17 @@ export function createRequestHandler(deps) {
|
|
|
2360
2818
|
}
|
|
2361
2819
|
const { becameVisibleFor } = deps.clientRegistry.setVisibility(clientId, {
|
|
2362
2820
|
visible: body.visible,
|
|
2363
|
-
active:
|
|
2821
|
+
active: taskIdPatch,
|
|
2364
2822
|
});
|
|
2365
2823
|
// Edge-triggered only: heartbeat refreshes repeat the same
|
|
2366
|
-
// (visible:true,
|
|
2824
|
+
// (visible:true, taskId:X) POST every 15s — firing sendClose
|
|
2367
2825
|
// on each would hammer banner recall. Only the first such
|
|
2368
2826
|
// transition after a change should recall stale banners.
|
|
2369
2827
|
if (becameVisibleFor && deps.pushService) {
|
|
2370
2828
|
void deps.pushService.sendClose(`sess-${becameVisibleFor}-done`);
|
|
2371
|
-
if (
|
|
2372
|
-
for (const perm of
|
|
2373
|
-
if (perm.
|
|
2829
|
+
if (tasks) {
|
|
2830
|
+
for (const perm of tasks.pendingPermissions.values()) {
|
|
2831
|
+
if (perm.taskId === becameVisibleFor) {
|
|
2374
2832
|
void deps.pushService.sendClose(`sess-${becameVisibleFor}-perm-${perm.requestId}`);
|
|
2375
2833
|
}
|
|
2376
2834
|
}
|