@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/server.js CHANGED
@@ -7,8 +7,10 @@ import { setLogLevel, log } from "./log.js";
7
7
  import { AgentBridge } from "./bridge.js";
8
8
  import { agentKeyFromCommand } from "./agent-key.js";
9
9
  import { Store } from "./store.js";
10
- import { SessionManager } from "./session-manager.js";
11
- 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";
12
14
  import { createRequestHandler } from "./routes.js";
13
15
  import { handleAgentEvent } from "./event-handler.js";
14
16
  import { PushService } from "./push-service.js";
@@ -20,7 +22,7 @@ import { startMessageCleanup } from "./message-cleanup.js";
20
22
  import { startSharePreviewCleanup, } from "./share/cleanup.js";
21
23
  import { AuthStore } from "./auth-store.js";
22
24
  import { join as pathJoin } from "node:path";
23
- import { resolveSessionsAnchor } from "./sessions-anchor.js";
25
+ import { resolveTasksAnchor } from "./tasks-anchor.js";
24
26
  import { runStartupChecks } from "./startup-checks.js";
25
27
  import { AttachmentDispatcher } from "./attachment-dispatch.js";
26
28
  import { buildBridgeEventHandlerConfig as _buildBridgeEventHandlerConfig } from "./bridge-event-config.js";
@@ -55,12 +57,13 @@ const PKG_VERSION = (() => {
55
57
  })();
56
58
  // --- Core dependencies ---
57
59
  const store = new Store(config.data_dir, agentKeyFromCommand(preflight.agentCmd));
60
+ store.ensureRootTask(config.default_cwd);
58
61
  console.log(`[store] using ${config.data_dir}/`);
59
- // 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
60
63
  // (file:// URI construction, permission interceptor) compare against the
61
64
  // same canonical path. Defends against macOS /var → /private/var.
62
- const sessionsAnchor = resolveSessionsAnchor(config.data_dir);
63
- const attachmentDispatcher = new AttachmentDispatcher(store, sessionsAnchor, {
65
+ const tasksAnchor = resolveTasksAnchor(config.data_dir);
66
+ const attachmentDispatcher = new AttachmentDispatcher(store, tasksAnchor, {
64
67
  warn: (msg) => {
65
68
  console.warn(msg);
66
69
  },
@@ -76,8 +79,14 @@ setInterval(() => {
76
79
  .scope("attachment-interceptor")
77
80
  .info("counters", { ...attachmentInterceptorCounters });
78
81
  }, ATTACHMENT_INTERCEPTOR_DUMP_MS).unref();
79
- const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
80
- 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);
81
90
  const sseManager = new SseManager();
82
91
  const clientRegistry = new ClientRegistry();
83
92
  const pushService = new PushService(store, config.data_dir, config.push.vapid_subject, {
@@ -99,12 +108,46 @@ const attachmentSecret = randomBytes(32);
99
108
  // within one heartbeat interval (≤15s).
100
109
  sseManager.setRevocationCheck((tokenName) => !authStore.hasTokenName(tokenName));
101
110
  sseManager.setAttachmentSecret(attachmentSecret);
102
- sseManager.setLabelMapProvider((sessionId) => sessions.getLabelMap(sessionId));
103
- // Broadcast runtime state patches to all SSE clients interested in the session.
104
- 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) => {
105
114
  sseManager.broadcast(event);
106
115
  });
107
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
+ });
108
151
  const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
109
152
  sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
110
153
  });
@@ -112,10 +155,9 @@ let sharePreviewCleanup = null;
112
155
  // --- HTTP server ---
