@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.
Files changed (56) hide show
  1. package/README.md +43 -15
  2. package/config.toml +7 -27
  3. package/dist/index.html +21 -5
  4. package/dist/js/app.INIQQEGD.js +5 -0
  5. package/dist/js/chunk.3CLGCUHW.js +1 -0
  6. package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
  7. package/dist/js/chunk.AOTG3PL7.js +20 -0
  8. package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
  9. package/dist/js/viewer.RHZMFYWJ.js +1 -0
  10. package/dist/login.html +2 -2
  11. package/dist/share-viewer.html +6 -6
  12. package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
  13. package/dist/sw.js +6 -6
  14. package/lib/agent-key.js +6 -0
  15. package/lib/attachment-dispatch.js +60 -31
  16. package/lib/attachment-interceptor.js +7 -7
  17. package/lib/attachment-labels.js +1 -1
  18. package/lib/attachments.js +69 -7
  19. package/lib/auth-middleware.js +11 -4
  20. package/lib/auth.js +2 -2
  21. package/lib/bridge.js +209 -90
  22. package/lib/client-registry.js +12 -12
  23. package/lib/config.js +2 -31
  24. package/lib/event-handler.js +166 -85
  25. package/lib/files/limits.js +15 -0
  26. package/lib/files/paths.js +155 -0
  27. package/lib/files/routes.js +232 -0
  28. package/lib/home-path.js +35 -0
  29. package/lib/http-status.js +1 -0
  30. package/lib/mcp/capability.js +74 -0
  31. package/lib/mcp/server.js +148 -0
  32. package/lib/mcp/task-history.js +245 -0
  33. package/lib/mcp/task-host.js +253 -0
  34. package/lib/mcp/tools.js +168 -0
  35. package/lib/mode-bucket.js +1 -1
  36. package/lib/push-service.js +33 -35
  37. package/lib/routes.js +1022 -475
  38. package/lib/server.js +84 -34
  39. package/lib/share/routes.js +97 -85
  40. package/lib/shared/task-reference.js +20 -0
  41. package/lib/sse-manager.js +8 -8
  42. package/lib/store.js +992 -284
  43. package/lib/task-collaboration.js +15 -0
  44. package/lib/task-manager.js +1409 -0
  45. package/lib/task-path.js +131 -0
  46. package/lib/{session-state.js → task-state.js} +90 -38
  47. package/lib/task-tree-lock.js +74 -0
  48. package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
  49. package/lib/tokens.js +1 -1
  50. package/lib/types.js +2 -2
  51. package/package.json +8 -1
  52. package/dist/js/app.XBFXH37R.js +0 -2
  53. package/dist/js/chunk.UMQMOGWO.js +0 -1
  54. package/dist/js/viewer.CVWXSKJM.js +0 -1
  55. package/lib/session-manager.js +0 -613
  56. 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, InvalidSessionDirectoryError, } from "./session-manager.js";
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("session");
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
- sessionId: fields.sessionId.slice(0, 8),
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, sessionId) {
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(sessionId, opId);
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, sessionId, status, body) {
174
+ function saveClientOpResult(store, opId, taskId, status, body) {
137
175
  if (!opId)
138
176
  return;
139
- store.saveClientOp(sessionId, opId, { status, body });
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>/sessions/<sid>/attachments/<uuid>.<ext>.tmp, atomic-renames on
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, sessionId, deps) {
180
- const { store, dataDir, limits, sessions } = deps;
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.getSession(sessionId)) {
183
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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, "sessions", sessionId, "attachments");
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
- sessionId,
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-session attachment label cache so the
416
+ // Invalidate the per-task attachment label cache so the
371
417
  // next egress (SSE broadcast or replay) sees this new row.
372
- sessions?.invalidateLabelCache(sessionId);
418
+ tasks?.invalidateLabelCache(taskId);
373
419
  const fileName = `${row.id}.${fileExt}`;
374
- const basePath = `/api/v1/sessions/${sessionId}/attachments/${fileName}`;
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 session lifetime, short enough
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: `sessions/${sessionId}/attachments/${fileName}`,
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, sessions, getBridge, sseManager, titleService } = deps;
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/sessions/:id/share*, /api/v1/shares); viewer endpoints
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
- sessions,
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
- sessions: "/api/v1/sessions",
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/sessions
463
- if (url.startsWith("/api/v1/sessions") &&
464
- !url.slice("/api/v1/sessions".length).match(/^\//) &&
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
- res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
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: sessions?.cachedConfigOptions ?? [],
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: sessions?.agentInfo ?? null,
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(sessions, titleService);
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 (session-scoped) ---
660
- // GET /api/v1/sessions/:id/permissions
661
- const permListMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/permissions\/?(\?.*)?$/);
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 sessionId = decodeURIComponent(permListMatch[1]);
664
- const perms = sessions?.getPendingPermissions(sessionId) ?? [];
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/sessions/:id/permissions/:reqId
669
- const permActionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/permissions\/([^/?]+)\/?$/);
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 sessionId = decodeURIComponent(permActionMatch[1]);
746
+ const taskId = decodeURIComponent(permActionMatch[1]);
672
747
  const requestId = decodeURIComponent(permActionMatch[2]);
