@lelouchhe/webagent 0.9.0 → 0.10.0

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