@lelouchhe/webagent 0.8.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 +43 -15
- package/config.toml +7 -27
- package/dist/index.html +21 -5
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +6 -6
- package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
- package/dist/sw.js +6 -6
- package/lib/agent-key.js +6 -0
- 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 +69 -7
- package/lib/auth-middleware.js +11 -4
- package/lib/auth.js +2 -2
- package/lib/bridge.js +209 -90
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +166 -85
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- package/lib/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 +1022 -475
- package/lib/server.js +84 -34
- package/lib/share/routes.js +97 -85
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +992 -284
- 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} +90 -38
- 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 +8 -1
- package/dist/js/app.XBFXH37R.js +0 -2
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.CVWXSKJM.js +0 -1
- package/lib/session-manager.js +0 -613
- package/lib/title-service.js +0 -95
package/lib/routes.js
CHANGED
|
@@ -3,19 +3,57 @@ 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";
|
|
12
|
+
import { handleFileRoutes } from "./files/routes.js";
|
|
11
13
|
import { authenticate, isWhitelistedPath } from "./auth-middleware.js";
|
|
12
14
|
import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
|
|
13
15
|
import { agentCommandToken, resolveAgentCommand } from "./agent-commands.js";
|
|
16
|
+
import { abbreviateHomePath } from "./home-path.js";
|
|
14
17
|
import { log } from "./log.js";
|
|
18
|
+
import { isLocalCollaborationTarget } from "./task-collaboration.js";
|
|
19
|
+
import { formatTaskReference } from "./shared/task-reference.js";
|
|
15
20
|
const rlog = log.scope("routes");
|
|
16
21
|
const plog = rlog.scope("prompt");
|
|
17
|
-
const slog = rlog.scope("
|
|
22
|
+
const slog = rlog.scope("task");
|
|
18
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
|
+
}
|
|
19
57
|
import { signAttachmentUrl, verifyAttachmentSig, reSignAttachmentUrlsInJson, } from "./auth.js";
|
|
20
58
|
import { buildContentDisposition, classifyKind, isInlineMime, mimeToExt, normalizeDisplayName, sniffMime, } from "./attachments.js";
|
|
21
59
|
import { readImageDimensions } from "./image-dimensions.js";
|
|
@@ -107,7 +145,7 @@ function getClientOpId(req) {
|
|
|
107
145
|
}
|
|
108
146
|
function logPromptRejectBeforeSave(fields) {
|
|
109
147
|
plog.warn("rejected before save", {
|
|
110
|
-
|
|
148
|
+
taskId: fields.taskId.slice(0, 8),
|
|
111
149
|
status: fields.status,
|
|
112
150
|
reason: fields.reason,
|
|
113
151
|
...(fields.opId ? { opId: fields.opId } : {}),
|
|
@@ -119,11 +157,11 @@ function logPromptRejectBeforeSave(fields) {
|
|
|
119
157
|
...(fields.error ? { error: fields.error } : {}),
|
|
120
158
|
});
|
|
121
159
|
}
|
|
122
|
-
function tryReplayClientOp(req, res, store,
|
|
160
|
+
function tryReplayClientOp(req, res, store, taskId) {
|
|
123
161
|
const opId = getClientOpId(req);
|
|
124
162
|
if (!opId)
|
|
125
163
|
return { opId: null, replayed: false };
|
|
126
|
-
const cached = store.getClientOp(
|
|
164
|
+
const cached = store.getClientOp(taskId, opId);
|
|
127
165
|
if (cached &&
|
|
128
166
|
typeof cached === "object" &&
|
|
129
167
|
"status" in cached &&
|
|
@@ -133,10 +171,18 @@ function tryReplayClientOp(req, res, store, sessionId) {
|
|
|
133
171
|
}
|
|
134
172
|
return { opId, replayed: false };
|
|
135
173
|
}
|
|
136
|
-
function saveClientOpResult(store, opId,
|
|
174
|
+
function saveClientOpResult(store, opId, taskId, status, body) {
|
|
137
175
|
if (!opId)
|
|
138
176
|
return;
|
|
139
|
-
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;
|
|
140
186
|
}
|
|
141
187
|
/** Send a JSON response, gzip-compressed when the client supports it. */
|
|
142
188
|
function json(res, status, data, req) {
|
|
@@ -165,7 +211,7 @@ export function getPrincipal(req) {
|
|
|
165
211
|
}
|
|
166
212
|
/**
|
|
167
213
|
* Multipart upload handler. Streams the `file` field straight to disk under
|
|
168
|
-
* <data_dir>/
|
|
214
|
+
* <data_dir>/tasks/<sid>/attachments/<uuid>.<ext>.tmp, atomic-renames on
|
|
169
215
|
* success, deletes on any failure path. Inserts an attachments row with the
|
|
170
216
|
* resolved realpath so the bridge / permission interceptor can match it
|
|
171
217
|
* later.
|
|
@@ -176,14 +222,14 @@ export function getPrincipal(req) {
|
|
|
176
222
|
* - Optional text fields are ignored — displayName comes from the file
|
|
177
223
|
* part's filename header, classification comes from its content-type.
|
|
178
224
|
*/
|
|
179
|
-
async function handleAttachmentUpload(req, res,
|
|
180
|
-
const { store, dataDir, limits,
|
|
225
|
+
async function handleAttachmentUpload(req, res, taskId, deps) {
|
|
226
|
+
const { store, dataDir, limits, tasks } = deps;
|
|
181
227
|
const fileUploadLimit = limits.file_upload ?? 52_428_800;
|
|
182
|
-
if (!store.
|
|
183
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
228
|
+
if (!store.getTask(taskId)) {
|
|
229
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
184
230
|
return;
|
|
185
231
|
}
|
|
186
|
-
const dir = join(dataDir, "
|
|
232
|
+
const dir = join(dataDir, "tasks", taskId, "attachments");
|
|
187
233
|
await mkdir(dir, { recursive: true });
|
|
188
234
|
const uploadId = randomUUID();
|
|
189
235
|
let tmpPath = null;
|
|
@@ -358,7 +404,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
358
404
|
const rp = await realpath(finalPath);
|
|
359
405
|
const row = store.insertAttachment({
|
|
360
406
|
id: uploadId,
|
|
361
|
-
|
|
407
|
+
taskId,
|
|
362
408
|
kind,
|
|
363
409
|
name: displayName,
|
|
364
410
|
mime: fileMime,
|
|
@@ -367,13 +413,13 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
367
413
|
width: imageDimensions?.width ?? null,
|
|
368
414
|
height: imageDimensions?.height ?? null,
|
|
369
415
|
});
|
|
370
|
-
// Invalidate the per-
|
|
416
|
+
// Invalidate the per-task attachment label cache so the
|
|
371
417
|
// next egress (SSE broadcast or replay) sees this new row.
|
|
372
|
-
|
|
418
|
+
tasks?.invalidateLabelCache(taskId);
|
|
373
419
|
const fileName = `${row.id}.${fileExt}`;
|
|
374
|
-
const basePath = `/api/v1/
|
|
420
|
+
const basePath = `/api/v1/tasks/${taskId}/attachments/${fileName}`;
|
|
375
421
|
// 1h signed URL — long enough that the browser holds the rendered
|
|
376
|
-
// image in <img> cache for the full
|
|
422
|
+
// image in <img> cache for the full task lifetime, short enough
|
|
377
423
|
// that a leaked URL (screenshot, link share) expires within the day.
|
|
378
424
|
const fileUrl = deps.attachmentSecret
|
|
379
425
|
? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
|
|
@@ -386,7 +432,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
386
432
|
width: row.width,
|
|
387
433
|
height: row.height,
|
|
388
434
|
kind: row.kind,
|
|
389
|
-
path: `
|
|
435
|
+
path: `tasks/${taskId}/attachments/${fileName}`,
|
|
390
436
|
url: fileUrl,
|
|
391
437
|
});
|
|
392
438
|
}
|
|
@@ -402,10 +448,15 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
402
448
|
});
|
|
403
449
|
}
|
|
404
450
|
export function createRequestHandler(deps) {
|
|
405
|
-
const { store,
|
|
451
|
+
const { store, tasks, getBridge, sseManager } = deps;
|
|
452
|
+
let bootstrapTaskPromise = null;
|
|
406
453
|
// eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
|
|
407
454
|
return async (req, res) => {
|
|
408
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;
|
|
409
460
|
// --- Auth gate: any /api/** outside whitelist requires Bearer ---
|
|
410
461
|
if (deps.authStore && url.startsWith("/api/")) {
|
|
411
462
|
const path = url.split("?")[0] ?? url;
|
|
@@ -427,19 +478,27 @@ export function createRequestHandler(deps) {
|
|
|
427
478
|
// their URL space before the generic /api/v1 branch. When
|
|
428
479
|
// `shareConfig.enabled === false` handleShareRoutes is a no-op.
|
|
429
480
|
// The auth gate above has already enforced Bearer on owner endpoints
|
|
430
|
-
// (/api/v1/
|
|
481
|
+
// (/api/v1/tasks/:id/share*, /api/v1/shares); viewer endpoints
|
|
431
482
|
// (/s/:token, /api/v1/shared/:token/events) must be whitelisted in
|
|
432
483
|
// auth-middleware.ts so they remain public.
|
|
433
484
|
if (deps.shareConfig &&
|
|
434
485
|
(await handleShareRoutes(req, res, {
|
|
435
486
|
store,
|
|
436
|
-
|
|
487
|
+
tasks,
|
|
437
488
|
config: deps.shareConfig,
|
|
438
489
|
dataDir: deps.dataDir,
|
|
439
490
|
publicDir: deps.publicDir,
|
|
440
491
|
}))) {
|
|
441
492
|
return;
|
|
442
493
|
}
|
|
494
|
+
// File viewer — task-less read-only access to arbitrary local paths.
|
|
495
|
+
// Claims /api/v1/files/{info,list,content} before the generic /api/v1
|
|
496
|
+
// branch. info/list use the Bearer gate above; content is whitelisted
|
|
497
|
+
// only because its handler requires an HMAC-signed URL for headerless
|
|
498
|
+
// media/download fetches (see src/files/routes.ts).
|
|
499
|
+
if (await handleFileRoutes(req, res, { secret: deps.attachmentSecret })) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
443
502
|
// --- API routes ---
|
|
444
503
|
if (url === "/api/v1" || url.startsWith("/api/v1/")) {
|
|
445
504
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -448,8 +507,9 @@ export function createRequestHandler(deps) {
|
|
|
448
507
|
json(res, HTTP_STATUS.OK, {
|
|
449
508
|
version: "v1",
|
|
450
509
|
endpoints: {
|
|
451
|
-
|
|
510
|
+
tasks: "/api/v1/tasks",
|
|
452
511
|
paths: "/api/v1/recent-paths",
|
|
512
|
+
files: "/api/v1/files",
|
|
453
513
|
config: "/api/v1/config",
|
|
454
514
|
events_stream: "/api/v1/events/stream",
|
|
455
515
|
prompt: "/api/beta/prompt",
|
|
@@ -459,19 +519,31 @@ export function createRequestHandler(deps) {
|
|
|
459
519
|
});
|
|
460
520
|
return;
|
|
461
521
|
}
|
|
462
|
-
// GET /api/v1/
|
|
463
|
-
if (url.startsWith("/api/v1/
|
|
464
|
-
!url.slice("/api/v1/
|
|
522
|
+
// GET /api/v1/tasks
|
|
523
|
+
if (url.startsWith("/api/v1/tasks") &&
|
|
524
|
+
!url.slice("/api/v1/tasks".length).match(/^\//) &&
|
|
465
525
|
req.method === "GET") {
|
|
466
526
|
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
467
527
|
const source = params.get("source") ?? undefined;
|
|
468
|
-
|
|
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));
|
|
469
541
|
return;
|
|
470
542
|
}
|
|
471
543
|
// --- GET /api/v1/config ---
|
|
472
544
|
if (url === "/api/v1/config" && req.method === "GET") {
|
|
473
545
|
json(res, HTTP_STATUS.OK, {
|
|
474
|
-
configOptions:
|
|
546
|
+
configOptions: tasks?.cachedConfigOptions ?? [],
|
|
475
547
|
cancelTimeout: deps.limits.cancel_timeout ?? 0,
|
|
476
548
|
recentPathsLimit: deps.limits.recent_paths ?? 10,
|
|
477
549
|
});
|
|
@@ -487,14 +559,17 @@ export function createRequestHandler(deps) {
|
|
|
487
559
|
limit: isNaN(limit) ? 0 : limit,
|
|
488
560
|
ttlDays,
|
|
489
561
|
});
|
|
490
|
-
json(res, HTTP_STATUS.OK, paths)
|
|
562
|
+
json(res, HTTP_STATUS.OK, paths.map((entry) => ({
|
|
563
|
+
...entry,
|
|
564
|
+
cwdDisplay: abbreviateHomePath(entry.cwd),
|
|
565
|
+
})));
|
|
491
566
|
return;
|
|
492
567
|
}
|
|
493
568
|
// GET /api/v1/version
|
|
494
569
|
if (url === "/api/v1/version" && req.method === "GET") {
|
|
495
570
|
json(res, HTTP_STATUS.OK, {
|
|
496
571
|
server: deps.serverVersion ?? "unknown",
|
|
497
|
-
agent:
|
|
572
|
+
agent: tasks?.agentInfo ?? null,
|
|
498
573
|
});
|
|
499
574
|
return;
|
|
500
575
|
}
|
|
@@ -646,7 +721,7 @@ export function createRequestHandler(deps) {
|
|
|
646
721
|
return;
|
|
647
722
|
}
|
|
648
723
|
try {
|
|
649
|
-
await bridge.restart(
|
|
724
|
+
await bridge.restart(tasks);
|
|
650
725
|
json(res, HTTP_STATUS.OK, { ok: true });
|
|
651
726
|
}
|
|
652
727
|
catch (err) {
|
|
@@ -656,30 +731,30 @@ export function createRequestHandler(deps) {
|
|
|
656
731
|
}
|
|
657
732
|
return;
|
|
658
733
|
}
|
|
659
|
-
// --- Permissions (
|
|
660
|
-
// GET /api/v1/
|
|
661
|
-
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\/?(\?.*)?$/);
|
|
662
737
|
if (permListMatch && req.method === "GET") {
|
|
663
|
-
const
|
|
664
|
-
const perms =
|
|
738
|
+
const taskId = decodeURIComponent(permListMatch[1]);
|
|
739
|
+
const perms = tasks?.getPendingPermissions(taskId) ?? [];
|
|
665
740
|
json(res, HTTP_STATUS.OK, perms);
|
|
666
741
|
return;
|
|
667
742
|
}
|
|
668
|
-
// POST /api/v1/
|
|
669
|
-
const permActionMatch = url.match(/^\/api\/v1\/
|
|
743
|
+
// POST /api/v1/tasks/:id/permissions/:reqId
|
|
744
|
+
const permActionMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/permissions\/([^/?]+)\/?$/);
|
|
670
745
|
if (permActionMatch && req.method === "POST") {
|
|
671
|
-
const
|
|
746
|
+
const taskId = decodeURIComponent(permActionMatch[1]);
|
|
672
747
|
const requestId = decodeURIComponent(permActionMatch[2]);
|
|
673
|
-
const { opId, replayed } = tryReplayClientOp(req, res, store,
|
|
748
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
|
|
674
749
|
if (replayed)
|
|
675
750
|
return;
|
|
676
|
-
const perm =
|
|
751
|
+
const perm = tasks?.pendingPermissions.get(requestId);
|
|
677
752
|
if (!perm) {
|
|
678
753
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "Permission not found" });
|
|
679
754
|
return;
|
|
680
755
|
}
|
|
681
|
-
if (perm.
|
|
682
|
-
json(res, HTTP_STATUS.BAD_REQUEST, { error: "
|
|
756
|
+
if (perm.taskId !== taskId) {
|
|
757
|
+
json(res, HTTP_STATUS.BAD_REQUEST, { error: "Task ID mismatch" });
|
|
683
758
|
return;
|
|
684
759
|
}
|
|
685
760
|
const bridge = getBridge?.();
|
|
@@ -712,128 +787,80 @@ export function createRequestHandler(deps) {
|
|
|
712
787
|
else {
|
|
713
788
|
bridge.resolvePermission(requestId, optionId);
|
|
714
789
|
}
|
|
715
|
-
|
|
716
|
-
|
|
790
|
+
tasks.pendingPermissions.delete(requestId);
|
|
791
|
+
tasks.syncPendingPermissions(taskId);
|
|
717
792
|
// Store event and broadcast (same type so SSE drops are recoverable via sync)
|
|
718
793
|
const permEventData = { requestId, optionName, denied };
|
|
719
|
-
store.saveEvent(perm.
|
|
794
|
+
store.saveEvent(perm.taskId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
|
|
720
795
|
sseManager.broadcast({
|
|
721
796
|
type: "permission_response",
|
|
722
|
-
|
|
797
|
+
taskId: perm.taskId,
|
|
723
798
|
...permEventData,
|
|
724
799
|
});
|
|
725
800
|
// Cross-device banner recall: close the permission banner on
|
|
726
801
|
// every subscribed endpoint now that the permission has been
|
|
727
802
|
// handled by this client.
|
|
728
803
|
if (deps.pushService) {
|
|
729
|
-
void deps.pushService.sendClose(`sess-${perm.
|
|
804
|
+
void deps.pushService.sendClose(`sess-${perm.taskId}-perm-${requestId}`);
|
|
730
805
|
}
|
|
731
806
|
const okBody = { ok: true };
|
|
732
|
-
saveClientOpResult(store, opId,
|
|
807
|
+
saveClientOpResult(store, opId, taskId, HTTP_STATUS.OK, okBody);
|
|
733
808
|
json(res, HTTP_STATUS.OK, okBody);
|
|
734
809
|
return;
|
|
735
810
|
}
|
|
736
|
-
// --- POST /api/v1/
|
|
737
|
-
const cancelMatch = url.match(/^\/api\/v1\/
|
|
811
|
+
// --- POST /api/v1/tasks/:id/cancel ---
|
|
812
|
+
const cancelMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/cancel\/?$/);
|
|
738
813
|
if (cancelMatch && req.method === "POST") {
|
|
739
|
-
const
|
|
740
|
-
const
|
|
741
|
-
if (!
|
|
742
|
-
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" });
|
|
743
818
|
return;
|
|
744
819
|
}
|
|
745
|
-
const { opId, replayed } = tryReplayClientOp(req, res, store,
|
|
820
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
|
|
746
821
|
if (replayed)
|
|
747
822
|
return;
|
|
748
|
-
|
|
749
|
-
const hadPendingPrompt = sessions?.cancelPendingPromptSubmission(sessionId) ?? false;
|
|
750
|
-
const hadBash = sessions?.runningBashProcs.has(sessionId) ?? false;
|
|
751
|
-
if (!hadAgentPrompt && !hadPendingPrompt && !hadBash) {
|
|
823
|
+
if (!tasks) {
|
|
752
824
|
const idleBody = { ok: true, status: "idle" };
|
|
753
|
-
saveClientOpResult(store, opId,
|
|
825
|
+
saveClientOpResult(store, opId, taskId, HTTP_STATUS.OK, idleBody);
|
|
754
826
|
json(res, HTTP_STATUS.OK, idleBody);
|
|
755
827
|
return;
|
|
756
828
|
}
|
|
757
|
-
const
|
|
758
|
-
? (
|
|
829
|
+
const bridge = tasks.activePrompts.has(taskId)
|
|
830
|
+
? (getBridge?.() ?? null)
|
|
759
831
|
: null;
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
const force = sessions.interruptedBashProcs.has(proc);
|
|
764
|
-
interruptBashProc(proc, force);
|
|
765
|
-
sessions.interruptedBashProcs.add(proc);
|
|
766
|
-
}
|
|
767
|
-
const bridge = hadAgentPrompt ? getBridge?.() : null;
|
|
768
|
-
if (hadAgentPrompt && !bridge) {
|
|
769
|
-
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
770
|
-
error: "Agent not ready yet",
|
|
771
|
-
});
|
|
772
|
-
return;
|
|
832
|
+
let result;
|
|
833
|
+
try {
|
|
834
|
+
result = await tasks.cancelTaskExecution(taskId, bridge, deps.limits.cancel_timeout ?? 0);
|
|
773
835
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
rlog.info("cancel requested", {
|
|
781
|
-
sessionId: sessionId.slice(0, 8),
|
|
782
|
-
retry: previousCancelStatus !== null,
|
|
783
|
-
previousStatus: previousCancelStatus,
|
|
784
|
-
});
|
|
785
|
-
await bridge.cancel(sessionId);
|
|
786
|
-
const busy = sessions.state.getState(sessionId).runtime.busy;
|
|
787
|
-
const stillCancellingSamePrompt = sessions.activePrompts.has(sessionId) &&
|
|
788
|
-
busy?.kind === "agent" &&
|
|
789
|
-
busy.promptId === cancelledPromptId;
|
|
790
|
-
if (stillCancellingSamePrompt) {
|
|
791
|
-
sessions.state.markCancelRequested(sessionId);
|
|
836
|
+
catch (error) {
|
|
837
|
+
if (error instanceof AgentNotReadyError) {
|
|
838
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
839
|
+
error: "Agent not ready yet",
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
792
842
|
}
|
|
843
|
+
throw error;
|
|
793
844
|
}
|
|
794
|
-
|
|
795
|
-
// instead of pretending the prompt stopped.
|
|
796
|
-
const cancelTimeout = deps.limits.cancel_timeout ?? 0;
|
|
797
|
-
const busyAfterCancel = sessions?.state.getState(sessionId).runtime.busy;
|
|
798
|
-
const cancelPending = hadAgentPrompt &&
|
|
799
|
-
sessions?.activePrompts.has(sessionId) === true &&
|
|
800
|
-
busyAfterCancel?.kind === "agent" &&
|
|
801
|
-
busyAfterCancel.promptId === cancelledPromptId;
|
|
802
|
-
if (cancelPending && cancelTimeout > 0)
|
|
803
|
-
sessions.state.armCancelSafety(sessionId, cancelTimeout);
|
|
804
|
-
sessions?.syncBusy(sessionId);
|
|
805
|
-
const workPending = cancelPending || hadBash;
|
|
806
|
-
const replacementPromptActive = hadAgentPrompt &&
|
|
807
|
-
((sessions?.activePrompts.has(sessionId) === true &&
|
|
808
|
-
busyAfterCancel?.kind === "agent" &&
|
|
809
|
-
busyAfterCancel.promptId !== cancelledPromptId) ||
|
|
810
|
-
sessions?.pendingPromptSubmissions.has(sessionId) === true);
|
|
811
|
-
const status = workPending || replacementPromptActive
|
|
845
|
+
const status = result.status === "cancelling" || result.status === "superseded"
|
|
812
846
|
? HTTP_STATUS.ACCEPTED
|
|
813
847
|
: HTTP_STATUS.OK;
|
|
814
|
-
const okBody = {
|
|
815
|
-
|
|
816
|
-
status: workPending
|
|
817
|
-
? "cancelling"
|
|
818
|
-
: replacementPromptActive
|
|
819
|
-
? "superseded"
|
|
820
|
-
: "cancelled",
|
|
821
|
-
};
|
|
822
|
-
saveClientOpResult(store, opId, sessionId, status, okBody);
|
|
848
|
+
const okBody = { ok: true, status: result.status };
|
|
849
|
+
saveClientOpResult(store, opId, taskId, status, okBody);
|
|
823
850
|
json(res, status, okBody);
|
|
824
851
|
return;
|
|
825
852
|
}
|
|
826
|
-
// --- GET /api/v1/
|
|
827
|
-
const statusMatch = url.match(/^\/api\/v1\/
|
|
853
|
+
// --- GET /api/v1/tasks/:id/status ---
|
|
854
|
+
const statusMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/status\/?$/);
|
|
828
855
|
if (statusMatch && req.method === "GET") {
|
|
829
|
-
const
|
|
830
|
-
const
|
|
831
|
-
if (!
|
|
832
|
-
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" });
|
|
833
860
|
return;
|
|
834
861
|
}
|
|
835
|
-
const busyKind =
|
|
836
|
-
const pendingPerms =
|
|
862
|
+
const busyKind = tasks?.getBusyKind(taskId) ?? null;
|
|
863
|
+
const pendingPerms = tasks?.getPendingPermissions(taskId) ?? [];
|
|
837
864
|
json(res, HTTP_STATUS.OK, {
|
|
838
865
|
busy: busyKind != null,
|
|
839
866
|
busyKind,
|
|
@@ -841,83 +868,184 @@ export function createRequestHandler(deps) {
|
|
|
841
868
|
});
|
|
842
869
|
return;
|
|
843
870
|
}
|
|
844
|
-
// --- GET /api/v1/
|
|
871
|
+
// --- GET /api/v1/tasks/:id/snapshot ---
|
|
845
872
|
// client-server-split M1: single source of truth for "what state is
|
|
846
|
-
// this
|
|
873
|
+
// this task in right now". Frontend calls this on connect / reconnect
|
|
847
874
|
// / after long backgrounding, then applies incremental `state_patch`
|
|
848
875
|
// SSE events.
|
|
849
|
-
const snapshotMatch = url.match(/^\/api\/v1\/
|
|
876
|
+
const snapshotMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/snapshot\/?$/);
|
|
850
877
|
if (snapshotMatch && req.method === "GET") {
|
|
851
|
-
const
|
|
852
|
-
const
|
|
853
|
-
if (!
|
|
854
|
-
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" });
|
|
855
882
|
return;
|
|
856
883
|
}
|
|
857
|
-
if (!
|
|
884
|
+
if (!tasks) {
|
|
858
885
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
859
|
-
error: "
|
|
886
|
+
error: "Task manager not available",
|
|
860
887
|
});
|
|
861
888
|
return;
|
|
862
889
|
}
|
|
863
890
|
const bridge = getBridge?.();
|
|
864
|
-
if (bridge && !
|
|
891
|
+
if (bridge && !tasks.liveTasks.has(taskId)) {
|
|
865
892
|
try {
|
|
866
|
-
// Command discovery happens during
|
|
893
|
+
// Command discovery happens during task/load. Snapshot is the
|
|
867
894
|
// authoritative hydration boundary, so it must join any in-flight
|
|
868
|
-
// restore before reading the per-
|
|
869
|
-
await
|
|
895
|
+
// restore before reading the per-task command state.
|
|
896
|
+
await tasks.ensureResumed(bridge, taskId);
|
|
870
897
|
}
|
|
871
898
|
catch {
|
|
872
899
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
873
|
-
error: "Failed to restore
|
|
900
|
+
error: "Failed to restore task",
|
|
874
901
|
});
|
|
875
902
|
return;
|
|
876
903
|
}
|
|
877
904
|
}
|
|
878
905
|
// Make sure runtime reflects the current activePrompts/bash state even
|
|
879
|
-
// if no patch has been emitted yet for this
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
const runtimeState =
|
|
883
|
-
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);
|
|
884
911
|
json(res, HTTP_STATUS.OK, {
|
|
885
912
|
version: 1,
|
|
886
913
|
seq: runtimeState.seq,
|
|
887
|
-
|
|
888
|
-
id:
|
|
889
|
-
title:
|
|
890
|
-
cwd:
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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,
|
|
894
922
|
lastEventSeq,
|
|
895
923
|
},
|
|
896
924
|
runtime: runtimeState.runtime,
|
|
897
|
-
agentCommands:
|
|
925
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
898
926
|
}, req);
|
|
899
927
|
return;
|
|
900
928
|
}
|
|
901
|
-
// --- POST /api/v1/
|
|
902
|
-
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\/?(\?.*)?$/);
|
|
903
1031
|
if (promptMatch && req.method === "POST") {
|
|
904
|
-
const
|
|
1032
|
+
const taskId = decodeURIComponent(promptMatch[1]);
|
|
905
1033
|
const requestOpId = getClientOpId(req);
|
|
906
|
-
const
|
|
907
|
-
if (!
|
|
1034
|
+
const task = store.getTask(taskId);
|
|
1035
|
+
if (!task) {
|
|
908
1036
|
logPromptRejectBeforeSave({
|
|
909
|
-
|
|
1037
|
+
taskId,
|
|
910
1038
|
status: HTTP_STATUS.NOT_FOUND,
|
|
911
|
-
reason: "
|
|
1039
|
+
reason: "task_not_found",
|
|
912
1040
|
opId: requestOpId,
|
|
913
1041
|
});
|
|
914
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
1042
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "Task not found" });
|
|
915
1043
|
return;
|
|
916
1044
|
}
|
|
917
1045
|
const bridge = getBridge?.();
|
|
918
1046
|
if (!bridge) {
|
|
919
1047
|
logPromptRejectBeforeSave({
|
|
920
|
-
|
|
1048
|
+
taskId,
|
|
921
1049
|
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
922
1050
|
reason: "agent_not_ready",
|
|
923
1051
|
opId: requestOpId,
|
|
@@ -927,33 +1055,33 @@ export function createRequestHandler(deps) {
|
|
|
927
1055
|
});
|
|
928
1056
|
return;
|
|
929
1057
|
}
|
|
930
|
-
if (!
|
|
1058
|
+
if (!tasks) {
|
|
931
1059
|
logPromptRejectBeforeSave({
|
|
932
|
-
|
|
1060
|
+
taskId,
|
|
933
1061
|
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
934
|
-
reason: "
|
|
1062
|
+
reason: "task_manager_unavailable",
|
|
935
1063
|
opId: requestOpId,
|
|
936
1064
|
});
|
|
937
1065
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
938
|
-
error: "
|
|
1066
|
+
error: "Task manager not available",
|
|
939
1067
|
});
|
|
940
1068
|
return;
|
|
941
1069
|
}
|
|
942
|
-
const { opId, replayed } = tryReplayClientOp(req, res, store,
|
|
1070
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
|
|
943
1071
|
if (replayed)
|
|
944
1072
|
return;
|
|
945
|
-
const promptSubmissionId =
|
|
1073
|
+
const promptSubmissionId = tasks.reservePromptSubmission(taskId);
|
|
946
1074
|
if (promptSubmissionId === null) {
|
|
947
|
-
const busyKind =
|
|
1075
|
+
const busyKind = tasks.getBusyKind(taskId);
|
|
948
1076
|
logPromptRejectBeforeSave({
|
|
949
|
-
|
|
1077
|
+
taskId,
|
|
950
1078
|
status: HTTP_STATUS.CONFLICT,
|
|
951
|
-
reason: "
|
|
1079
|
+
reason: "task_busy",
|
|
952
1080
|
opId,
|
|
953
1081
|
busyKind: busyKind ?? undefined,
|
|
954
1082
|
});
|
|
955
1083
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
956
|
-
error: "
|
|
1084
|
+
error: "Task is busy",
|
|
957
1085
|
busyKind,
|
|
958
1086
|
});
|
|
959
1087
|
return;
|
|
@@ -962,39 +1090,39 @@ export function createRequestHandler(deps) {
|
|
|
962
1090
|
const isRequestAborted = () => requestState.aborted;
|
|
963
1091
|
const abortPromptSubmission = () => {
|
|
964
1092
|
requestState.aborted = true;
|
|
965
|
-
|
|
1093
|
+
tasks.releasePromptSubmission(taskId, promptSubmissionId);
|
|
966
1094
|
};
|
|
967
1095
|
res.once("finish", () => {
|
|
968
|
-
|
|
1096
|
+
tasks.releasePromptSubmission(taskId, promptSubmissionId);
|
|
969
1097
|
});
|
|
970
1098
|
req.once("aborted", abortPromptSubmission);
|
|
971
1099
|
res.once("close", () => {
|
|
972
1100
|
if (!res.writableEnded)
|
|
973
1101
|
abortPromptSubmission();
|
|
974
1102
|
});
|
|
975
|
-
// Ensure
|
|
1103
|
+
// Ensure task is live in ACP before prompting (awaits in-flight resume)
|
|
976
1104
|
try {
|
|
977
|
-
await
|
|
1105
|
+
await tasks.ensureResumed(bridge, taskId);
|
|
978
1106
|
}
|
|
979
1107
|
catch (err) {
|
|
980
1108
|
if (isRequestAborted())
|
|
981
1109
|
return;
|
|
982
1110
|
logPromptRejectBeforeSave({
|
|
983
|
-
|
|
1111
|
+
taskId,
|
|
984
1112
|
status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
|
|
985
1113
|
reason: "resume_failed",
|
|
986
1114
|
opId,
|
|
987
1115
|
error: errorMessage(err),
|
|
988
1116
|
});
|
|
989
1117
|
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
990
|
-
error: `Failed to resume
|
|
1118
|
+
error: `Failed to resume task: ${err instanceof Error ? err.message : String(err)}`,
|
|
991
1119
|
});
|
|
992
1120
|
return;
|
|
993
1121
|
}
|
|
994
1122
|
if (isRequestAborted() ||
|
|
995
|
-
|
|
1123
|
+
tasks.isPromptSubmissionCancelled(promptSubmissionId)) {
|
|
996
1124
|
logPromptRejectBeforeSave({
|
|
997
|
-
|
|
1125
|
+
taskId,
|
|
998
1126
|
status: HTTP_STATUS.CONFLICT,
|
|
999
1127
|
reason: "prompt_cancelled_before_start",
|
|
1000
1128
|
opId,
|
|
@@ -1012,7 +1140,7 @@ export function createRequestHandler(deps) {
|
|
|
1012
1140
|
if (isRequestAborted())
|
|
1013
1141
|
return;
|
|
1014
1142
|
logPromptRejectBeforeSave({
|
|
1015
|
-
|
|
1143
|
+
taskId,
|
|
1016
1144
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1017
1145
|
reason: "invalid_json",
|
|
1018
1146
|
opId,
|
|
@@ -1021,7 +1149,7 @@ export function createRequestHandler(deps) {
|
|
|
1021
1149
|
return;
|
|
1022
1150
|
}
|
|
1023
1151
|
if (isRequestAborted() ||
|
|
1024
|
-
|
|
1152
|
+
tasks.isPromptSubmissionCancelled(promptSubmissionId)) {
|
|
1025
1153
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
1026
1154
|
error: "Prompt was cancelled before start",
|
|
1027
1155
|
});
|
|
@@ -1029,7 +1157,7 @@ export function createRequestHandler(deps) {
|
|
|
1029
1157
|
}
|
|
1030
1158
|
if (!body.text) {
|
|
1031
1159
|
logPromptRejectBeforeSave({
|
|
1032
|
-
|
|
1160
|
+
taskId,
|
|
1033
1161
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1034
1162
|
reason: "missing_text",
|
|
1035
1163
|
opId,
|
|
@@ -1050,7 +1178,7 @@ export function createRequestHandler(deps) {
|
|
|
1050
1178
|
if (attachments) {
|
|
1051
1179
|
if (!Array.isArray(attachments)) {
|
|
1052
1180
|
logPromptRejectBeforeSave({
|
|
1053
|
-
|
|
1181
|
+
taskId,
|
|
1054
1182
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1055
1183
|
reason: "attachments_not_array",
|
|
1056
1184
|
opId,
|
|
@@ -1070,7 +1198,7 @@ export function createRequestHandler(deps) {
|
|
|
1070
1198
|
typeof att.displayName !== "string" ||
|
|
1071
1199
|
typeof att.mimeType !== "string") {
|
|
1072
1200
|
logPromptRejectBeforeSave({
|
|
1073
|
-
|
|
1201
|
+
taskId,
|
|
1074
1202
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1075
1203
|
reason: "invalid_attachment_entry",
|
|
1076
1204
|
opId,
|
|
@@ -1088,7 +1216,7 @@ export function createRequestHandler(deps) {
|
|
|
1088
1216
|
typeof att.width === "number" ||
|
|
1089
1217
|
typeof att.height === "number") {
|
|
1090
1218
|
logPromptRejectBeforeSave({
|
|
1091
|
-
|
|
1219
|
+
taskId,
|
|
1092
1220
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
1093
1221
|
reason: "client_supplied_attachment_data",
|
|
1094
1222
|
opId,
|
|
@@ -1104,11 +1232,11 @@ export function createRequestHandler(deps) {
|
|
|
1104
1232
|
}
|
|
1105
1233
|
let agentText = body.text;
|
|
1106
1234
|
if (body.text.startsWith("//")) {
|
|
1107
|
-
const resolved = resolveAgentCommand(body.text,
|
|
1235
|
+
const resolved = resolveAgentCommand(body.text, tasks.getAgentCommands(taskId).commands);
|
|
1108
1236
|
if (!resolved) {
|
|
1109
1237
|
const command = agentCommandToken(body.text);
|
|
1110
1238
|
logPromptRejectBeforeSave({
|
|
1111
|
-
|
|
1239
|
+
taskId,
|
|
1112
1240
|
status: HTTP_STATUS.UNPROCESSABLE_CONTENT,
|
|
1113
1241
|
reason: "unknown_command",
|
|
1114
1242
|
opId,
|
|
@@ -1124,15 +1252,19 @@ export function createRequestHandler(deps) {
|
|
|
1124
1252
|
}
|
|
1125
1253
|
agentText = resolved.agentText;
|
|
1126
1254
|
}
|
|
1255
|
+
const pendingCompactSummary = store.getPendingCompactSummary(taskId);
|
|
1256
|
+
if (pendingCompactSummary) {
|
|
1257
|
+
agentText = prependCompactSummary(pendingCompactSummary, agentText);
|
|
1258
|
+
}
|
|
1127
1259
|
// Stored shape mirrors the wire shape PLUS a server-derived `path`
|
|
1128
1260
|
// for renderers. The path is the unsigned base URL
|
|
1129
|
-
// (`/api/v1/
|
|
1261
|
+
// (`/api/v1/tasks/<sid>/attachments/<filename>`); reSign on
|
|
1130
1262
|
// egress (history GET + SSE broadcast) appends a fresh `?sig=&exp=`.
|
|
1131
1263
|
// Renderers use it to mount `<img>` (kind=image) or `<a>` (kind=file).
|
|
1132
1264
|
// Refs whose attachment row is missing are dropped (defense — the
|
|
1133
1265
|
// dispatcher's [attachment removed] fallback covers that case).
|
|
1134
1266
|
const storedAttachments = attachments?.flatMap((a) => {
|
|
1135
|
-
const row = store.getAttachment(
|
|
1267
|
+
const row = store.getAttachment(taskId, a.attachmentId);
|
|
1136
1268
|
if (!row)
|
|
1137
1269
|
return [];
|
|
1138
1270
|
const fileName = basename(row.realpath);
|
|
@@ -1142,86 +1274,85 @@ export function createRequestHandler(deps) {
|
|
|
1142
1274
|
attachmentId: a.attachmentId,
|
|
1143
1275
|
displayName: a.displayName,
|
|
1144
1276
|
mimeType: a.mimeType,
|
|
1145
|
-
path: `/api/v1/
|
|
1277
|
+
path: `/api/v1/tasks/${taskId}/attachments/${fileName}`,
|
|
1146
1278
|
...(row.width != null && row.height != null
|
|
1147
1279
|
? { width: row.width, height: row.height }
|
|
1148
1280
|
: {}),
|
|
1149
1281
|
},
|
|
1150
1282
|
];
|
|
1151
1283
|
});
|
|
1284
|
+
// A background task can trigger unsolicited Main-agent output after
|
|
1285
|
+
// the foreground ACP prompt has ended. Its chunks remain buffered
|
|
1286
|
+
// until a real protocol boundary arrives; seal them before this user
|
|
1287
|
+
// row so they cannot merge into the next turn's assistant response.
|
|
1288
|
+
tasks.flushBuffers(taskId);
|
|
1152
1289
|
const eventClientOpId = opId ?? randomUUID();
|
|
1153
|
-
store.saveEvent(
|
|
1290
|
+
store.saveEvent(taskId, "user_message", {
|
|
1154
1291
|
text: body.text,
|
|
1155
1292
|
clientOpId: eventClientOpId,
|
|
1156
1293
|
...(storedAttachments?.length
|
|
1157
1294
|
? { attachments: storedAttachments }
|
|
1158
1295
|
: {}),
|
|
1159
1296
|
}, { from_ref: "user" });
|
|
1160
|
-
store.
|
|
1161
|
-
store.touchRecentPath(
|
|
1297
|
+
store.updateTaskLastActive(taskId);
|
|
1298
|
+
store.touchRecentPath(task.cwd);
|
|
1162
1299
|
const userMsgEvent = {
|
|
1163
1300
|
type: "user_message",
|
|
1164
|
-
|
|
1301
|
+
taskId,
|
|
1165
1302
|
text: body.text,
|
|
1166
1303
|
clientOpId: eventClientOpId,
|
|
1167
1304
|
attachments: storedAttachments,
|
|
1168
1305
|
};
|
|
1169
1306
|
sseManager.broadcast(userMsgEvent);
|
|
1170
|
-
// Generate title (fire-and-forget)
|
|
1171
|
-
if (titleService &&
|
|
1172
|
-
sessions && // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- optional dep
|
|
1173
|
-
!sessions.sessionHasTitle.has(sessionId)) {
|
|
1174
|
-
titleService.generate(bridge, body.text, sessionId, (title) => {
|
|
1175
|
-
const titleEvent = {
|
|
1176
|
-
type: "session_title_updated",
|
|
1177
|
-
sessionId,
|
|
1178
|
-
title,
|
|
1179
|
-
};
|
|
1180
|
-
sseManager.broadcast(titleEvent);
|
|
1181
|
-
});
|
|
1182
|
-
}
|
|
1183
1307
|
// Fire prompt asynchronously (don't await — response is 202)
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
const promptId =
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
+
})
|
|
1191
1322
|
.catch((err) => {
|
|
1192
|
-
plog.error("error", {
|
|
1323
|
+
plog.error("error", { taskId, error: err });
|
|
1193
1324
|
})
|
|
1194
1325
|
.finally(() => {
|
|
1195
1326
|
// A turn that outlived its own supersession must not clear the
|
|
1196
1327
|
// busy state of the turn that replaced it.
|
|
1197
|
-
if (!
|
|
1328
|
+
if (!tasks.isCurrentPrompt(taskId, promptId))
|
|
1198
1329
|
return;
|
|
1199
|
-
|
|
1200
|
-
|
|
1330
|
+
tasks.activePrompts.delete(taskId);
|
|
1331
|
+
tasks.syncBusy(taskId);
|
|
1201
1332
|
});
|
|
1202
1333
|
const acceptedBody = { status: "accepted" };
|
|
1203
|
-
saveClientOpResult(store, opId,
|
|
1334
|
+
saveClientOpResult(store, opId, taskId, HTTP_STATUS.ACCEPTED, acceptedBody);
|
|
1204
1335
|
json(res, HTTP_STATUS.ACCEPTED, acceptedBody);
|
|
1205
1336
|
return;
|
|
1206
1337
|
}
|
|
1207
|
-
// --- POST /api/v1/
|
|
1208
|
-
const bashMatch = url.match(/^\/api\/v1\/
|
|
1338
|
+
// --- POST /api/v1/tasks/:id/bash ---
|
|
1339
|
+
const bashMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/bash\/?$/);
|
|
1209
1340
|
if (bashMatch && req.method === "POST") {
|
|
1210
|
-
const
|
|
1211
|
-
const
|
|
1212
|
-
if (!
|
|
1213
|
-
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" });
|
|
1214
1345
|
return;
|
|
1215
1346
|
}
|
|
1216
|
-
if (!
|
|
1347
|
+
if (!tasks) {
|
|
1217
1348
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1218
|
-
error: "
|
|
1349
|
+
error: "Task manager not available",
|
|
1219
1350
|
});
|
|
1220
1351
|
return;
|
|
1221
1352
|
}
|
|
1222
|
-
if (
|
|
1353
|
+
if (tasks.runningBashProcs.has(taskId)) {
|
|
1223
1354
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
1224
|
-
error: "A bash command is already running in this
|
|
1355
|
+
error: "A bash command is already running in this task",
|
|
1225
1356
|
});
|
|
1226
1357
|
return;
|
|
1227
1358
|
}
|
|
@@ -1239,11 +1370,11 @@ export function createRequestHandler(deps) {
|
|
|
1239
1370
|
});
|
|
1240
1371
|
return;
|
|
1241
1372
|
}
|
|
1242
|
-
const cwd =
|
|
1243
|
-
store.saveEvent(
|
|
1373
|
+
const cwd = tasks.getTaskCwd(taskId);
|
|
1374
|
+
store.saveEvent(taskId, "bash_command", { command: body.command }, { from_ref: "user" });
|
|
1244
1375
|
const bashCmdEvent = {
|
|
1245
1376
|
type: "bash_command",
|
|
1246
|
-
|
|
1377
|
+
taskId,
|
|
1247
1378
|
command: body.command,
|
|
1248
1379
|
};
|
|
1249
1380
|
sseManager.broadcast(bashCmdEvent);
|
|
@@ -1259,8 +1390,8 @@ export function createRequestHandler(deps) {
|
|
|
1259
1390
|
env: { ...process.env, TERM: "dumb" },
|
|
1260
1391
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1261
1392
|
});
|
|
1262
|
-
|
|
1263
|
-
|
|
1393
|
+
tasks.runningBashProcs.set(taskId, child);
|
|
1394
|
+
tasks.syncBusy(taskId);
|
|
1264
1395
|
let output = "";
|
|
1265
1396
|
let outputTruncated = false;
|
|
1266
1397
|
const limit = deps.limits.bash_output;
|
|
@@ -1278,7 +1409,7 @@ export function createRequestHandler(deps) {
|
|
|
1278
1409
|
}
|
|
1279
1410
|
const bashOutEvent = {
|
|
1280
1411
|
type: "bash_output",
|
|
1281
|
-
|
|
1412
|
+
taskId,
|
|
1282
1413
|
text,
|
|
1283
1414
|
stream,
|
|
1284
1415
|
};
|
|
@@ -1287,26 +1418,26 @@ export function createRequestHandler(deps) {
|
|
|
1287
1418
|
child.stdout.on("data", onData("stdout"));
|
|
1288
1419
|
child.stderr.on("data", onData("stderr"));
|
|
1289
1420
|
child.on("close", (code, signal) => {
|
|
1290
|
-
|
|
1291
|
-
|
|
1421
|
+
tasks.runningBashProcs.delete(taskId);
|
|
1422
|
+
tasks.syncBusy(taskId);
|
|
1292
1423
|
const stored = outputTruncated ? "[truncated]\n" + output : output;
|
|
1293
|
-
store.saveEvent(
|
|
1424
|
+
store.saveEvent(taskId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
|
|
1294
1425
|
const bashDoneEvent = {
|
|
1295
1426
|
type: "bash_done",
|
|
1296
|
-
|
|
1427
|
+
taskId,
|
|
1297
1428
|
code,
|
|
1298
1429
|
signal,
|
|
1299
1430
|
};
|
|
1300
1431
|
sseManager.broadcast(bashDoneEvent);
|
|
1301
1432
|
});
|
|
1302
1433
|
child.on("error", (err) => {
|
|
1303
|
-
|
|
1304
|
-
|
|
1434
|
+
tasks.runningBashProcs.delete(taskId);
|
|
1435
|
+
tasks.syncBusy(taskId);
|
|
1305
1436
|
const errMsg = errorMessage(err);
|
|
1306
|
-
store.saveEvent(
|
|
1437
|
+
store.saveEvent(taskId, "bash_result", { output: errMsg, code: -1, signal: null }, { from_ref: "system" });
|
|
1307
1438
|
const bashErrEvent = {
|
|
1308
1439
|
type: "bash_done",
|
|
1309
|
-
|
|
1440
|
+
taskId,
|
|
1310
1441
|
code: -1,
|
|
1311
1442
|
signal: null,
|
|
1312
1443
|
error: errMsg,
|
|
@@ -1316,31 +1447,31 @@ export function createRequestHandler(deps) {
|
|
|
1316
1447
|
json(res, HTTP_STATUS.ACCEPTED, { status: "accepted" });
|
|
1317
1448
|
return;
|
|
1318
1449
|
}
|
|
1319
|
-
// --- POST /api/v1/
|
|
1320
|
-
const bashCancelMatch = url.match(/^\/api\/v1\/
|
|
1450
|
+
// --- POST /api/v1/tasks/:id/bash/cancel ---
|
|
1451
|
+
const bashCancelMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/bash\/cancel\/?$/);
|
|
1321
1452
|
if (bashCancelMatch && req.method === "POST") {
|
|
1322
|
-
const
|
|
1323
|
-
const
|
|
1324
|
-
if (!
|
|
1325
|
-
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" });
|
|
1326
1457
|
return;
|
|
1327
1458
|
}
|
|
1328
|
-
interruptBashProc(
|
|
1459
|
+
interruptBashProc(tasks?.runningBashProcs.get(taskId));
|
|
1329
1460
|
json(res, HTTP_STATUS.OK, { ok: true });
|
|
1330
1461
|
return;
|
|
1331
1462
|
}
|
|
1332
|
-
// --- PUT /api/v1/
|
|
1333
|
-
// --- PUT /api/v1/
|
|
1334
|
-
const legacyConfigPutMatch = url.match(/^\/api\/v1\/
|
|
1335
|
-
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\/([^/]+)\/?$/);
|
|
1336
1467
|
const configPutMatch = legacyConfigPutMatch ?? genericConfigPutMatch;
|
|
1337
1468
|
if (configPutMatch && req.method === "PUT") {
|
|
1338
|
-
const
|
|
1469
|
+
const taskId = decodeURIComponent(configPutMatch[1]);
|
|
1339
1470
|
const configPath = decodeURIComponent(configPutMatch[2]);
|
|
1340
1471
|
const configId = configPath.replace(/-/g, "_");
|
|
1341
|
-
const
|
|
1342
|
-
if (!
|
|
1343
|
-
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" });
|
|
1344
1475
|
return;
|
|
1345
1476
|
}
|
|
1346
1477
|
const bridge = getBridge?.();
|
|
@@ -1365,20 +1496,20 @@ export function createRequestHandler(deps) {
|
|
|
1365
1496
|
return;
|
|
1366
1497
|
}
|
|
1367
1498
|
try {
|
|
1368
|
-
const configOptions = await bridge.setConfigOption(
|
|
1499
|
+
const configOptions = await bridge.setConfigOption(taskId, configId, body.value);
|
|
1369
1500
|
for (const opt of configOptions) {
|
|
1370
1501
|
if (typeof opt.currentValue === "string") {
|
|
1371
|
-
store.
|
|
1502
|
+
store.updateTaskConfig(taskId, opt.id, opt.currentValue);
|
|
1372
1503
|
}
|
|
1373
1504
|
}
|
|
1374
1505
|
sseManager.broadcast({
|
|
1375
1506
|
type: "config_option_update",
|
|
1376
|
-
|
|
1507
|
+
taskId,
|
|
1377
1508
|
configOptions,
|
|
1378
1509
|
});
|
|
1379
1510
|
sseManager.broadcast({
|
|
1380
1511
|
type: "config_set",
|
|
1381
|
-
|
|
1512
|
+
taskId,
|
|
1382
1513
|
configId,
|
|
1383
1514
|
value: body.value,
|
|
1384
1515
|
});
|
|
@@ -1391,13 +1522,13 @@ export function createRequestHandler(deps) {
|
|
|
1391
1522
|
}
|
|
1392
1523
|
return;
|
|
1393
1524
|
}
|
|
1394
|
-
// --- PUT /api/v1/
|
|
1395
|
-
const titlePutMatch = url.match(/^\/api\/v1\/
|
|
1525
|
+
// --- PUT /api/v1/tasks/:id/title ---
|
|
1526
|
+
const titlePutMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/title\/?$/);
|
|
1396
1527
|
if (titlePutMatch && req.method === "PUT") {
|
|
1397
|
-
const
|
|
1398
|
-
const
|
|
1399
|
-
if (!
|
|
1400
|
-
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" });
|
|
1401
1532
|
return;
|
|
1402
1533
|
}
|
|
1403
1534
|
let body;
|
|
@@ -1414,32 +1545,331 @@ export function createRequestHandler(deps) {
|
|
|
1414
1545
|
});
|
|
1415
1546
|
return;
|
|
1416
1547
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
const
|
|
1421
|
-
if (
|
|
1422
|
-
|
|
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);
|
|
1423
1570
|
const titleEvent = {
|
|
1424
|
-
type: "
|
|
1425
|
-
|
|
1426
|
-
title
|
|
1571
|
+
type: "task_title_updated",
|
|
1572
|
+
taskId,
|
|
1573
|
+
title,
|
|
1427
1574
|
};
|
|
1428
1575
|
sseManager.broadcast(titleEvent);
|
|
1429
|
-
json(res, HTTP_STATUS.OK, { title
|
|
1576
|
+
json(res, HTTP_STATUS.OK, { title });
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
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") {
|
|
1582
|
+
const bridge = getBridge?.();
|
|
1583
|
+
if (!bridge) {
|
|
1584
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1585
|
+
error: "Agent not ready yet",
|
|
1586
|
+
});
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
if (!tasks) {
|
|
1590
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1591
|
+
error: "Task manager not available",
|
|
1592
|
+
});
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const taskManager = tasks;
|
|
1596
|
+
bootstrapTaskPromise ??= (async () => {
|
|
1597
|
+
const existing = store.listTasks().at(0);
|
|
1598
|
+
if (existing) {
|
|
1599
|
+
return {
|
|
1600
|
+
id: existing.id,
|
|
1601
|
+
cwd: existing.cwd,
|
|
1602
|
+
cwdDisplay: abbreviateHomePath(existing.cwd),
|
|
1603
|
+
title: existing.title,
|
|
1604
|
+
source: existing.source,
|
|
1605
|
+
configOptions: [],
|
|
1606
|
+
agentCommands: taskManager.getAgentCommands(existing.id),
|
|
1607
|
+
created: false,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
const { taskId, configOptions } = await taskManager.createTask(bridge);
|
|
1611
|
+
const task = store.getTask(taskId);
|
|
1612
|
+
const result = {
|
|
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",
|
|
1618
|
+
configOptions,
|
|
1619
|
+
agentCommands: taskManager.getAgentCommands(taskId),
|
|
1620
|
+
created: true,
|
|
1621
|
+
};
|
|
1622
|
+
sseManager.broadcast({
|
|
1623
|
+
type: "task_created",
|
|
1624
|
+
taskId,
|
|
1625
|
+
cwd: result.cwd,
|
|
1626
|
+
cwdDisplay: result.cwdDisplay,
|
|
1627
|
+
title: result.title,
|
|
1628
|
+
configOptions,
|
|
1629
|
+
agentCommands: result.agentCommands,
|
|
1630
|
+
});
|
|
1631
|
+
return result;
|
|
1632
|
+
})().finally(() => {
|
|
1633
|
+
bootstrapTaskPromise = null;
|
|
1634
|
+
});
|
|
1635
|
+
try {
|
|
1636
|
+
const result = await bootstrapTaskPromise;
|
|
1637
|
+
json(res, HTTP_STATUS.OK, {
|
|
1638
|
+
...result,
|
|
1639
|
+
clientOpId: getClientOpId(req) ?? undefined,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
catch (err) {
|
|
1643
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
1644
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
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
|
+
}
|
|
1430
1860
|
return;
|
|
1431
1861
|
}
|
|
1432
|
-
// ---
|
|
1433
|
-
const
|
|
1434
|
-
if (
|
|
1435
|
-
const
|
|
1436
|
-
// POST /api/v1/
|
|
1437
|
-
// This match is for /api/v1/
|
|
1438
|
-
// GET /api/v1/
|
|
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
|
|
1439
1869
|
if (req.method === "GET") {
|
|
1440
|
-
const
|
|
1441
|
-
if (!
|
|
1442
|
-
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" });
|
|
1443
1873
|
return;
|
|
1444
1874
|
}
|
|
1445
1875
|
// Start resume in background (non-blocking) so the client gets metadata fast.
|
|
@@ -1447,21 +1877,21 @@ export function createRequestHandler(deps) {
|
|
|
1447
1877
|
// configOptions returns inline. This eliminates the race where the
|
|
1448
1878
|
// post-resume `config_option_update` broadcast can arrive before the
|
|
1449
1879
|
// client's SSE is fully wired up — a flake observed on slow CI runners.
|
|
1450
|
-
const wasLive =
|
|
1880
|
+
const wasLive = tasks?.liveTasks.has(taskId) ?? true;
|
|
1451
1881
|
let resumePromise = null;
|
|
1452
|
-
if (
|
|
1882
|
+
if (tasks && getBridge && !wasLive) {
|
|
1453
1883
|
const bridge = getBridge();
|
|
1454
1884
|
if (bridge) {
|
|
1455
|
-
resumePromise =
|
|
1885
|
+
resumePromise = tasks.ensureResumed(bridge, taskId);
|
|
1456
1886
|
// Broadcast config_option_update too, so any OTHER client viewing
|
|
1457
|
-
// the same
|
|
1887
|
+
// the same task (whose own GET may have raced and returned cold)
|
|
1458
1888
|
// also picks up the warm values.
|
|
1459
1889
|
resumePromise
|
|
1460
1890
|
.then(() => {
|
|
1461
|
-
const cur = store.
|
|
1462
|
-
if (!cur || !
|
|
1891
|
+
const cur = store.getTask(taskId);
|
|
1892
|
+
if (!cur || !tasks.cachedConfigOptions.length)
|
|
1463
1893
|
return;
|
|
1464
|
-
const opts =
|
|
1894
|
+
const opts = tasks.cachedConfigOptions.map((opt) => {
|
|
1465
1895
|
const stored = {
|
|
1466
1896
|
model: cur.model,
|
|
1467
1897
|
mode: cur.mode,
|
|
@@ -1474,45 +1904,24 @@ export function createRequestHandler(deps) {
|
|
|
1474
1904
|
});
|
|
1475
1905
|
sseManager.broadcast({
|
|
1476
1906
|
type: "config_option_update",
|
|
1477
|
-
|
|
1907
|
+
taskId,
|
|
1478
1908
|
configOptions: opts,
|
|
1479
1909
|
});
|
|
1480
1910
|
})
|
|
1481
1911
|
.catch(() => { });
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
sessions.activePrompts.add(sessionId);
|
|
1487
|
-
sessions.syncBusy(sessionId);
|
|
1488
|
-
void resumePromise
|
|
1489
|
-
.then(() => {
|
|
1490
|
-
if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
|
|
1491
|
-
// Retry not needed after all — release the optimistic lock
|
|
1492
|
-
sessions.activePrompts.delete(sessionId);
|
|
1493
|
-
sessions.syncBusy(sessionId);
|
|
1494
|
-
}
|
|
1495
|
-
})
|
|
1496
|
-
.catch(() => {
|
|
1497
|
-
sessions.activePrompts.delete(sessionId);
|
|
1498
|
-
sessions.syncBusy(sessionId);
|
|
1499
|
-
});
|
|
1500
|
-
}
|
|
1501
|
-
else {
|
|
1502
|
-
resumePromise.catch((err) => {
|
|
1503
|
-
slog.error("background resume failed", {
|
|
1504
|
-
sessionId: sessionId.slice(0, 8) + "…",
|
|
1505
|
-
error: err,
|
|
1506
|
-
});
|
|
1912
|
+
resumePromise.catch((err) => {
|
|
1913
|
+
slog.error("background resume failed", {
|
|
1914
|
+
taskId: taskId.slice(0, 8) + "…",
|
|
1915
|
+
error: err,
|
|
1507
1916
|
});
|
|
1508
|
-
}
|
|
1917
|
+
});
|
|
1509
1918
|
}
|
|
1510
1919
|
}
|
|
1511
1920
|
// If cache is cold and we kicked off a resume, wait briefly so the
|
|
1512
1921
|
// GET response includes warm configOptions. Bounded to 8s — past that
|
|
1513
1922
|
// we fall back to returning empty configOptions and rely on the
|
|
1514
1923
|
// broadcast above. The frontend retries (re-fetches) on broadcast.
|
|
1515
|
-
if (resumePromise && !
|
|
1924
|
+
if (resumePromise && !tasks?.cachedConfigOptions.length) {
|
|
1516
1925
|
let timer = null;
|
|
1517
1926
|
const timeoutPromise = new Promise((resolve) => {
|
|
1518
1927
|
timer = setTimeout(resolve, 8000);
|
|
@@ -1525,16 +1934,16 @@ export function createRequestHandler(deps) {
|
|
|
1525
1934
|
timeoutPromise,
|
|
1526
1935
|
]).catch(() => { });
|
|
1527
1936
|
}
|
|
1528
|
-
// Re-read
|
|
1529
|
-
const
|
|
1530
|
-
const configOptions =
|
|
1937
|
+
// Re-read task in case resume mutated stored config
|
|
1938
|
+
const freshTask = store.getTask(taskId) ?? task;
|
|
1939
|
+
const configOptions = tasks
|
|
1531
1940
|
? (() => {
|
|
1532
1941
|
// Build configOptions from cached + stored overrides
|
|
1533
|
-
const opts =
|
|
1942
|
+
const opts = tasks.cachedConfigOptions.map((opt) => {
|
|
1534
1943
|
const stored = {
|
|
1535
|
-
model:
|
|
1536
|
-
mode:
|
|
1537
|
-
reasoning_effort:
|
|
1944
|
+
model: freshTask.model,
|
|
1945
|
+
mode: freshTask.mode,
|
|
1946
|
+
reasoning_effort: freshTask.reasoning_effort,
|
|
1538
1947
|
};
|
|
1539
1948
|
const override = stored[opt.id];
|
|
1540
1949
|
return override && "options" in opt
|
|
@@ -1545,43 +1954,113 @@ export function createRequestHandler(deps) {
|
|
|
1545
1954
|
})()
|
|
1546
1955
|
: [];
|
|
1547
1956
|
json(res, HTTP_STATUS.OK, {
|
|
1548
|
-
id:
|
|
1549
|
-
cwd:
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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,
|
|
1554
1965
|
configOptions,
|
|
1555
1966
|
}, req);
|
|
1556
1967
|
return;
|
|
1557
1968
|
}
|
|
1558
|
-
// DELETE /api/v1/
|
|
1969
|
+
// DELETE /api/v1/tasks/:id
|
|
1559
1970
|
if (req.method === "DELETE") {
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
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" });
|
|
1563
1975
|
return;
|
|
1564
1976
|
}
|
|
1565
|
-
if (
|
|
1977
|
+
if (tasks && tasks.getBusyKind(taskId) !== null) {
|
|
1978
|
+
const busyPath = store.getTaskPath(taskId) ?? taskId;
|
|
1566
1979
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
1567
|
-
error:
|
|
1980
|
+
error: `Cancel active work in ${busyPath} before deleting this task`,
|
|
1568
1981
|
});
|
|
1569
1982
|
return;
|
|
1570
1983
|
}
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
+
}
|
|
1573
2000
|
}
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
+
});
|
|
1576
2058
|
}
|
|
1577
|
-
sseManager.broadcast({ type: "session_deleted", sessionId });
|
|
1578
|
-
res.writeHead(HTTP_STATUS.NO_CONTENT);
|
|
1579
|
-
res.end();
|
|
1580
2059
|
return;
|
|
1581
2060
|
}
|
|
1582
2061
|
}
|
|
1583
|
-
// POST /api/v1/
|
|
1584
|
-
if (url === "/api/v1/
|
|
2062
|
+
// POST /api/v1/tasks (create new task)
|
|
2063
|
+
if (url === "/api/v1/tasks" && req.method === "POST") {
|
|
1585
2064
|
const clientOpId = getClientOpId(req);
|
|
1586
2065
|
const bridge = getBridge?.();
|
|
1587
2066
|
if (!bridge) {
|
|
@@ -1590,9 +2069,9 @@ export function createRequestHandler(deps) {
|
|
|
1590
2069
|
});
|
|
1591
2070
|
return;
|
|
1592
2071
|
}
|
|
1593
|
-
if (!
|
|
2072
|
+
if (!tasks) {
|
|
1594
2073
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1595
|
-
error: "
|
|
2074
|
+
error: "Task manager not available",
|
|
1596
2075
|
});
|
|
1597
2076
|
return;
|
|
1598
2077
|
}
|
|
@@ -1605,53 +2084,124 @@ export function createRequestHandler(deps) {
|
|
|
1605
2084
|
return;
|
|
1606
2085
|
}
|
|
1607
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;
|
|
1608
2115
|
try {
|
|
1609
|
-
const {
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
+
}
|
|
2137
|
+
: undefined,
|
|
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,
|
|
1616
2146
|
configOptions,
|
|
1617
|
-
agentCommands:
|
|
2147
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
1618
2148
|
clientOpId: clientOpId ?? undefined,
|
|
1619
2149
|
};
|
|
1620
|
-
sseManager.broadcast(
|
|
1621
|
-
// ACP's
|
|
2150
|
+
sseManager.broadcast(taskCreatedEvent);
|
|
2151
|
+
// ACP's task_created event fires before inheritance runs, so
|
|
1622
2152
|
// broadcast final configOptions so SSE clients get the inherited values.
|
|
1623
2153
|
if (configOptions.length) {
|
|
1624
2154
|
sseManager.broadcast({
|
|
1625
2155
|
type: "config_option_update",
|
|
1626
|
-
|
|
2156
|
+
taskId,
|
|
1627
2157
|
configOptions,
|
|
1628
2158
|
});
|
|
1629
2159
|
}
|
|
1630
2160
|
json(res, HTTP_STATUS.CREATED, {
|
|
1631
|
-
id:
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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,
|
|
1635
2172
|
configOptions,
|
|
1636
|
-
agentCommands:
|
|
2173
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
1637
2174
|
clientOpId: clientOpId ?? undefined,
|
|
1638
2175
|
});
|
|
2176
|
+
if (initialMessageId)
|
|
2177
|
+
void tasks.drainCollaborationDeliveries(bridge, taskId);
|
|
1639
2178
|
}
|
|
1640
2179
|
catch (err) {
|
|
1641
2180
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1642
|
-
if (err instanceof
|
|
2181
|
+
if (err instanceof InvalidTaskDirectoryError ||
|
|
2182
|
+
err instanceof TaskNotFoundError) {
|
|
1643
2183
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: msg });
|
|
1644
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
|
+
}
|
|
1645
2195
|
else {
|
|
1646
2196
|
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: msg });
|
|
1647
2197
|
}
|
|
1648
2198
|
}
|
|
1649
2199
|
return;
|
|
1650
2200
|
}
|
|
1651
|
-
// GET /api/v1/
|
|
1652
|
-
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(\?.*)?$/);
|
|
1653
2203
|
if (eventsMatch && req.method === "GET") {
|
|
1654
|
-
const
|
|
2204
|
+
const taskId = decodeURIComponent(eventsMatch[1]);
|
|
1655
2205
|
const queryPart = eventsMatch[2];
|
|
1656
2206
|
const params = new URLSearchParams(queryPart ? queryPart.slice(1) : "");
|
|
1657
2207
|
const excludeThinking = params.get("thinking") === "0";
|
|
@@ -1663,9 +2213,9 @@ export function createRequestHandler(deps) {
|
|
|
1663
2213
|
const limit = limitRaw != null
|
|
1664
2214
|
? Math.max(1, Math.min(10000, Number(limitRaw)))
|
|
1665
2215
|
: undefined;
|
|
1666
|
-
const
|
|
1667
|
-
if (!
|
|
1668
|
-
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" });
|
|
1669
2219
|
return;
|
|
1670
2220
|
}
|
|
1671
2221
|
// Flush pending buffers so their content becomes part of the event list.
|
|
@@ -1673,18 +2223,18 @@ export function createRequestHandler(deps) {
|
|
|
1673
2223
|
// last thinking/assistant element "open" for continued live streaming.
|
|
1674
2224
|
let streamingThinking = false;
|
|
1675
2225
|
let streamingAssistant = false;
|
|
1676
|
-
if (
|
|
1677
|
-
const runtimeStreaming =
|
|
2226
|
+
if (tasks) {
|
|
2227
|
+
const runtimeStreaming = tasks.state.peekStreaming(taskId);
|
|
1678
2228
|
streamingThinking =
|
|
1679
2229
|
runtimeStreaming.thinking ||
|
|
1680
|
-
Boolean(
|
|
2230
|
+
Boolean(tasks.thinkingBuffers.get(taskId));
|
|
1681
2231
|
streamingAssistant =
|
|
1682
2232
|
runtimeStreaming.assistant ||
|
|
1683
|
-
Boolean(
|
|
1684
|
-
|
|
1685
|
-
|
|
2233
|
+
Boolean(tasks.assistantBuffers.get(taskId));
|
|
2234
|
+
tasks.flushThinkingBuffer(taskId);
|
|
2235
|
+
tasks.flushAssistantBuffer(taskId);
|
|
1686
2236
|
}
|
|
1687
|
-
const events = store.getEvents(
|
|
2237
|
+
const events = store.getEvents(taskId, {
|
|
1688
2238
|
excludeThinking,
|
|
1689
2239
|
afterSeq,
|
|
1690
2240
|
beforeSeq,
|
|
@@ -1693,8 +2243,8 @@ export function createRequestHandler(deps) {
|
|
|
1693
2243
|
// Replace internal uuid attachment paths with `<name> [#<id4>]`
|
|
1694
2244
|
// labels at egress (CLAUDE.md "Attachment label egress
|
|
1695
2245
|
// rewrite"). DB rows still hold raw paths.
|
|
1696
|
-
if (
|
|
1697
|
-
enrichStoredEventsForDisplay(events,
|
|
2246
|
+
if (tasks) {
|
|
2247
|
+
enrichStoredEventsForDisplay(events, tasks.getLabelMap(taskId));
|
|
1698
2248
|
}
|
|
1699
2249
|
// Re-sign image URLs at egress so 1h-old stored URLs become valid
|
|
1700
2250
|
// again — the user can reload history days later and images still
|
|
@@ -1716,9 +2266,9 @@ export function createRequestHandler(deps) {
|
|
|
1716
2266
|
},
|
|
1717
2267
|
};
|
|
1718
2268
|
if (limit != null) {
|
|
1719
|
-
const total = store.getEventCount(
|
|
2269
|
+
const total = store.getEventCount(taskId, { excludeThinking });
|
|
1720
2270
|
const hasMore = events.length > 0
|
|
1721
|
-
? store.getEvents(
|
|
2271
|
+
? store.getEvents(taskId, {
|
|
1722
2272
|
excludeThinking,
|
|
1723
2273
|
beforeSeq: events[0].seq,
|
|
1724
2274
|
limit: 1,
|
|
@@ -1770,10 +2320,10 @@ export function createRequestHandler(deps) {
|
|
|
1770
2320
|
sseManager.writeHeartbeat(client);
|
|
1771
2321
|
return;
|
|
1772
2322
|
}
|
|
1773
|
-
// GET /api/v1/
|
|
1774
|
-
const
|
|
1775
|
-
if (
|
|
1776
|
-
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]);
|
|
1777
2327
|
let tokenName;
|
|
1778
2328
|
if (deps.authStore) {
|
|
1779
2329
|
const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
|
|
@@ -1786,9 +2336,9 @@ export function createRequestHandler(deps) {
|
|
|
1786
2336
|
}
|
|
1787
2337
|
tokenName = principal.tokenName;
|
|
1788
2338
|
}
|
|
1789
|
-
const
|
|
1790
|
-
if (!
|
|
1791
|
-
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" });
|
|
1792
2342
|
return;
|
|
1793
2343
|
}
|
|
1794
2344
|
const clientId = sseManager.generateClientId();
|
|
@@ -1800,7 +2350,7 @@ export function createRequestHandler(deps) {
|
|
|
1800
2350
|
const client = {
|
|
1801
2351
|
id: clientId,
|
|
1802
2352
|
res,
|
|
1803
|
-
|
|
2353
|
+
taskId,
|
|
1804
2354
|
tokenName,
|
|
1805
2355
|
};
|
|
1806
2356
|
sseManager.add(client);
|
|
@@ -1817,7 +2367,7 @@ export function createRequestHandler(deps) {
|
|
|
1817
2367
|
if (lastEventId) {
|
|
1818
2368
|
const afterSeq = parseInt(lastEventId, 10);
|
|
1819
2369
|
if (!isNaN(afterSeq)) {
|
|
1820
|
-
const events = store.getEvents(
|
|
2370
|
+
const events = store.getEvents(taskId, { afterSeq });
|
|
1821
2371
|
for (const evt of events) {
|
|
1822
2372
|
try {
|
|
1823
2373
|
sseManager.sendEvent(client, {
|
|
@@ -1833,19 +2383,19 @@ export function createRequestHandler(deps) {
|
|
|
1833
2383
|
}
|
|
1834
2384
|
return;
|
|
1835
2385
|
}
|
|
1836
|
-
// --- Attachments (
|
|
1837
|
-
// POST /api/v1/
|
|
2386
|
+
// --- Attachments (task-scoped) ---
|
|
2387
|
+
// POST /api/v1/tasks/:id/attachments — multipart/form-data upload.
|
|
1838
2388
|
//
|
|
1839
2389
|
// Wire format: a single `file` field. busboy streams chunks straight
|
|
1840
|
-
// to <data_dir>/
|
|
2390
|
+
// to <data_dir>/tasks/<sid>/attachments/<uuid>.<ext>.tmp; on close
|
|
1841
2391
|
// we atomic-rename to the final name and insert an attachments row.
|
|
1842
2392
|
// Aborts / mid-stream errors / oversize / wrong field name all leave
|
|
1843
2393
|
// the .tmp removed before responding.
|
|
1844
|
-
const imgUploadMatch = url.match(/^\/api\/v1\/
|
|
2394
|
+
const imgUploadMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/attachments\/?$/);
|
|
1845
2395
|
if (imgUploadMatch && req.method === "POST") {
|
|
1846
|
-
const
|
|
1847
|
-
if (!SAFE_ID.test(
|
|
1848
|
-
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" });
|
|
1849
2399
|
return;
|
|
1850
2400
|
}
|
|
1851
2401
|
const ctype = req.headers["content-type"] ?? "";
|
|
@@ -1855,16 +2405,16 @@ export function createRequestHandler(deps) {
|
|
|
1855
2405
|
});
|
|
1856
2406
|
return;
|
|
1857
2407
|
}
|
|
1858
|
-
await handleAttachmentUpload(req, res,
|
|
2408
|
+
await handleAttachmentUpload(req, res, taskId, deps);
|
|
1859
2409
|
return;
|
|
1860
2410
|
}
|
|
1861
|
-
// GET /api/v1/
|
|
2411
|
+
// GET /api/v1/tasks/:id/attachments/:file — serve a previously
|
|
1862
2412
|
// uploaded attachment. Mime + displayName are looked up from the
|
|
1863
2413
|
// attachments table so the response carries the original filename
|
|
1864
2414
|
// (RFC 5987) and the per-mime inline/attachment disposition.
|
|
1865
|
-
const imgGetMatch = url.match(/^\/api\/v1\/
|
|
2415
|
+
const imgGetMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
|
|
1866
2416
|
if (imgGetMatch && req.method === "GET") {
|
|
1867
|
-
const
|
|
2417
|
+
const taskId = decodeURIComponent(imgGetMatch[1]);
|
|
1868
2418
|
const file = decodeURIComponent(imgGetMatch[2]);
|
|
1869
2419
|
// When secret is configured, GET requires sig+exp in query — there
|
|
1870
2420
|
// is no Bearer fallback because <img src=...> / <a href=...> can't
|
|
@@ -1873,7 +2423,7 @@ export function createRequestHandler(deps) {
|
|
|
1873
2423
|
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
1874
2424
|
const sig = params.get("sig") ?? "";
|
|
1875
2425
|
const exp = params.get("exp") ?? "";
|
|
1876
|
-
const basePath = `/api/v1/
|
|
2426
|
+
const basePath = `/api/v1/tasks/${taskId}/attachments/${file}`;
|
|
1877
2427
|
if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
|
|
1878
2428
|
res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
|
|
1879
2429
|
"Content-Type": "application/json",
|
|
@@ -1882,8 +2432,8 @@ export function createRequestHandler(deps) {
|
|
|
1882
2432
|
return;
|
|
1883
2433
|
}
|
|
1884
2434
|
}
|
|
1885
|
-
const filePath = join(deps.dataDir, "
|
|
1886
|
-
if (!filePath.startsWith(join(deps.dataDir, "
|
|
2435
|
+
const filePath = join(deps.dataDir, "tasks", taskId, "attachments", file);
|
|
2436
|
+
if (!filePath.startsWith(join(deps.dataDir, "tasks"))) {
|
|
1887
2437
|
res.writeHead(HTTP_STATUS.FORBIDDEN);
|
|
1888
2438
|
res.end("Forbidden");
|
|
1889
2439
|
return;
|
|
@@ -1891,7 +2441,7 @@ export function createRequestHandler(deps) {
|
|
|
1891
2441
|
// Look up the row to recover the original mime + display name.
|
|
1892
2442
|
// Pre-attachments-table uploads (none in v0.4+) would miss here;
|
|
1893
2443
|
// we degrade gracefully to extension-based mime.
|
|
1894
|
-
const row = store.getAttachmentByFile(
|
|
2444
|
+
const row = store.getAttachmentByFile(taskId, file);
|
|
1895
2445
|
try {
|
|
1896
2446
|
const fileData = await readFile(filePath);
|
|
1897
2447
|
const mime = row?.mime ??
|
|
@@ -1918,7 +2468,7 @@ export function createRequestHandler(deps) {
|
|
|
1918
2468
|
// POST /api/v1/messages — create ingress message
|
|
1919
2469
|
if (url === "/api/v1/messages" && req.method === "POST") {
|
|
1920
2470
|
// client-server-split M2: idempotency for the ingress message
|
|
1921
|
-
// creator. /messages has no real
|
|
2471
|
+
// creator. /messages has no real task id, so we scope the
|
|
1922
2472
|
// cache under the synthetic key "__ingress__".
|
|
1923
2473
|
const { opId, replayed } = tryReplayClientOp(req, res, store, "__ingress__");
|
|
1924
2474
|
if (replayed)
|
|
@@ -1949,14 +2499,14 @@ export function createRequestHandler(deps) {
|
|
|
1949
2499
|
}
|
|
1950
2500
|
const input = validation.data;
|
|
1951
2501
|
const id = `msg-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1952
|
-
if (input.to.startsWith("
|
|
1953
|
-
const targetSid = input.to.slice("
|
|
1954
|
-
const
|
|
1955
|
-
if (!
|
|
1956
|
-
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" });
|
|
1957
2507
|
return;
|
|
1958
2508
|
}
|
|
1959
|
-
|
|
2509
|
+
tasks?.flushBuffers(targetSid);
|
|
1960
2510
|
const data = {
|
|
1961
2511
|
message_id: id,
|
|
1962
2512
|
from_ref: input.from_ref,
|
|
@@ -1970,7 +2520,7 @@ export function createRequestHandler(deps) {
|
|
|
1970
2520
|
});
|
|
1971
2521
|
sseManager.broadcast({
|
|
1972
2522
|
type: "message",
|
|
1973
|
-
|
|
2523
|
+
taskId: targetSid,
|
|
1974
2524
|
...data,
|
|
1975
2525
|
});
|
|
1976
2526
|
if (deps.pushService) {
|
|
@@ -1988,7 +2538,7 @@ export function createRequestHandler(deps) {
|
|
|
1988
2538
|
msg_id: id,
|
|
1989
2539
|
sess_id: targetSid.slice(0, 8),
|
|
1990
2540
|
});
|
|
1991
|
-
const boundBody = { id, delivered: "
|
|
2541
|
+
const boundBody = { id, delivered: "task" };
|
|
1992
2542
|
saveClientOpResult(store, opId, "__ingress__", HTTP_STATUS.OK, boundBody);
|
|
1993
2543
|
json(res, HTTP_STATUS.OK, boundBody);
|
|
1994
2544
|
return;
|
|
@@ -2052,19 +2602,19 @@ export function createRequestHandler(deps) {
|
|
|
2052
2602
|
const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
|
|
2053
2603
|
if (consumeMatch && req.method === "POST") {
|
|
2054
2604
|
const id = decodeURIComponent(consumeMatch[1]);
|
|
2055
|
-
let
|
|
2605
|
+
let inheritFromTaskId;
|
|
2056
2606
|
try {
|
|
2057
2607
|
const rawBody = await readBody(req);
|
|
2058
2608
|
if (rawBody) {
|
|
2059
2609
|
const body = JSON.parse(rawBody);
|
|
2060
|
-
if (body.
|
|
2061
|
-
typeof body.
|
|
2610
|
+
if (body.inheritFromTaskId !== undefined &&
|
|
2611
|
+
typeof body.inheritFromTaskId !== "string") {
|
|
2062
2612
|
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
2063
|
-
error: "
|
|
2613
|
+
error: "inheritFromTaskId must be a string",
|
|
2064
2614
|
});
|
|
2065
2615
|
return;
|
|
2066
2616
|
}
|
|
2067
|
-
|
|
2617
|
+
inheritFromTaskId = body.inheritFromTaskId;
|
|
2068
2618
|
}
|
|
2069
2619
|
}
|
|
2070
2620
|
catch {
|
|
@@ -2072,7 +2622,7 @@ export function createRequestHandler(deps) {
|
|
|
2072
2622
|
return;
|
|
2073
2623
|
}
|
|
2074
2624
|
const bridge = getBridge?.();
|
|
2075
|
-
if (!
|
|
2625
|
+
if (!tasks || !bridge) {
|
|
2076
2626
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
2077
2627
|
error: "Agent not available",
|
|
2078
2628
|
});
|
|
@@ -2080,10 +2630,10 @@ export function createRequestHandler(deps) {
|
|
|
2080
2630
|
}
|
|
2081
2631
|
let out;
|
|
2082
2632
|
try {
|
|
2083
|
-
out = await
|
|
2633
|
+
out = await tasks.consumeMessage(bridge, id, inheritFromTaskId);
|
|
2084
2634
|
}
|
|
2085
2635
|
catch (err) {
|
|
2086
|
-
if (err instanceof
|
|
2636
|
+
if (err instanceof InvalidTaskDirectoryError) {
|
|
2087
2637
|
json(res, HTTP_STATUS.BAD_REQUEST, {
|
|
2088
2638
|
error: err.message,
|
|
2089
2639
|
});
|
|
@@ -2101,7 +2651,7 @@ export function createRequestHandler(deps) {
|
|
|
2101
2651
|
sseManager.broadcast({
|
|
2102
2652
|
type: "message_consumed",
|
|
2103
2653
|
messageId: id,
|
|
2104
|
-
|
|
2654
|
+
taskId: out.taskId,
|
|
2105
2655
|
});
|
|
2106
2656
|
broadcastInboxCount(store, sseManager);
|
|
2107
2657
|
if (!out.alreadyConsumed && deps.pushService) {
|
|
@@ -2109,11 +2659,11 @@ export function createRequestHandler(deps) {
|
|
|
2109
2659
|
}
|
|
2110
2660
|
mlog.info("consume", {
|
|
2111
2661
|
msg_id: id,
|
|
2112
|
-
sess_id: out.
|
|
2662
|
+
sess_id: out.taskId.slice(0, 8),
|
|
2113
2663
|
already_consumed: out.alreadyConsumed,
|
|
2114
2664
|
});
|
|
2115
2665
|
json(res, HTTP_STATUS.OK, {
|
|
2116
|
-
|
|
2666
|
+
taskId: out.taskId,
|
|
2117
2667
|
alreadyConsumed: out.alreadyConsumed,
|
|
2118
2668
|
});
|
|
2119
2669
|
return;
|
|
@@ -2154,9 +2704,9 @@ export function createRequestHandler(deps) {
|
|
|
2154
2704
|
// --- Beta API routes ---
|
|
2155
2705
|
if (url.startsWith("/api/beta/")) {
|
|
2156
2706
|
res.setHeader("Content-Type", "application/json");
|
|
2157
|
-
// POST /api/beta/prompt — quick one-shot prompt (create temp
|
|
2707
|
+
// POST /api/beta/prompt — quick one-shot prompt (create temp task + send)
|
|
2158
2708
|
if (url === "/api/beta/prompt" && req.method === "POST") {
|
|
2159
|
-
if (!
|
|
2709
|
+
if (!tasks || !getBridge) {
|
|
2160
2710
|
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
2161
2711
|
error: "Agent not available",
|
|
2162
2712
|
});
|
|
@@ -2185,33 +2735,31 @@ export function createRequestHandler(deps) {
|
|
|
2185
2735
|
return;
|
|
2186
2736
|
}
|
|
2187
2737
|
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
|
|
2188
|
-
const {
|
|
2189
|
-
const streamUrl = `/api/v1/
|
|
2190
|
-
|
|
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);
|
|
2741
|
+
sseManager.broadcast({
|
|
2742
|
+
type: "task_created",
|
|
2743
|
+
taskId,
|
|
2744
|
+
cwd: task?.cwd,
|
|
2745
|
+
cwdDisplay: task?.cwd ? abbreviateHomePath(task.cwd) : undefined,
|
|
2746
|
+
title: task?.title,
|
|
2747
|
+
configOptions,
|
|
2748
|
+
agentCommands: tasks.getAgentCommands(taskId),
|
|
2749
|
+
});
|
|
2750
|
+
json(res, HTTP_STATUS.ACCEPTED, { taskId, streamUrl });
|
|
2191
2751
|
// Fire-and-forget: send the prompt asynchronously, tracking busy state
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
|
|
2196
|
-
titleService.generate(bridge, text, sessionId, (title) => {
|
|
2197
|
-
const titleEvent = {
|
|
2198
|
-
type: "session_title_updated",
|
|
2199
|
-
sessionId,
|
|
2200
|
-
title,
|
|
2201
|
-
};
|
|
2202
|
-
sseManager.broadcast(titleEvent);
|
|
2203
|
-
});
|
|
2204
|
-
}
|
|
2205
|
-
const betaPromptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
|
|
2206
|
-
undefined;
|
|
2752
|
+
tasks.activePrompts.add(taskId);
|
|
2753
|
+
tasks.syncBusy(taskId);
|
|
2754
|
+
const betaPromptId = tasks.state.getState(taskId).runtime.busy?.promptId ?? undefined;
|
|
2207
2755
|
bridge
|
|
2208
|
-
.prompt(
|
|
2756
|
+
.prompt(taskId, text, undefined, betaPromptId)
|
|
2209
2757
|
.catch(() => { })
|
|
2210
2758
|
.finally(() => {
|
|
2211
|
-
if (!
|
|
2759
|
+
if (!tasks.isCurrentPrompt(taskId, betaPromptId))
|
|
2212
2760
|
return;
|
|
2213
|
-
|
|
2214
|
-
|
|
2761
|
+
tasks.activePrompts.delete(taskId);
|
|
2762
|
+
tasks.syncBusy(taskId);
|
|
2215
2763
|
});
|
|
2216
2764
|
return;
|
|
2217
2765
|
}
|
|
@@ -2244,23 +2792,22 @@ export function createRequestHandler(deps) {
|
|
|
2244
2792
|
});
|
|
2245
2793
|
return;
|
|
2246
2794
|
}
|
|
2247
|
-
//
|
|
2795
|
+
// taskId patch semantics: absent = preserve, null = clear,
|
|
2248
2796
|
// string = replace. Zod can't distinguish omitted from explicit
|
|
2249
2797
|
// null after parse, so branch on raw body key.
|
|
2250
|
-
const
|
|
2251
|
-
let
|
|
2252
|
-
if (!
|
|
2253
|
-
|
|
2798
|
+
const hasTaskIdKey = Object.prototype.hasOwnProperty.call(body, "taskId");
|
|
2799
|
+
let taskIdPatch;
|
|
2800
|
+
if (!hasTaskIdKey) {
|
|
2801
|
+
taskIdPatch = undefined;
|
|
2254
2802
|
}
|
|
2255
|
-
else if (body.
|
|
2256
|
-
|
|
2803
|
+
else if (body.taskId === null) {
|
|
2804
|
+
taskIdPatch = null;
|
|
2257
2805
|
}
|
|
2258
|
-
else if (typeof body.
|
|
2259
|
-
body.
|
|
2260
|
-
sessionIdPatch = body.sessionId;
|
|
2806
|
+
else if (typeof body.taskId === "string" && body.taskId.length > 0) {
|
|
2807
|
+
taskIdPatch = body.taskId;
|
|
2261
2808
|
}
|
|
2262
2809
|
else {
|
|
2263
|
-
|
|
2810
|
+
taskIdPatch = null;
|
|
2264
2811
|
}
|
|
2265
2812
|
if (deps.clientRegistry) {
|
|
2266
2813
|
// setVisibility no-ops on unknown clients; auto-register here so
|
|
@@ -2271,17 +2818,17 @@ export function createRequestHandler(deps) {
|
|
|
2271
2818
|
}
|
|
2272
2819
|
const { becameVisibleFor } = deps.clientRegistry.setVisibility(clientId, {
|
|
2273
2820
|
visible: body.visible,
|
|
2274
|
-
active:
|
|
2821
|
+
active: taskIdPatch,
|
|
2275
2822
|
});
|
|
2276
2823
|
// Edge-triggered only: heartbeat refreshes repeat the same
|
|
2277
|
-
// (visible:true,
|
|
2824
|
+
// (visible:true, taskId:X) POST every 15s — firing sendClose
|
|
2278
2825
|
// on each would hammer banner recall. Only the first such
|
|
2279
2826
|
// transition after a change should recall stale banners.
|
|
2280
2827
|
if (becameVisibleFor && deps.pushService) {
|
|
2281
2828
|
void deps.pushService.sendClose(`sess-${becameVisibleFor}-done`);
|
|
2282
|
-
if (
|
|
2283
|
-
for (const perm of
|
|
2284
|
-
if (perm.
|
|
2829
|
+
if (tasks) {
|
|
2830
|
+
for (const perm of tasks.pendingPermissions.values()) {
|
|
2831
|
+
if (perm.taskId === becameVisibleFor) {
|
|
2285
2832
|
void deps.pushService.sendClose(`sess-${becameVisibleFor}-perm-${perm.requestId}`);
|
|
2286
2833
|
}
|
|
2287
2834
|
}
|