@lelouchhe/webagent 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +43 -15
  2. package/config.toml +7 -27
  3. package/dist/index.html +21 -5
  4. package/dist/js/app.INIQQEGD.js +5 -0
  5. package/dist/js/chunk.3CLGCUHW.js +1 -0
  6. package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
  7. package/dist/js/chunk.AOTG3PL7.js +20 -0
  8. package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
  9. package/dist/js/viewer.RHZMFYWJ.js +1 -0
  10. package/dist/login.html +2 -2
  11. package/dist/share-viewer.html +6 -6
  12. package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
  13. package/dist/sw.js +6 -6
  14. package/lib/agent-key.js +6 -0
  15. package/lib/attachment-dispatch.js +60 -31
  16. package/lib/attachment-interceptor.js +7 -7
  17. package/lib/attachment-labels.js +1 -1
  18. package/lib/attachments.js +69 -7
  19. package/lib/auth-middleware.js +11 -4
  20. package/lib/auth.js +2 -2
  21. package/lib/bridge.js +209 -90
  22. package/lib/client-registry.js +12 -12
  23. package/lib/config.js +2 -31
  24. package/lib/event-handler.js +166 -85
  25. package/lib/files/limits.js +15 -0
  26. package/lib/files/paths.js +155 -0
  27. package/lib/files/routes.js +232 -0
  28. package/lib/home-path.js +35 -0
  29. package/lib/http-status.js +1 -0
  30. package/lib/mcp/capability.js +74 -0
  31. package/lib/mcp/server.js +148 -0
  32. package/lib/mcp/task-history.js +245 -0
  33. package/lib/mcp/task-host.js +253 -0
  34. package/lib/mcp/tools.js +168 -0
  35. package/lib/mode-bucket.js +1 -1
  36. package/lib/push-service.js +33 -35
  37. package/lib/routes.js +1022 -475
  38. package/lib/server.js +84 -34
  39. package/lib/share/routes.js +97 -85
  40. package/lib/shared/task-reference.js +20 -0
  41. package/lib/sse-manager.js +8 -8
  42. package/lib/store.js +992 -284
  43. package/lib/task-collaboration.js +15 -0
  44. package/lib/task-manager.js +1409 -0
  45. package/lib/task-path.js +131 -0
  46. package/lib/{session-state.js → task-state.js} +90 -38
  47. package/lib/task-tree-lock.js +74 -0
  48. package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
  49. package/lib/tokens.js +1 -1
  50. package/lib/types.js +2 -2
  51. package/package.json +8 -1
  52. package/dist/js/app.XBFXH37R.js +0 -2
  53. package/dist/js/chunk.UMQMOGWO.js +0 -1
  54. package/dist/js/viewer.CVWXSKJM.js +0 -1
  55. package/lib/session-manager.js +0 -613
  56. package/lib/title-service.js +0 -95
package/lib/server.js CHANGED
@@ -5,9 +5,12 @@ import { fileURLToPath } from "node:url";
5
5
  import { loadConfig } from "./config.js";
6
6
  import { setLogLevel, log } from "./log.js";
7
7
  import { AgentBridge } from "./bridge.js";
8
+ import { agentKeyFromCommand } from "./agent-key.js";
8
9
  import { Store } from "./store.js";
9
- import { SessionManager } from "./session-manager.js";
10
- import { TitleService } from "./title-service.js";
10
+ import { TaskManager } from "./task-manager.js";
11
+ import { CapabilityStore } from "./mcp/capability.js";
12
+ import { createMcpEndpoint } from "./mcp/server.js";
13
+ import { createMcpTaskToolHost } from "./mcp/task-host.js";
11
14
  import { createRequestHandler } from "./routes.js";
12
15
  import { handleAgentEvent } from "./event-handler.js";
13
16
  import { PushService } from "./push-service.js";
@@ -19,7 +22,7 @@ import { startMessageCleanup } from "./message-cleanup.js";
19
22
  import { startSharePreviewCleanup, } from "./share/cleanup.js";
20
23
  import { AuthStore } from "./auth-store.js";
21
24
  import { join as pathJoin } from "node:path";
22
- import { resolveSessionsAnchor } from "./sessions-anchor.js";
25
+ import { resolveTasksAnchor } from "./tasks-anchor.js";
23
26
  import { runStartupChecks } from "./startup-checks.js";
24
27
  import { AttachmentDispatcher } from "./attachment-dispatch.js";
25
28
  import { buildBridgeEventHandlerConfig as _buildBridgeEventHandlerConfig } from "./bridge-event-config.js";