113
156
  const requestHandler = createRequestHandler({
114
157
  store,
115
- sessions,
158
+ tasks,
116
159
  sseManager,
117
160
  clientRegistry,
118
- titleService,
119
161
  getBridge: () => bridge,
120
162
  publicDir: PUBLIC_DIR,
121
163
  dataDir: config.data_dir,
@@ -127,6 +169,11 @@ const requestHandler = createRequestHandler({
127
169
  ticketStore,
128
170
  attachmentSecret,
129
171
  shareConfig: config.share,
172
+ mcpEndpoint: createMcpEndpoint({
173
+ capabilities,
174
+ isTaskActive: (taskId) => tasks.isMcpSessionActive(taskId),
175
+ taskTools: mcpTaskTools,
176
+ }),
130
177
  });
131
178
  const server = createServer((req, res) => {
132
179
  void requestHandler(req, res);
@@ -147,7 +194,7 @@ async function initBridge(agentCmd) {
147
194
  },
148
195
  });
149
196
  b.on("event", (event) => {
150
- handleAgentEvent(event, sessions, store, b, eventHandlerConfig, sseManager, pushService, clientRegistry);
197
+ handleAgentEvent(event, tasks, store, b, eventHandlerConfig, sseManager, pushService, clientRegistry);
151
198
  });
152
199
  await b.start();
153
200
  bridge = b;
@@ -159,7 +206,7 @@ async function shutdown() {
159
206
  sseManager.stopHeartbeat();
160
207
  messageCleanup.stop();
161
208
  sharePreviewCleanup?.stop();
162
- sessions.killAllBashProcs();
209
+ tasks.killAllBashProcs();
163
210
  await bridge?.shutdown();
164
211
  await authStore.close();
165
212
  store.close();
@@ -199,7 +246,8 @@ server.listen(config.port, config.host, () => {
199
246
  try {
200
247
  await initBridge(agentCmd);
201
248
  console.log(`[bridge] ready`);
202
- sessions.hydrate();
249
+ await tasks.ensureRootTask(bridge);
250
+ tasks.hydrate();
203
251
  }
204
252
  catch (err) {
205
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,7 +444,7 @@ 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.ownsSession(sessionId)) {
447
+ if (!deps.store.ownsTask(taskId)) {
448
448
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
449
449
  return;
450
450
  }
@@ -453,7 +453,7 @@ async function handlePublish(req, res, deps, sessionId) {
453
453
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
454
454
  return;
455
455
  }
456
- if (row.session_id !== sessionId) {
456
+ if (row.task_id !== taskId) {
457
457
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
458
458
  return;
459
459
  }
@@ -525,7 +525,7 @@ async function handlePublish(req, res, deps, sessionId) {
525
525
  : "";
526
526
  json(res, HTTP_STATUS.OK, {
527
527
  token: after.token,
528
- session_id: sessionId,
528
+ task_id: taskId,
529
529
  shared_at: after.shared_at,
530
530
  display_name: after.display_name,
531
531
  owner_label: after.owner_label,
@@ -606,27 +606,27 @@ async function handleSharedEvents(res, deps, token) {
606
606
  return;
607
607
  }
608
608
  // Public viewer must keep working after the owner deletes the source
609
- // session — events stay alive as long as any active share references
610
- // them (Store.deleteSession soft-deletes when shares exist).
611
- const session = deps.store.getSessionIncludingDeleted(row.session_id);
612
- if (!session) {
613
- 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" });
614
614
  return;
615
615
  }
616
616
  const allEvents = deps.store
617
- .getEvents(row.session_id)
617
+ .getEvents(row.task_id)
618
618
  .filter((e) => e.seq <= row.share_snapshot_seq);
619
- if (deps.sessions) {
620
- enrichStoredEventsForDisplay(allEvents, deps.sessions.getLabelMap(row.session_id));
619
+ if (deps.tasks) {
620
+ enrichStoredEventsForDisplay(allEvents, deps.tasks.getLabelMap(row.task_id));
621
621
  }
622
622
  try {
623
623
  const { events } = sanitizeEventsForShare({
624
624
  events: allEvents,
625
- cwd: session.cwd,
625
+ cwd: task.cwd,
626
626
  homeDir: homedir(),
627
627
  internalHosts: deps.config.internal_hosts,
628
628
  });
629
- // 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.
630
630
  res.writeHead(HTTP_STATUS.OK, {
631
631
  "Content-Type": "application/json",
632
632
  "Cache-Control": "no-store",
@@ -636,7 +636,7 @@ async function handleSharedEvents(res, deps, token) {
636
636
  schema_version: "1.0",
637
637
  share: {
638
638
  token: row.token,
639
- session_title: session.title,
639
+ task_title: task.title,
640
640
  shared_at: row.shared_at,
641
641
  snapshot_seq: row.share_snapshot_seq,
642
642
  display_name: row.display_name,
@@ -648,7 +648,7 @@ async function handleSharedEvents(res, deps, token) {
648
648
  }
649
649
  catch (err) {
650
650
  if (err instanceof SanitizeError) {
651
- // 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
652
652
  // post-publish leak. Return 410 publicly; owner sees root cause via
653
653
  // preview re-gate.
654
654
  slog.error("shared_events hard-reject", {
@@ -721,8 +721,8 @@ async function handleViewerAsset(res, deps, file) {
721
721
  }
722
722
  /**
723
723
  * GET /s/:token/attachments/:file — token-scoped image proxy. Resolves the token
724
- * to a session_id on-demand; directly serving /api/v1/sessions/:id/images
725
- * would leak session_id.
724
+ * to a task_id on-demand; directly serving /api/v1/tasks/:id/images
725
+ * would leak task_id.
726
726
  */
727
727
  async function handleViewerImage(res, deps, token, file) {
728
728
  if (!deps.dataDir) {
@@ -745,10 +745,10 @@ async function handleViewerImage(res, deps, token, file) {
745
745
  json(res, HTTP_STATUS.NOT_FOUND, { error: "invalid file" });
746
746
  return;
747
747
  }
748
- const sessionRoot = join(deps.dataDir, "sessions", row.session_id, "attachments");
749
- const filePath = join(sessionRoot, file);
750
- // Final realpath-style guard: must stay under <dataDir>/sessions/<sid>/attachments.
751
- 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) {
752
752
  res.writeHead(HTTP_STATUS.FORBIDDEN);
753
753
  res.end("Forbidden");
754
754
  return;
@@ -760,7 +760,7 @@ async function handleViewerImage(res, deps, token, file) {
760
760
  // appends ".bin" to the <a download> name (e.g. zhihu.user.js →
761
761
  // zhihu.user.js.bin). The owner-side route at routes.ts does the
762
762
  // same lookup; share viewer needs parity for non-image attachments.
763
- const att = deps.store.getAttachmentByFile(row.session_id, file);
763
+ const att = deps.store.getAttachmentByFile(row.task_id, file);
764
764
  const ext = extname(filePath).toLowerCase();
765
765
  let mime = att?.mime;
766
766
  mime ??= IMAGE_MIME[ext];
@@ -831,13 +831,13 @@ export function validateLabel(input, field, maxBytes = 1024) {
831
831
  return { ok: true, value: input };
832
832
  }
833
833
  /**
834
- * 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.
835
835
  * Body: { token }.
836
836
  * Idempotent: already-revoked tokens return 200 with revoked=false.
837
837
  * Returns { ok, token, revoked, purge_status }. purge_status is always
838
838
  * 'skipped' in v1 — image/event purge is a future hardening pass.
839
839
  */
840
- async function handleRevoke(req, res, deps, sessionId) {
840
+ async function handleRevoke(req, res, deps, taskId) {
841
841
  let body;
842
842
  try {
843
843
  body = (await readJson(req));
@@ -853,14 +853,14 @@ async function handleRevoke(req, res, deps, sessionId) {
853
853
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
854
854
  return;
855
855
  }
856
- if (!deps.store.ownsSession(sessionId)) {
856
+ if (!deps.store.ownsTask(taskId)) {
857
857
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
858
858
  return;
859
859
  }
860
860
  const row = deps.store.getShareByToken(body.token);
861
861
  if (!row) {
862
862
  // Idempotent DELETE: row already gone (revoked or never existed).
863
- // 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
864
864
  // is opaque/random so leaking "revoked or never existed" is fine.
865
865
  json(res, HTTP_STATUS.OK, {
866
866
  ok: true,
@@ -870,18 +870,18 @@ async function handleRevoke(req, res, deps, sessionId) {
870
870
  });
871
871
  return;
872
872
  }
873
- if (row.session_id !== sessionId) {
873
+ if (row.task_id !== taskId) {
874
874
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
875
875
  return;
876
876
  }
877
877
  const revoked = deps.store.revokeShare(body.token);
878
- // If this was the last share on a soft-deleted session, finish the
879
- // 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.
880
880
  if (revoked) {
881
- const reaped = deps.store.reapTombstoneIfOrphaned(row.session_id);
881
+ const reaped = deps.store.reapTombstoneIfOrphaned(row.task_id);
882
882
  if (reaped && deps.dataDir) {
883
- // Tombstoned session is fully gone; sweep its attachments directory too.
884
- 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), {
885
885
  recursive: true,
886
886
  force: true,
887
887
  }).catch(() => { });
@@ -895,14 +895,14 @@ async function handleRevoke(req, res, deps, sessionId) {
895
895
  });
896
896
  }
897
897
  /**
898
- * 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
899
899
  * a live (non-revoked) share. Body: { token, owner_label?, display_name? }.
900
900
  *
901
901
  * Full validation: UTF-8 ≤1024B, no C0 controls (except TAB), no DEL, no
902
902
  * bidi overrides. Fields omitted from body are left unchanged; fields set
903
903
  * to empty string clear the value.
904
904
  */
905
- async function handlePatchLabel(req, res, deps, sessionId) {
905
+ async function handlePatchLabel(req, res, deps, taskId) {
906
906
  let body;
907
907
  try {
908
908
  body = (await readJson(req));
@@ -918,7 +918,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
918
918
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
919
919
  return;
920
920
  }
921
- if (!deps.store.ownsSession(sessionId)) {
921
+ if (!deps.store.ownsTask(taskId)) {
922
922
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
923
923
  return;
924
924
  }
@@ -927,7 +927,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
927
927
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
928
928
  return;
929
929
  }
930
- if (row.session_id !== sessionId) {
930
+ if (row.task_id !== taskId) {
931
931
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
932
932
  return;
933
933
  }
@@ -962,7 +962,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
962
962
  }
963
963
  json(res, HTTP_STATUS.OK, {
964
964
  token: after.token,
965
- session_id: sessionId,
965
+ task_id: taskId,
966
966
  owner_label: after.owner_label,
967
967
  display_name: after.display_name,
968
968
  });
@@ -0,0 +1,20 @@
1
+ /** Quote one task/path word for the shell-style task command grammar. */
2
+ export function quoteShellWord(word) {
3
+ if (/[\s"'\\]/.test(word) || word === "") {
4
+ return `"${word.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
5
+ }
6
+ return word;
7
+ }
8
+ /** Format a task title as a stable, shell-parseable @ reference. */
9
+ export function formatTaskReference(title) {
10
+ return `@${quoteShellWord(title)}`;
11
+ }
12
+ /**
13
+ * Format tree path segments as the absolute `@`-prefixed path the input grammar
14
+ * and the UI completion use. The caller decides which segments are root-relative
15
+ * (see `Store.getTaskPath`); this only renders them, shell-quoting as needed so
16
+ * the result can be pasted straight into the input.
17
+ */
18
+ export function formatTaskPath(segments) {
19
+ return `@/${segments.map(quoteShellWord).join("/")}`;
20
+ }