673
- const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
748
+ const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
674
749
  if (replayed)
675
750
  return;
676
- const perm = sessions?.pendingPermissions.get(requestId);
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.sessionId !== sessionId) {
682
- json(res, HTTP_STATUS.BAD_REQUEST, { error: "Session ID mismatch" });
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
- sessions.pendingPermissions.delete(requestId);
716
- sessions.syncPendingPermissions(sessionId);
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.sessionId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
794
+ store.saveEvent(perm.taskId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
720
795
  sseManager.broadcast({
721
796
  type: "permission_response",
722
- sessionId: perm.sessionId,
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.sessionId}-perm-${requestId}`);
804
+ void deps.pushService.sendClose(`sess-${perm.taskId}-perm-${requestId}`);
730
805
  }
731
806
  const okBody = { ok: true };
732
- saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, okBody);
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/sessions/:id/cancel ---
737
- const cancelMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/cancel\/?$/);
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 sessionId = decodeURIComponent(cancelMatch[1]);
740
- const session = store.getSession(sessionId);
741
- if (!session) {
742
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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, sessionId);
820
+ const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
746
821
  if (replayed)
747
822
  return;
748
- const hadAgentPrompt = sessions?.activePrompts.has(sessionId) ?? false;
749
- const hadPendingPrompt = sessions?.cancelPendingPromptSubmission(sessionId) ?? false;
750
- const hadBash = sessions?.runningBashProcs.has(sessionId) ?? false;
751
- if (!hadAgentPrompt && !hadPendingPrompt && !hadBash) {
823
+ if (!tasks) {
752
824
  const idleBody = { ok: true, status: "idle" };
753
- saveClientOpResult(store, opId, sessionId, HTTP_STATUS.OK, idleBody);
825
+ saveClientOpResult(store, opId, taskId, HTTP_STATUS.OK, idleBody);
754
826
  json(res, HTTP_STATUS.OK, idleBody);
755
827
  return;
756
828
  }
757
- const cancelledPromptId = hadAgentPrompt
758
- ? (sessions?.state.getState(sessionId).runtime.busy?.promptId ?? null)
829
+ const bridge = tasks.activePrompts.has(taskId)
830
+ ? (getBridge?.() ?? null)
759
831
  : null;
760
- // Kill running bash process if any
761
- const proc = sessions?.runningBashProcs.get(sessionId);
762
- if (proc) {
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
- // ACP cancel is a notification, not an acknowledgement. Keep the
775
- // prompt active until its prompt response supplies the terminal stop
776
- // reason, and allow repeated requests to resend the notification.
777
- if (hadAgentPrompt && sessions && bridge) {
778
- const previousCancelStatus = sessions.state.getState(sessionId).runtime.busy?.cancelStatus ??
779
- null;
780
- rlog.info("cancel requested", {
781
- sessionId: sessionId.slice(0, 8),
782
- retry: previousCancelStatus !== null,
783
- previousStatus: previousCancelStatus,
784
- });
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
- // If prompt_done does not arrive, expose the lack of acknowledgement
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
- ok: true,
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/sessions/:id/status ---
827
- const statusMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/status\/?$/);
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 sessionId = decodeURIComponent(statusMatch[1]);
830
- const session = store.getSession(sessionId);
831
- if (!session) {
832
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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 = sessions?.getBusyKind(sessionId) ?? null;
836
- const pendingPerms = sessions?.getPendingPermissions(sessionId) ?? [];
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/sessions/:id/snapshot ---
871
+ // --- GET /api/v1/tasks/:id/snapshot ---
845
872
  // client-server-split M1: single source of truth for "what state is
846
- // this session in right now". Frontend calls this on connect / reconnect
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\/sessions\/([^/]+)\/snapshot\/?$/);
876
+ const snapshotMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/snapshot\/?$/);
850
877
  if (snapshotMatch && req.method === "GET") {
851
- const sessionId = decodeURIComponent(snapshotMatch[1]);
852
- const session = store.getSession(sessionId);
853
- if (!session) {
854
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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 (!sessions) {
884
+ if (!tasks) {
858
885
  json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
859
- error: "Session manager not available",
886
+ error: "Task manager not available",
860
887
  });
861
888
  return;
862
889
  }
863
890
  const bridge = getBridge?.();
864
- if (bridge && !sessions.liveSessions.has(sessionId)) {
891
+ if (bridge && !tasks.liveTasks.has(taskId)) {
865
892
  try {
866
- // Command discovery happens during session/load. Snapshot is the
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-session command state.
869
- await sessions.ensureResumed(bridge, sessionId);
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 session",
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 session.
880
- sessions.syncBusy(sessionId);
881
- sessions.syncPendingPermissions(sessionId);
882
- const runtimeState = sessions.state.getState(sessionId);
883
- const lastEventSeq = store.getLastEventSeq(sessionId);
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
- session: {
888
- id: session.id,
889
- title: session.title,
890
- cwd: session.cwd,
891
- model: session.model,
892
- mode: session.mode,
893
- createdAt: session.created_at,
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: sessions.getAgentCommands(sessionId),
925
+ agentCommands: tasks.getAgentCommands(taskId),
898
926
  }, req);
899
927
  return;
900
928
  }
901
- // --- POST /api/v1/sessions/:id/prompt ---
902
- const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
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 sessionId = decodeURIComponent(promptMatch[1]);
1032
+ const taskId = decodeURIComponent(promptMatch[1]);
905
1033
  const requestOpId = getClientOpId(req);
906
- const session = store.getSession(sessionId);
907
- if (!session) {
1034
+ const task = store.getTask(taskId);
1035
+ if (!task) {
908
1036
  logPromptRejectBeforeSave({
909
- sessionId,
1037
+ taskId,
910
1038
  status: HTTP_STATUS.NOT_FOUND,
911
- reason: "session_not_found",
1039
+ reason: "task_not_found",
912
1040
  opId: requestOpId,
913
1041
  });
914
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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
- sessionId,
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 (!sessions) {
1058
+ if (!tasks) {
931
1059
  logPromptRejectBeforeSave({
932
- sessionId,
1060
+ taskId,
933
1061
  status: HTTP_STATUS.SERVICE_UNAVAILABLE,
934
- reason: "session_manager_unavailable",
1062
+ reason: "task_manager_unavailable",
935
1063
  opId: requestOpId,
936
1064
  });
937
1065
  json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
938
- error: "Session manager not available",
1066
+ error: "Task manager not available",
939
1067
  });
940
1068
  return;
941
1069
  }
942
- const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
1070
+ const { opId, replayed } = tryReplayClientOp(req, res, store, taskId);
943
1071
  if (replayed)
944
1072
  return;
945
- const promptSubmissionId = sessions.reservePromptSubmission(sessionId);
1073
+ const promptSubmissionId = tasks.reservePromptSubmission(taskId);
946
1074
  if (promptSubmissionId === null) {
947
- const busyKind = sessions.getBusyKind(sessionId);
1075
+ const busyKind = tasks.getBusyKind(taskId);
948
1076
  logPromptRejectBeforeSave({
949
- sessionId,
1077
+ taskId,
950
1078
  status: HTTP_STATUS.CONFLICT,
951
- reason: "session_busy",
1079
+ reason: "task_busy",
952
1080
  opId,
953
1081
  busyKind: busyKind ?? undefined,
954
1082
  });
955
1083
  json(res, HTTP_STATUS.CONFLICT, {
956
- error: "Session is busy",
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
- sessions.releasePromptSubmission(sessionId, promptSubmissionId);
1093
+ tasks.releasePromptSubmission(taskId, promptSubmissionId);
966
1094
  };
967
1095
  res.once("finish", () => {
968
- sessions.releasePromptSubmission(sessionId, promptSubmissionId);
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 session is live in ACP before prompting (awaits in-flight resume)
1103
+ // Ensure task is live in ACP before prompting (awaits in-flight resume)
976
1104
  try {
977
- await sessions.ensureResumed(bridge, sessionId);
1105
+ await tasks.ensureResumed(bridge, taskId);
978
1106
  }
979
1107
  catch (err) {
980
1108
  if (isRequestAborted())
981
1109
  return;
982
1110
  logPromptRejectBeforeSave({
983
- sessionId,
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 session: ${err instanceof Error ? err.message : String(err)}`,
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
- sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
1123
+ tasks.isPromptSubmissionCancelled(promptSubmissionId)) {
996
1124
  logPromptRejectBeforeSave({
997
- sessionId,
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
- sessionId,
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
- sessions.isPromptSubmissionCancelled(promptSubmissionId)) {
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
- sessionId,
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
- sessionId,
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
- sessionId,
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
- sessionId,
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, sessions.getAgentCommands(sessionId).commands);
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
- sessionId,
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/sessions/<sid>/attachments/<filename>`); reSign on
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(sessionId, a.attachmentId);
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/sessions/${sessionId}/attachments/${fileName}`,
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(sessionId, "user_message", {
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.updateSessionLastActive(sessionId);
1161
- store.touchRecentPath(session.cwd);
1297
+ store.updateTaskLastActive(taskId);
1298
+ store.touchRecentPath(task.cwd);
1162
1299
  const userMsgEvent = {
1163
1300
  type: "user_message",
1164
- sessionId,
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
- sessions.releasePromptSubmission(sessionId, promptSubmissionId, false);
1185
- sessions.activePrompts.add(sessionId);
1186
- sessions.syncBusy(sessionId);
1187
- const promptId = sessions.state.getState(sessionId).runtime.busy?.promptId ??
1188
- undefined;
1189
- bridge
1190
- .prompt(sessionId, agentText, attachments, promptId)
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", { sessionId, error: err });
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 (!sessions.isCurrentPrompt(sessionId, promptId))
1328
+ if (!tasks.isCurrentPrompt(taskId, promptId))
1198
1329
  return;
1199
- sessions.activePrompts.delete(sessionId);
1200
- sessions.syncBusy(sessionId);
1330
+ tasks.activePrompts.delete(taskId);
1331
+ tasks.syncBusy(taskId);
1201
1332
  });
1202
1333
  const acceptedBody = { status: "accepted" };
1203
- saveClientOpResult(store, opId, sessionId, HTTP_STATUS.ACCEPTED, acceptedBody);
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/sessions/:id/bash ---
1208
- const bashMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/bash\/?$/);
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 sessionId = decodeURIComponent(bashMatch[1]);
1211
- const session = store.getSession(sessionId);
1212
- if (!session) {
1213
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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 (!sessions) {
1347
+ if (!tasks) {
1217
1348
  json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1218
- error: "Session manager not available",
1349
+ error: "Task manager not available",
1219
1350
  });
1220
1351
  return;
1221
1352
  }
1222
- if (sessions.runningBashProcs.has(sessionId)) {
1353
+ if (tasks.runningBashProcs.has(taskId)) {
1223
1354
  json(res, HTTP_STATUS.CONFLICT, {
1224
- error: "A bash command is already running in this session",
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 = sessions.getSessionCwd(sessionId);
1243
- store.saveEvent(sessionId, "bash_command", { command: body.command }, { from_ref: "user" });
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
- sessionId,
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
- sessions.runningBashProcs.set(sessionId, child);
1263
- sessions.syncBusy(sessionId);
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
- sessionId,
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
- sessions.runningBashProcs.delete(sessionId);
1291
- sessions.syncBusy(sessionId);
1421
+ tasks.runningBashProcs.delete(taskId);
1422
+ tasks.syncBusy(taskId);
1292
1423
  const stored = outputTruncated ? "[truncated]\n" + output : output;
1293
- store.saveEvent(sessionId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
1424
+ store.saveEvent(taskId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
1294
1425
  const bashDoneEvent = {
1295
1426
  type: "bash_done",
1296
- sessionId,
1427
+ taskId,
1297
1428
  code,
1298
1429
  signal,
1299
1430
  };
1300
1431
  sseManager.broadcast(bashDoneEvent);
1301
1432
  });
1302
1433
  child.on("error", (err) => {
1303
- sessions.runningBashProcs.delete(sessionId);
1304
- sessions.syncBusy(sessionId);
1434
+ tasks.runningBashProcs.delete(taskId);
1435
+ tasks.syncBusy(taskId);
1305
1436
  const errMsg = errorMessage(err);
1306
- store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null }, { from_ref: "system" });
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
- sessionId,
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/sessions/:id/bash/cancel ---
1320
- const bashCancelMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/bash\/cancel\/?$/);
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 sessionId = decodeURIComponent(bashCancelMatch[1]);
1323
- const session = store.getSession(sessionId);
1324
- if (!session) {
1325
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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(sessions?.runningBashProcs.get(sessionId));
1459
+ interruptBashProc(tasks?.runningBashProcs.get(taskId));
1329
1460
  json(res, HTTP_STATUS.OK, { ok: true });
1330
1461
  return;
1331
1462
  }
1332
- // --- PUT /api/v1/sessions/:id/{model,mode,reasoning-effort} ---
1333
- // --- PUT /api/v1/sessions/:id/config/:configId ---
1334
- const legacyConfigPutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/(model|mode|reasoning-effort)\/?$/);
1335
- const genericConfigPutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/config\/([^/]+)\/?$/);
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 sessionId = decodeURIComponent(configPutMatch[1]);
1469
+ const taskId = decodeURIComponent(configPutMatch[1]);
1339
1470
  const configPath = decodeURIComponent(configPutMatch[2]);
1340
1471
  const configId = configPath.replace(/-/g, "_");
1341
- const session = store.getSession(sessionId);
1342
- if (!session) {
1343
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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(sessionId, configId, body.value);
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.updateSessionConfig(sessionId, opt.id, opt.currentValue);
1502
+ store.updateTaskConfig(taskId, opt.id, opt.currentValue);
1372
1503
  }
1373
1504
  }
1374
1505
  sseManager.broadcast({
1375
1506
  type: "config_option_update",
1376
- sessionId,
1507
+ taskId,
1377
1508
  configOptions,
1378
1509
  });
1379
1510
  sseManager.broadcast({
1380
1511
  type: "config_set",
1381
- sessionId,
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/sessions/:id/title ---
1395
- const titlePutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/title\/?$/);
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 sessionId = decodeURIComponent(titlePutMatch[1]);
1398
- const session = store.getSession(sessionId);
1399
- if (!session) {
1400
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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
- store.updateSessionTitle(sessionId, body.value);
1418
- if (sessions)
1419
- sessions.sessionHasTitle.add(sessionId);
1420
- const bridge = getBridge?.();
1421
- if (titleService && bridge)
1422
- void titleService.cancel(sessionId, bridge);
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: "session_title_updated",
1425
- sessionId,
1426
- title: body.value,
1571
+ type: "task_title_updated",
1572
+ taskId,
1573
+ title,
1427
1574
  };
1428
1575
  sseManager.broadcast(titleEvent);
1429
- json(res, HTTP_STATUS.OK, { title: body.value });
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
- // --- Session CRUD: /api/v1/sessions/:id ---
1433
- const sessionIdMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/?(\?.*)?$/);
1434
- if (sessionIdMatch) {
1435
- const sessionId = decodeURIComponent(sessionIdMatch[1]);
1436
- // POST /api/v1/sessions (create) — handled below since :id would match "sessions" literally
1437
- // This match is for /api/v1/sessions/:id only (not /api/sessions)
1438
- // GET /api/v1/sessions/:id
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 session = store.getSession(sessionId);
1441
- if (!session) {
1442
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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 = sessions?.liveSessions.has(sessionId) ?? true;
1880
+ const wasLive = tasks?.liveTasks.has(taskId) ?? true;
1451
1881
  let resumePromise = null;
1452
- if (sessions && getBridge && !wasLive) {
1882
+ if (tasks && getBridge && !wasLive) {
1453
1883
  const bridge = getBridge();
1454
1884
  if (bridge) {
1455
- resumePromise = sessions.ensureResumed(bridge, sessionId);
1885
+ resumePromise = tasks.ensureResumed(bridge, taskId);
1456
1886
  // Broadcast config_option_update too, so any OTHER client viewing
1457
- // the same session (whose own GET may have raced and returned cold)
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.getSession(sessionId);
1462
- if (!cur || !sessions.cachedConfigOptions.length)
1891
+ const cur = store.getTask(taskId);
1892
+ if (!cur || !tasks.cachedConfigOptions.length)
1463
1893
  return;
1464
- const opts = sessions.cachedConfigOptions.map((opt) => {
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
- sessionId,
1907
+ taskId,
1478
1908
  configOptions: opts,
1479
1909
  });
1480
1910
  })
1481
1911
  .catch(() => { });
1482
- // Auto-retry if the last turn was interrupted (must wait for resume)
1483
- const hasInterrupted = store.hasInterruptedTurn(sessionId);
1484
- if (hasInterrupted) {
1485
- // Optimistically mark busy so concurrent POST sees the session as active
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 && !sessions?.cachedConfigOptions.length) {
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 session in case resume mutated stored config
1529
- const freshSession = store.getSession(sessionId) ?? session;
1530
- const configOptions = sessions
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 = sessions.cachedConfigOptions.map((opt) => {
1942
+ const opts = tasks.cachedConfigOptions.map((opt) => {
1534
1943
  const stored = {
1535
- model: freshSession.model,
1536
- mode: freshSession.mode,
1537
- reasoning_effort: freshSession.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: freshSession.id,
1549
- cwd: freshSession.cwd,
1550
- title: freshSession.title,
1551
- source: freshSession.source,
1552
- model: freshSession.model,
1553
- mode: freshSession.mode,
1957
+ id: freshTask.id,
1958
+ cwd: freshTask.cwd,
1959
+ cwdDisplay: abbreviateHomePath(freshTask.cwd),
1960
+ title: freshTask.title,
1961
+ source: freshTask.source,
1962
+ model: freshTask.model,
1963
+ mode: freshTask.mode,
1964
+ parentId: freshTask.parent_id,
1554
1965
  configOptions,
1555
1966
  }, req);
1556
1967
  return;
1557
1968
  }
1558
- // DELETE /api/v1/sessions/:id
1969
+ // DELETE /api/v1/tasks/:id
1559
1970
  if (req.method === "DELETE") {
1560
- const session = store.getSession(sessionId);
1561
- if (!session) {
1562
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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 (sessions && sessions.getBusyKind(sessionId) !== null) {
1977
+ if (tasks && tasks.getBusyKind(taskId) !== null) {
1978
+ const busyPath = store.getTaskPath(taskId) ?? taskId;
1566
1979
  json(res, HTTP_STATUS.CONFLICT, {
1567
- error: "Cancel active work before deleting the session",
1980
+ error: `Cancel active work in ${busyPath} before deleting this task`,
1568
1981
  });
1569
1982
  return;
1570
1983
  }
1571
- if (sessions) {
1572
- sessions.deleteSession(sessionId);
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
- else {
1575
- store.deleteSession(sessionId);
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/sessions (create new session)
1584
- if (url === "/api/v1/sessions" && req.method === "POST") {
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 (!sessions) {
2072
+ if (!tasks) {
1594
2073
  json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
1595
- error: "Session manager not available",
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 { sessionId, configOptions } = await sessions.createSession(bridge, body.cwd, body.inheritFromSessionId, source);
1610
- const session = store.getSession(sessionId);
1611
- const sessionCreatedEvent = {
1612
- type: "session_created",
1613
- sessionId,
1614
- cwd: session?.cwd,
1615
- title: session?.title,
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: sessions.getAgentCommands(sessionId),
2147
+ agentCommands: tasks.getAgentCommands(taskId),
1618
2148
  clientOpId: clientOpId ?? undefined,
1619
2149
  };
1620
- sseManager.broadcast(sessionCreatedEvent);
1621
- // ACP's session_created event fires before inheritance runs, so
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
- sessionId,
2156
+ taskId,
1627
2157
  configOptions,
1628
2158
  });
1629
2159
  }
1630
2160
  json(res, HTTP_STATUS.CREATED, {
1631
- id: sessionId,
1632
- cwd: session?.cwd ?? body.cwd,
1633
- title: session?.title ?? null,
1634
- source: session?.source ?? source,
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: sessions.getAgentCommands(sessionId),
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 InvalidSessionDirectoryError) {
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/sessions/:id/events?thinking=0|1&limit=N&before=SEQ&after=SEQ
1652
- const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
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 sessionId = decodeURIComponent(eventsMatch[1]);
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 session = store.getSession(sessionId);
1667
- if (!session) {
1668
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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 (sessions) {
1677
- const runtimeStreaming = sessions.state.peekStreaming(sessionId);
2226
+ if (tasks) {
2227
+ const runtimeStreaming = tasks.state.peekStreaming(taskId);
1678
2228
  streamingThinking =
1679
2229
  runtimeStreaming.thinking ||
1680
- Boolean(sessions.thinkingBuffers.get(sessionId));
2230
+ Boolean(tasks.thinkingBuffers.get(taskId));
1681
2231
  streamingAssistant =
1682
2232
  runtimeStreaming.assistant ||
1683
- Boolean(sessions.assistantBuffers.get(sessionId));
1684
- sessions.flushThinkingBuffer(sessionId);
1685
- sessions.flushAssistantBuffer(sessionId);
2233
+ Boolean(tasks.assistantBuffers.get(taskId));
2234
+ tasks.flushThinkingBuffer(taskId);
2235
+ tasks.flushAssistantBuffer(taskId);
1686
2236
  }
1687
- const events = store.getEvents(sessionId, {
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 (sessions) {
1697
- enrichStoredEventsForDisplay(events, sessions.getLabelMap(sessionId));
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(sessionId, { excludeThinking });
2269
+ const total = store.getEventCount(taskId, { excludeThinking });
1720
2270
  const hasMore = events.length > 0
1721
- ? store.getEvents(sessionId, {
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/sessions/:id/events/stream — per-session SSE stream
1774
- const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
1775
- if (sseSessionMatch && req.method === "GET") {
1776
- const sessionId = decodeURIComponent(sseSessionMatch[1]);
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 session = store.getSession(sessionId);
1790
- if (!session) {
1791
- json(res, HTTP_STATUS.NOT_FOUND, { error: "Session not found" });
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
- sessionId,
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(sessionId, { afterSeq });
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 (session-scoped) ---
1837
- // POST /api/v1/sessions/:id/attachments — multipart/form-data upload.
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>/sessions/<sid>/attachments/<uuid>.<ext>.tmp; on close
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\/sessions\/([^/]+)\/attachments\/?$/);
2394
+ const imgUploadMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/attachments\/?$/);
1845
2395
  if (imgUploadMatch && req.method === "POST") {
1846
- const sessionId = decodeURIComponent(imgUploadMatch[1]);
1847
- if (!SAFE_ID.test(sessionId)) {
1848
- json(res, HTTP_STATUS.BAD_REQUEST, { error: "Invalid session ID" });
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, sessionId, deps);
2408
+ await handleAttachmentUpload(req, res, taskId, deps);
1859
2409
  return;
1860
2410
  }
1861
- // GET /api/v1/sessions/:id/attachments/:file — serve a previously
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\/sessions\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
2415
+ const imgGetMatch = url.match(/^\/api\/v1\/tasks\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
1866
2416
  if (imgGetMatch && req.method === "GET") {
1867
- const sessionId = decodeURIComponent(imgGetMatch[1]);
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/sessions/${sessionId}/attachments/${file}`;
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, "sessions", sessionId, "attachments", file);
1886
- if (!filePath.startsWith(join(deps.dataDir, "sessions"))) {
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(sessionId, file);
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 session id, so we scope the
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("session:")) {
1953
- const targetSid = input.to.slice("session:".length);
1954
- const session = store.getSession(targetSid);
1955
- if (!session) {
1956
- json(res, HTTP_STATUS.BAD_REQUEST, { error: "session_not_found" });
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
- sessions?.flushBuffers(targetSid);
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
- sessionId: targetSid,
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: "session" };
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 inheritFromSessionId;
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.inheritFromSessionId !== undefined &&
2061
- typeof body.inheritFromSessionId !== "string") {
2610
+ if (body.inheritFromTaskId !== undefined &&
2611
+ typeof body.inheritFromTaskId !== "string") {
2062
2612
  json(res, HTTP_STATUS.BAD_REQUEST, {
2063
- error: "inheritFromSessionId must be a string",
2613
+ error: "inheritFromTaskId must be a string",
2064
2614
  });
2065
2615
  return;
2066
2616
  }
2067
- inheritFromSessionId = body.inheritFromSessionId;
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 (!sessions || !bridge) {
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 sessions.consumeMessage(bridge, id, inheritFromSessionId);
2633
+ out = await tasks.consumeMessage(bridge, id, inheritFromTaskId);
2084
2634
  }
2085
2635
  catch (err) {
2086
- if (err instanceof InvalidSessionDirectoryError) {
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
- sessionId: out.sessionId,
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.sessionId.slice(0, 8),
2662
+ sess_id: out.taskId.slice(0, 8),
2113
2663
  already_consumed: out.alreadyConsumed,
2114
2664
  });
2115
2665
  json(res, HTTP_STATUS.OK, {
2116
- sessionId: out.sessionId,
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 session + send)
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 (!sessions || !getBridge) {
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 { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
2189
- const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
2190
- json(res, HTTP_STATUS.ACCEPTED, { sessionId, streamUrl });
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
- sessions.activePrompts.add(sessionId);
2193
- sessions.syncBusy(sessionId);
2194
- // Generate title (fire-and-forget)
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(sessionId, text, undefined, betaPromptId)
2756
+ .prompt(taskId, text, undefined, betaPromptId)
2209
2757
  .catch(() => { })
2210
2758
  .finally(() => {
2211
- if (!sessions.isCurrentPrompt(sessionId, betaPromptId))
2759
+ if (!tasks.isCurrentPrompt(taskId, betaPromptId))
2212
2760
  return;
2213
- sessions.activePrompts.delete(sessionId);
2214
- sessions.syncBusy(sessionId);
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
- // sessionId patch semantics: absent = preserve, null = clear,
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 hasSessionIdKey = Object.prototype.hasOwnProperty.call(body, "sessionId");
2251
- let sessionIdPatch;
2252
- if (!hasSessionIdKey) {
2253
- sessionIdPatch = undefined;
2798
+ const hasTaskIdKey = Object.prototype.hasOwnProperty.call(body, "taskId");
2799
+ let taskIdPatch;
2800
+ if (!hasTaskIdKey) {
2801
+ taskIdPatch = undefined;
2254
2802
  }
2255
- else if (body.sessionId === null) {
2256
- sessionIdPatch = null;
2803
+ else if (body.taskId === null) {
2804
+ taskIdPatch = null;
2257
2805
  }
2258
- else if (typeof body.sessionId === "string" &&
2259
- body.sessionId.length > 0) {
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
- sessionIdPatch = null;
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: sessionIdPatch,
2821
+ active: taskIdPatch,
2275
2822
  });
2276
2823
  // Edge-triggered only: heartbeat refreshes repeat the same
2277
- // (visible:true, sessionId:X) POST every 15s — firing sendClose
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 (sessions) {
2283
- for (const perm of sessions.pendingPermissions.values()) {
2284
- if (perm.sessionId === becameVisibleFor) {
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
  }