@@ -53,13 +56,14 @@ const PKG_VERSION = (() => {
53
56
  }
54
57
  })();
55
58
  // --- Core dependencies ---
56
- const store = new Store(config.data_dir);
59
+ const store = new Store(config.data_dir, agentKeyFromCommand(preflight.agentCmd));
60
+ store.ensureRootTask(config.default_cwd);
57
61
  console.log(`[store] using ${config.data_dir}/`);
58
- // Pin <data_dir>/sessions realpath at boot so all later anchor checks
62
+ // Pin <data_dir>/tasks realpath at boot so all later anchor checks
59
63
  // (file:// URI construction, permission interceptor) compare against the
60
64
  // same canonical path. Defends against macOS /var → /private/var.
61
- const sessionsAnchor = resolveSessionsAnchor(config.data_dir);
62
- const attachmentDispatcher = new AttachmentDispatcher(store, sessionsAnchor, {
65
+ const tasksAnchor = resolveTasksAnchor(config.data_dir);
66
+ const attachmentDispatcher = new AttachmentDispatcher(store, tasksAnchor, {
63
67
  warn: (msg) => {
64
68
  console.warn(msg);
65
69
  },
@@ -75,8 +79,14 @@ setInterval(() => {
75
79
  .scope("attachment-interceptor")
76
80
  .info("counters", { ...attachmentInterceptorCounters });
77
81
  }, ATTACHMENT_INTERCEPTOR_DUMP_MS).unref();
78
- const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
79
- const titleService = new TitleService(store, sessions, config.default_cwd, config.title.models);
82
+ // Per-task MCP capability store; minted/revoked by TaskManager. The
83
+ // MCP endpoint authenticates against it. Tokens are in-memory
84
+ // only, so a restart invalidates every outstanding capability.
85
+ const capabilities = new CapabilityStore();
86
+ // Agent subprocesses run on the same host as WebAgent, so the MCP server
87
+ // URL is always loopback even when the HTTP listener binds elsewhere.
88
+ const mcpBaseUrl = `http://127.0.0.1:${config.port}`;
89
+ const tasks = new TaskManager(store, config.default_cwd, config.data_dir, capabilities, mcpBaseUrl);
80
90
  const sseManager = new SseManager();
81
91
  const clientRegistry = new ClientRegistry();
82
92
  const pushService = new PushService(store, config.data_dir, config.push.vapid_subject, {
@@ -98,39 +108,78 @@ const attachmentSecret = randomBytes(32);
98
108
  // within one heartbeat interval (≤15s).
99
109
  sseManager.setRevocationCheck((tokenName) => !authStore.hasTokenName(tokenName));
100
110
  sseManager.setAttachmentSecret(attachmentSecret);
101
- sseManager.setLabelMapProvider((sessionId) => sessions.getLabelMap(sessionId));
102
- // Broadcast runtime state patches to all SSE clients interested in the session.
103
- sessions.state.onPatch((event) => {
111
+ sseManager.setLabelMapProvider((taskId) => tasks.getLabelMap(taskId));
112
+ // Broadcast runtime state patches to all SSE clients interested in the task.
113
+ tasks.state.onPatch((event) => {
104
114
  sseManager.broadcast(event);
105
115
  });
106
116
  let bridge = null;
117
+ const mcpTaskTools = createMcpTaskToolHost({
118
+ store,
119
+ tasks,
120
+ getBridge: () => bridge,
121
+ cancelTimeoutMs: config.limits.cancel_timeout,
122
+ broadcastCollaboration: ({ messageId, sourceTaskId, targetTaskId, title, body, }) => {
123
+ for (const projection of store.listCollaborationProjections(messageId)) {
124
+ sseManager.broadcast({
125
+ type: "system_message",
126
+ taskId: projection.task_id,
127
+ kind: "collaboration",
128
+ messageId,
129
+ sourceTaskId,
130
+ targetTaskId,
131
+ role: projection.role,
132
+ title,
133
+ body,
134
+ });
135
+ }
136
+ },
137
+ broadcastTaskCreated: ({ messageId, sourceTaskId, targetTaskId, title, body, }) => {
138
+ sseManager.broadcast({
139
+ type: "system_message",
140
+ taskId: sourceTaskId,
141
+ kind: "task_created",
142
+ messageId,
143
+ sourceTaskId,
144
+ targetTaskId,
145
+ role: "source",
146
+ title,
147
+ body,
148
+ });
149
+ },
150
+ });
107
151
  const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
108
152
  sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
109
153
  });
110
154
  let sharePreviewCleanup = null;
111
155
  // --- HTTP server ---
156
+ const requestHandler = createRequestHandler({
157
+ store,
158
+ tasks,
159
+ sseManager,
160
+ clientRegistry,
161
+ getBridge: () => bridge,
162
+ publicDir: PUBLIC_DIR,
163
+ dataDir: config.data_dir,
164
+ limits: config.limits,
165
+ pushService,
166
+ serverVersion: PKG_VERSION,
167
+ debugLevel: config.debug.level,
168
+ authStore,
169
+ ticketStore,
170
+ attachmentSecret,
171
+ shareConfig: config.share,
172
+ mcpEndpoint: createMcpEndpoint({
173
+ capabilities,
174
+ isTaskActive: (taskId) => tasks.isMcpSessionActive(taskId),
175
+ taskTools: mcpTaskTools,
176
+ }),
177
+ });
112
178
  const server = createServer((req, res) => {
113
- void createRequestHandler({
114
- store,
115
- sessions,
116
- sseManager,
117
- clientRegistry,
118
- titleService,
119
- getBridge: () => bridge,
120
- publicDir: PUBLIC_DIR,
121
- dataDir: config.data_dir,
122
- limits: config.limits,
123
- pushService,
124
- serverVersion: PKG_VERSION,
125
- debugLevel: config.debug.level,
126
- authStore,
127
- ticketStore,
128
- attachmentSecret,
129
- shareConfig: config.share,
130
- })(req, res);
179
+ void requestHandler(req, res);
131
180
  });
132
181
  async function initBridge(agentCmd) {
133
- const b = new AgentBridge(agentCmd);
182
+ const b = new AgentBridge(agentCmd, store);
134
183
  b.setAttachmentDispatcher(attachmentDispatcher);
135
184
  const eventHandlerConfig = _buildBridgeEventHandlerConfig({
136
185
  cancelTimeout: config.limits.cancel_timeout,
@@ -145,7 +194,7 @@ async function initBridge(agentCmd) {
145
194
  },
146
195
  });
147
196
  b.on("event", (event) => {
148
- handleAgentEvent(event, sessions, store, b, eventHandlerConfig, sseManager, pushService, clientRegistry);
197
+ handleAgentEvent(event, tasks, store, b, eventHandlerConfig, sseManager, pushService, clientRegistry);
149
198
  });
150
199
  await b.start();
151
200
  bridge = b;
@@ -157,7 +206,7 @@ async function shutdown() {
157
206
  sseManager.stopHeartbeat();
158
207
  messageCleanup.stop();
159
208
  sharePreviewCleanup?.stop();
160
- sessions.killAllBashProcs();
209
+ tasks.killAllBashProcs();
161
210
  await bridge?.shutdown();
162
211
  await authStore.close();
163
212
  store.close();
@@ -197,7 +246,8 @@ server.listen(config.port, config.host, () => {
197
246
  try {
198
247
  await initBridge(agentCmd);
199
248
  console.log(`[bridge] ready`);
200
- sessions.hydrate();
249
+ await tasks.ensureRootTask(bridge);
250
+ tasks.hydrate();
201
251
  }
202
252
  catch (err) {
203
253
  console.error(`[bridge] failed to start:`, err);
@@ -9,7 +9,7 @@ import { enrichStoredEventsForDisplay } from "../attachment-labels.js";
9
9
  import { log } from "../log.js";
10
10
  import { HTTP_STATUS } from "../http-status.js";
11
11
  const slog = log.scope("share");
12
- // In-flight dedup for concurrent POST /share on the same session.
12
+ // In-flight dedup for concurrent POST /share on the same task.
13
13
  // First caller does the work; concurrent callers await the same promise.
14
14
  // Idempotent because the body re-checks for an existing preview before
15
15
  // inserting.
@@ -79,9 +79,9 @@ function json(res, status, body) {
79
79
  *
80
80
  * URL space claimed:
81
81
  * /s, /s/:token (viewer — C3)
82
- * /api/v1/sessions/:id/share (preview create — C2 here)
83
- * /api/v1/sessions/:id/share/preview (preview read — C2 here)
84
- * /api/v1/sessions/:id/share/publish (activate — C3)
82
+ * /api/v1/tasks/:id/share (preview create — C2 here)
83
+ * /api/v1/tasks/:id/share/preview (preview read — C2 here)
84
+ * /api/v1/tasks/:id/share/publish (activate — C3)
85
85
  * /api/v1/shares, /api/v1/shares/:t (owner list/patch — C4)
86
86
  * /api/v1/shared/:token (public viewer JSON — C3)
87
87
  */
@@ -119,20 +119,20 @@ export async function handleShareRoutes(req, res, deps) {
119
119
  res.end("share token required");
120
120
  return true;
121
121
  }
122
- // POST /api/v1/sessions/:id/share — create preview
123
- const createMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/share\/?(?:\?.*)?$/);
122
+ // POST /api/v1/tasks/:id/share — create preview
123
+ const createMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/share\/?(?:\?.*)?$/);
124
124
  if (createMatch && method === "POST") {
125
125
  await handlePreviewCreate(req, res, deps, decodeURIComponent(createMatch[1]));
126
126
  return true;
127
127
  }
128
- // GET /api/v1/sessions/:id/share/preview — read preview + staleness
129
- const previewMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/share\/preview\/?(?:\?.*)?$/);
128
+ // GET /api/v1/tasks/:id/share/preview — read preview + staleness
129
+ const previewMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/share\/preview\/?(?:\?.*)?$/);
130
130
  if (previewMatch && method === "GET") {
131
131
  await handlePreviewRead(req, res, deps, decodeURIComponent(previewMatch[1]));
132
132
  return true;
133
133
  }
134
- // POST /api/v1/sessions/:id/share/publish — promote preview to public
135
- const publishMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/share\/publish\/?(?:\?.*)?$/);
134
+ // POST /api/v1/tasks/:id/share/publish — promote preview to public
135
+ const publishMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/share\/publish\/?(?:\?.*)?$/);
136
136
  if (publishMatch && method === "POST") {
137
137
  await handlePublish(req, res, deps, decodeURIComponent(publishMatch[1]));
138
138
  return true;
@@ -143,13 +143,13 @@ export async function handleShareRoutes(req, res, deps) {
143
143
  await handleSharedEvents(res, deps, sharedEventsMatch[1]);
144
144
  return true;
145
145
  }
146
- const revokeMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/share\/?(?:\?.*)?$/);
147
- // DELETE /api/v1/sessions/:id/share — hard-delete share row
146
+ const revokeMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/share\/?(?:\?.*)?$/);
147
+ // DELETE /api/v1/tasks/:id/share — hard-delete share row
148
148
  if (revokeMatch && method === "DELETE") {
149
149
  await handleRevoke(req, res, deps, decodeURIComponent(revokeMatch[1]));
150
150
  return true;
151
151
  }
152
- // PATCH /api/v1/sessions/:id/share — update display_name / owner_label
152
+ // PATCH /api/v1/tasks/:id/share — update display_name / owner_label
153
153
  if (revokeMatch && method === "PATCH") {
154
154
  await handlePatchLabel(req, res, deps, decodeURIComponent(revokeMatch[1]));
155
155
  return true;
@@ -172,7 +172,7 @@ export async function handleShareRoutes(req, res, deps) {
172
172
  return true;
173
173
  }
174
174
  // Any other /api/v1/shares[/...] or /api/v1/shared/... miss → 404.
175
- if (/^\/api\/v1\/sessions\/[^/]+\/share(?:\/|$|\?)/.test(url) ||
175
+ if (/^\/api\/v1\/tasks\/[^/]+\/share(?:\/|$|\?)/.test(url) ||
176
176
  url === "/api/v1/shares" ||
177
177
  url.startsWith("/api/v1/shares/") ||
178
178
  url.startsWith("/api/v1/shared/") ||
@@ -193,9 +193,9 @@ function resolveDisplayName(deps, validated) {
193
193
  return deps.store.getOwnerPref(DEFAULT_DISPLAY_NAME_KEY) ?? null;
194
194
  }
195
195
  /**
196
- * POST /api/v1/sessions/:id/share — create (or return existing) preview.
196
+ * POST /api/v1/tasks/:id/share — create (or return existing) preview.
197
197
  *
198
- * share-plan §4.2 R1-c1: same-session dedup — if an un-activated preview
198
+ * share-plan §4.2 R1-c1: same-task dedup — if an un-activated preview
199
199
  * already exists, return it verbatim. Only create new on miss.
200
200
  *
201
201
  * Body (all optional):
@@ -204,17 +204,17 @@ function resolveDisplayName(deps, validated) {
204
204
  * display_name: str — shown as "by @<name>" in viewer footer
205
205
  * owner_label: str — private owner-side label (full validation in C4)
206
206
  */
207
- async function handlePreviewCreate(req, res, deps, sessionId) {
208
- const session = deps.store.getSession(sessionId);
209
- if (!session) {
210
- json(res, HTTP_STATUS.NOT_FOUND, { error: "session not found" });
207
+ async function handlePreviewCreate(req, res, deps, taskId) {
208
+ const task = deps.store.getTask(taskId);
209
+ if (!task) {
210
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "task not found" });
211
211
  return;
212
212
  }
213
- // 409 guard: block while the agent is actively streaming into this session.
214
- if (deps.sessions?.getBusyKind(sessionId) === "agent") {
213
+ // 409 guard: block while the agent is actively streaming into this task.
214
+ if (deps.tasks?.getBusyKind(taskId) === "agent") {
215
215
  json(res, HTTP_STATUS.CONFLICT, {
216
- error: "session busy",
217
- detail: "此 session 正在接收 agent 输出,请等 agent 输出结束后再分享",
216
+ error: "task busy",
217
+ detail: "此 task 正在接收 agent 输出,请等 agent 输出结束后再分享",
218
218
  });
219
219
  return;
220
220
  }
@@ -257,24 +257,24 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
257
257
  const displayName = resolveDisplayName(deps, dnResult.value);
258
258
  const ownerLabel = olResult.value === "" ? null : olResult.value;
259
259
  try {
260
- const existingInflight = pendingShareCreates.get(sessionId);
260
+ const existingInflight = pendingShareCreates.get(taskId);
261
261
  const inflight = existingInflight ??
262
262
  (async () => {
263
263
  // Dedup first — existing preview short-circuits the gate.
264
- const existing = deps.store.findActivePreviewBySession(sessionId);
264
+ const existing = deps.store.findActivePreviewByTask(taskId);
265
265
  if (existing)
266
266
  return { row: existing, reused: true };
267
267
  // Flush buffered chunks so snapshot_seq includes the streaming tail.
268
- deps.sessions?.flushBuffers(sessionId);
269
- const allEvents = deps.store.getEvents(sessionId);
268
+ deps.tasks?.flushBuffers(taskId);
269
+ const allEvents = deps.store.getEvents(taskId);
270
270
  const snapshotSeq = allEvents.length > 0 ? Math.max(...allEvents.map((e) => e.seq)) : 0;
271
271
  // Gate: run the sanitizer on-write. Hard-rejects throw here so we
272
- // never create a preview row for a session with leaked secrets.
273
- runSanitizeGate(allEvents, session.cwd, deps.config.internal_hosts);
272
+ // never create a preview row for a task with leaked secrets.
273
+ runSanitizeGate(allEvents, task.cwd, deps.config.internal_hosts);
274
274
  const token = generateShareToken();
275
275
  const row = deps.store.insertSharePreview({
276
276
  token,
277
- sessionId,
277
+ taskId,
278
278
  snapshotSeq,
279
279
  ttlHours,
280
280
  displayName,
@@ -282,14 +282,14 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
282
282
  });
283
283
  return { row, reused: false };
284
284
  })().finally(() => {
285
- pendingShareCreates.delete(sessionId);
285
+ pendingShareCreates.delete(taskId);
286
286
  });
287
287
  if (!existingInflight)
288
- pendingShareCreates.set(sessionId, inflight);
288
+ pendingShareCreates.set(taskId, inflight);
289
289
  const result = await inflight;
290
290
  json(res, result.reused ? HTTP_STATUS.OK : HTTP_STATUS.CREATED, {
291
291
  token: result.row.token,
292
- session_id: sessionId,
292
+ task_id: taskId,
293
293
  snapshot_seq: result.row.share_snapshot_seq,
294
294
  ttl_hours: result.row.ttl_hours,
295
295
  display_name: result.row.display_name,
@@ -326,14 +326,14 @@ function runSanitizeGate(events, cwd, internalHosts) {
326
326
  });
327
327
  }
328
328
  /**
329
- * GET /api/v1/sessions/:id/share/preview — read sanitized preview.
329
+ * GET /api/v1/tasks/:id/share/preview — read sanitized preview.
330
330
  *
331
331
  * Auth: owner + X-Share-Token header (token never in URL, never in logs).
332
332
  * Returns a `{schema_version, events, share}` bundle matching the public
333
333
  * viewer contract (minus public-only fields) so the overlay can share
334
334
  * the render path.
335
335
  */
336
- async function handlePreviewRead(req, res, deps, sessionId) {
336
+ async function handlePreviewRead(req, res, deps, taskId) {
337
337
  const tokenHeader = req.headers["x-share-token"];
338
338
  const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
339
339
  if (!token) {
@@ -347,7 +347,7 @@ async function handlePreviewRead(req, res, deps, sessionId) {
347
347
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
348
348
  return;
349
349
  }
350
- if (row.session_id !== sessionId) {
350
+ if (row.task_id !== taskId) {
351
351
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
352
352
  return;
353
353
  }
@@ -357,24 +357,24 @@ async function handlePreviewRead(req, res, deps, sessionId) {
357
357
  });
358
358
  return;
359
359
  }
360
- const session = deps.store.getSession(sessionId);
361
- if (!session) {
362
- json(res, HTTP_STATUS.NOT_FOUND, { error: "session not found" });
360
+ const task = deps.store.getTask(taskId);
361
+ if (!task) {
362
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "task not found" });
363
363
  return;
364
364
  }
365
365
  const allEvents = deps.store
366
- .getEvents(sessionId)
366
+ .getEvents(taskId)
367
367
  .filter((e) => e.seq <= row.share_snapshot_seq);
368
- if (deps.sessions) {
369
- enrichStoredEventsForDisplay(allEvents, deps.sessions.getLabelMap(sessionId));
368
+ if (deps.tasks) {
369
+ enrichStoredEventsForDisplay(allEvents, deps.tasks.getLabelMap(taskId));
370
370
  }
371
371
  const currentLastSeq = deps.store
372
- .getEvents(sessionId)
372
+ .getEvents(taskId)
373
373
  .reduce((m, e) => Math.max(m, e.seq), 0);
374
374
  try {
375
375
  const { events } = sanitizeEventsForShare({
376
376
  events: allEvents,
377
- cwd: session.cwd,
377
+ cwd: task.cwd,
378
378
  homeDir: homedir(),
379
379
  internalHosts: deps.config.internal_hosts,
380
380
  });
@@ -384,8 +384,8 @@ async function handlePreviewRead(req, res, deps, sessionId) {
384
384
  schema_version: "1.0",
385
385
  share: {
386
386
  token: row.token,
387
- session_id: sessionId,
388
- session_title: session.title,
387
+ task_id: taskId,
388
+ task_title: task.title,
389
389
  shared_at: null,
390
390
  snapshot_seq: row.share_snapshot_seq,
391
391
  current_last_seq: currentLastSeq,
@@ -416,19 +416,19 @@ async function handlePreviewRead(req, res, deps, sessionId) {
416
416
  }
417
417
  }
418
418
  /**
419
- * POST /api/v1/sessions/:id/share/publish — activate an existing preview.
419
+ * POST /api/v1/tasks/:id/share/publish — activate an existing preview.
420
420
  *
421
421
  * Body: { token, display_name?, owner_label? }
422
- * - token MUST match a preview row for this session that has not been
422
+ * - token MUST match a preview row for this task that has not been
423
423
  * activated or revoked.
424
424
  * - display_name / owner_label, if present, overwrite the preview row and
425
425
  * are persisted into owner_prefs so the next /share defaults to them.
426
426
  *
427
- * Response: { token, session_id, shared_at, display_name, owner_label,
427
+ * Response: { token, task_id, shared_at, display_name, owner_label,
428
428
  * public_url } on 200; 404/409/410 on state errors.
429
429
  */
430
430
  // eslint-disable-next-line complexity -- TODO: split validation / state-update / response phases
431
- async function handlePublish(req, res, deps, sessionId) {
431
+ async function handlePublish(req, res, deps, taskId) {
432
432
  let body;
433
433
  try {
434
434
  body = (await readJson(req));
@@ -444,12 +444,16 @@ async function handlePublish(req, res, deps, sessionId) {
444
444
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
445
445
  return;
446
446
  }
447
+ if (!deps.store.ownsTask(taskId)) {
448
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
449
+ return;
450
+ }
447
451
  const row = deps.store.getShareByToken(body.token);
448
452
  if (!row) {
449
453
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
450
454
  return;
451
455
  }
452
- if (row.session_id !== sessionId) {
456
+ if (row.task_id !== taskId) {
453
457
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
454
458
  return;
455
459
  }
@@ -521,7 +525,7 @@ async function handlePublish(req, res, deps, sessionId) {
521
525
  : "";
522
526
  json(res, HTTP_STATUS.OK, {
523
527
  token: after.token,
524
- session_id: sessionId,
528
+ task_id: taskId,
525
529
  shared_at: after.shared_at,
526
530
  display_name: after.display_name,
527
531
  owner_label: after.owner_label,
@@ -602,27 +606,27 @@ async function handleSharedEvents(res, deps, token) {
602
606
  return;
603
607
  }
604
608
  // Public viewer must keep working after the owner deletes the source
605
- // session — events stay alive as long as any active share references
606
- // them (Store.deleteSession soft-deletes when shares exist).
607
- const session = deps.store.getSessionIncludingDeleted(row.session_id);
608
- if (!session) {
609
- json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session vanished" });
609
+ // task — events stay alive as long as any active share references
610
+ // them (Store.deleteTask soft-deletes when shares exist).
611
+ const task = deps.store.getTaskIncludingDeleted(row.task_id);
612
+ if (!task) {
613
+ json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "task vanished" });
610
614
  return;
611
615
  }
612
616
  const allEvents = deps.store
613
- .getEvents(row.session_id)
617
+ .getEvents(row.task_id)
614
618
  .filter((e) => e.seq <= row.share_snapshot_seq);
615
- if (deps.sessions) {
616
- enrichStoredEventsForDisplay(allEvents, deps.sessions.getLabelMap(row.session_id));
619
+ if (deps.tasks) {
620
+ enrichStoredEventsForDisplay(allEvents, deps.tasks.getLabelMap(row.task_id));
617
621
  }
618
622
  try {
619
623
  const { events } = sanitizeEventsForShare({
620
624
  events: allEvents,
621
- cwd: session.cwd,
625
+ cwd: task.cwd,
622
626
  homeDir: homedir(),
623
627
  internalHosts: deps.config.internal_hosts,
624
628
  });
625
- // Public response: DOES NOT expose session_id. Only title + display_name + meta.
629
+ // Public response: DOES NOT expose task_id. Only title + display_name + meta.
626
630
  res.writeHead(HTTP_STATUS.OK, {
627
631
  "Content-Type": "application/json",
628
632
  "Cache-Control": "no-store",
@@ -632,7 +636,7 @@ async function handleSharedEvents(res, deps, token) {
632
636
  schema_version: "1.0",
633
637
  share: {
634
638
  token: row.token,
635
- session_title: session.title,
639
+ task_title: task.title,
636
640
  shared_at: row.shared_at,
637
641
  snapshot_seq: row.share_snapshot_seq,
638
642
  display_name: row.display_name,
@@ -644,7 +648,7 @@ async function handleSharedEvents(res, deps, token) {
644
648
  }
645
649
  catch (err) {
646
650
  if (err instanceof SanitizeError) {
647
- // Hard-reject on a LIVE active share — owner's session gained a
651
+ // Hard-reject on a LIVE active share — owner's task gained a
648
652
  // post-publish leak. Return 410 publicly; owner sees root cause via
649
653
  // preview re-gate.
650
654
  slog.error("shared_events hard-reject", {
@@ -717,8 +721,8 @@ async function handleViewerAsset(res, deps, file) {
717
721
  }
718
722
  /**
719
723
  * GET /s/:token/attachments/:file — token-scoped image proxy. Resolves the token
720
- * to a session_id on-demand; directly serving /api/v1/sessions/:id/images
721
- * would leak session_id.
724
+ * to a task_id on-demand; directly serving /api/v1/tasks/:id/images
725
+ * would leak task_id.
722
726
  */
723
727
  async function handleViewerImage(res, deps, token, file) {
724
728
  if (!deps.dataDir) {
@@ -741,10 +745,10 @@ async function handleViewerImage(res, deps, token, file) {
741
745
  json(res, HTTP_STATUS.NOT_FOUND, { error: "invalid file" });
742
746
  return;
743
747
  }
744
- const sessionRoot = join(deps.dataDir, "sessions", row.session_id, "attachments");
745
- const filePath = join(sessionRoot, file);
746
- // Final realpath-style guard: must stay under <dataDir>/sessions/<sid>/attachments.
747
- if (!filePath.startsWith(sessionRoot + "/") && filePath !== sessionRoot) {
748
+ const taskRoot = join(deps.dataDir, "tasks", row.task_id, "attachments");
749
+ const filePath = join(taskRoot, file);
750
+ // Final realpath-style guard: must stay under <dataDir>/tasks/<sid>/attachments.
751
+ if (!filePath.startsWith(taskRoot + "/") && filePath !== taskRoot) {
748
752
  res.writeHead(HTTP_STATUS.FORBIDDEN);
749
753
  res.end("Forbidden");
750
754
  return;
@@ -756,7 +760,7 @@ async function handleViewerImage(res, deps, token, file) {
756
760
  // appends ".bin" to the <a download> name (e.g. zhihu.user.js →
757
761
  // zhihu.user.js.bin). The owner-side route at routes.ts does the
758
762
  // same lookup; share viewer needs parity for non-image attachments.
759
- const att = deps.store.getAttachmentByFile(row.session_id, file);
763
+ const att = deps.store.getAttachmentByFile(row.task_id, file);
760
764
  const ext = extname(filePath).toLowerCase();
761
765
  let mime = att?.mime;
762
766
  mime ??= IMAGE_MIME[ext];
@@ -827,13 +831,13 @@ export function validateLabel(input, field, maxBytes = 1024) {
827
831
  return { ok: true, value: input };
828
832
  }
829
833
  /**
830
- * DELETE /api/v1/sessions/:id/share — revoke an active or preview share.
834
+ * DELETE /api/v1/tasks/:id/share — revoke an active or preview share.
831
835
  * Body: { token }.
832
836
  * Idempotent: already-revoked tokens return 200 with revoked=false.
833
837
  * Returns { ok, token, revoked, purge_status }. purge_status is always
834
838
  * 'skipped' in v1 — image/event purge is a future hardening pass.
835
839
  */
836
- async function handleRevoke(req, res, deps, sessionId) {
840
+ async function handleRevoke(req, res, deps, taskId) {
837
841
  let body;
838
842
  try {
839
843
  body = (await readJson(req));
@@ -849,10 +853,14 @@ async function handleRevoke(req, res, deps, sessionId) {
849
853
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
850
854
  return;
851
855
  }
856
+ if (!deps.store.ownsTask(taskId)) {
857
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
858
+ return;
859
+ }
852
860
  const row = deps.store.getShareByToken(body.token);
853
861
  if (!row) {
854
862
  // Idempotent DELETE: row already gone (revoked or never existed).
855
- // We can't verify session ownership without a row, but the token
863
+ // We can't verify task ownership without a row, but the token
856
864
  // is opaque/random so leaking "revoked or never existed" is fine.
857
865
  json(res, HTTP_STATUS.OK, {
858
866
  ok: true,
@@ -862,18 +870,18 @@ async function handleRevoke(req, res, deps, sessionId) {
862
870
  });
863
871
  return;
864
872
  }
865
- if (row.session_id !== sessionId) {
873
+ if (row.task_id !== taskId) {
866
874
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
867
875
  return;
868
876
  }
869
877
  const revoked = deps.store.revokeShare(body.token);
870
- // If this was the last share on a soft-deleted session, finish the
871
- // hard-delete (events + sessions row) so we don't leak orphans.
878
+ // If this was the last share on a soft-deleted task, finish the
879
+ // hard-delete (events + tasks row) so we don't leak orphans.
872
880
  if (revoked) {
873
- const reaped = deps.store.reapTombstoneIfOrphaned(row.session_id);
881
+ const reaped = deps.store.reapTombstoneIfOrphaned(row.task_id);
874
882
  if (reaped && deps.dataDir) {
875
- // Tombstoned session is fully gone; sweep its attachments directory too.
876
- rm(join(deps.dataDir, "sessions", row.session_id), {
883
+ // Tombstoned task is fully gone; sweep its attachments directory too.
884
+ rm(join(deps.dataDir, "tasks", row.task_id), {
877
885
  recursive: true,
878
886
  force: true,
879
887
  }).catch(() => { });
@@ -887,14 +895,14 @@ async function handleRevoke(req, res, deps, sessionId) {
887
895
  });
888
896
  }
889
897
  /**
890
- * PATCH /api/v1/sessions/:id/share — update owner_label / display_name on
898
+ * PATCH /api/v1/tasks/:id/share — update owner_label / display_name on
891
899
  * a live (non-revoked) share. Body: { token, owner_label?, display_name? }.
892
900
  *
893
901
  * Full validation: UTF-8 ≤1024B, no C0 controls (except TAB), no DEL, no
894
902
  * bidi overrides. Fields omitted from body are left unchanged; fields set
895
903
  * to empty string clear the value.
896
904
  */
897
- async function handlePatchLabel(req, res, deps, sessionId) {
905
+ async function handlePatchLabel(req, res, deps, taskId) {
898
906
  let body;
899
907
  try {
900
908
  body = (await readJson(req));
@@ -910,12 +918,16 @@ async function handlePatchLabel(req, res, deps, sessionId) {
910
918
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
911
919
  return;
912
920
  }
921
+ if (!deps.store.ownsTask(taskId)) {
922
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
923
+ return;
924
+ }
913
925
  const row = deps.store.getShareByToken(body.token);
914
926
  if (!row) {
915
927
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
916
928
  return;
917
929
  }
918
- if (row.session_id !== sessionId) {
930
+ if (row.task_id !== taskId) {
919
931
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
920
932
  return;
921
933
  }
@@ -950,7 +962,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
950
962
  }
951
963
  json(res, HTTP_STATUS.OK, {
952
964
  token: after.token,
953
- session_id: sessionId,
965
+ task_id: taskId,
954
966
  owner_label: after.owner_label,
955
967
  display_name: after.display_name,
956
968
  });