@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
@@ -4,44 +4,44 @@ import { isAutopilotMode } from "./mode-bucket.js";
4
4
  const ailog = log.scope("attachment-interceptor");
5
5
  const plog = log.scope("push");
6
6
  const clog = log.scope("cancel");
7
- function handleConnected(event, sessions, config) {
7
+ function handleConnected(event, tasks, config) {
8
8
  event.cancelTimeout = config.cancelTimeout;
9
9
  event.recentPathsLimit = config.recentPathsLimit;
10
10
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive check
11
11
  if (event.agent)
12
- sessions.agentInfo = event.agent;
12
+ tasks.agentInfo = event.agent;
13
13
  }
14
- function handleConfigLikeEvent(event, sessions) {
15
- sessions.recordConfigOptions(event.sessionId, event.configOptions);
14
+ function handleConfigLikeEvent(event, tasks) {
15
+ tasks.recordConfigOptions(event.taskId, event.configOptions);
16
16
  }
17
- function handleMessageChunk(event, sessions) {
18
- sessions.flushThinkingBuffer(event.sessionId);
19
- sessions.appendAssistant(event.sessionId, event.text);
20
- sessions.state.patch(event.sessionId, {
17
+ function handleMessageChunk(event, tasks) {
18
+ tasks.flushThinkingBuffer(event.taskId);
19
+ tasks.appendAssistant(event.taskId, event.text);
20
+ tasks.state.patch(event.taskId, {
21
21
  runtime: { streaming: { assistant: true, thinking: false } },
22
22
  });
23
23
  }
24
- function handleThoughtChunk(event, sessions) {
25
- sessions.flushAssistantBuffer(event.sessionId);
26
- sessions.appendThinking(event.sessionId, event.text);
27
- sessions.state.patch(event.sessionId, {
24
+ function handleThoughtChunk(event, tasks) {
25
+ tasks.flushAssistantBuffer(event.taskId);
26
+ tasks.appendThinking(event.taskId, event.text);
27
+ tasks.state.patch(event.taskId, {
28
28
  runtime: { streaming: { assistant: false, thinking: true } },
29
29
  });
30
30
  }
31
- function handleToolCall(event, sessions, store) {
32
- sessions.flushBuffers(event.sessionId);
33
- sessions.state.patch(event.sessionId, {
31
+ function handleToolCall(event, tasks, store) {
32
+ tasks.flushBuffers(event.taskId);
33
+ tasks.state.patch(event.taskId, {
34
34
  runtime: { streaming: { assistant: false, thinking: false } },
35
35
  });
36
- store.saveEvent(event.sessionId, event.type, {
36
+ store.saveEvent(event.taskId, event.type, {
37
37
  id: event.id,
38
38
  title: event.title,
39
39
  kind: event.kind,
40
40
  rawInput: event.rawInput,
41
41
  }, { from_ref: "agent" });
42
42
  }
43
- function handleUsageUpdate(event, sessions) {
44
- sessions.state.patch(event.sessionId, {
43
+ function handleUsageUpdate(event, tasks) {
44
+ tasks.state.patch(event.taskId, {
45
45
  runtime: {
46
46
  contextUsage: {
47
47
  used: event.used,
@@ -51,9 +51,9 @@ function handleUsageUpdate(event, sessions) {
51
51
  },
52
52
  });
53
53
  }
54
- function handlePlan(event, sessions, store) {
55
- sessions.flushBuffers(event.sessionId);
56
- sessions.state.patch(event.sessionId, {
54
+ function handlePlan(event, tasks, store) {
55
+ tasks.flushBuffers(event.taskId);
56
+ tasks.state.patch(event.taskId, {
57
57
  runtime: {
58
58
  streaming: { assistant: false, thinking: false },
59
59
  plan: event.entries.length > 0 &&
@@ -62,14 +62,14 @@ function handlePlan(event, sessions, store) {
62
62
  : null,
63
63
  },
64
64
  });
65
- store.saveEvent(event.sessionId, event.type, { entries: event.entries }, { from_ref: "agent" });
65
+ store.saveEvent(event.taskId, event.type, { entries: event.entries }, { from_ref: "agent" });
66
66
  }
67
- function performAutoApprove(event, opt, sessions, store, bridge, sseManager, broadcastRequest) {
67
+ function performAutoApprove(event, opt, tasks, store, bridge, sseManager, broadcastRequest) {
68
68
  bridge.resolvePermission(event.requestId, opt.optionId);
69
- sessions.pendingPermissions.delete(event.requestId);
70
- sessions.syncPendingPermissions(event.sessionId);
69
+ tasks.pendingPermissions.delete(event.requestId);
70
+ tasks.syncPendingPermissions(event.taskId);
71
71
  const optionName = opt.label ?? opt.optionId;
72
- store.saveEvent(event.sessionId, "permission_response", {
72
+ store.saveEvent(event.taskId, "permission_response", {
73
73
  requestId: event.requestId,
74
74
  optionName,
75
75
  denied: false,
@@ -78,23 +78,23 @@ function performAutoApprove(event, opt, sessions, store, bridge, sseManager, bro
78
78
  sseManager.broadcast(event);
79
79
  sseManager.broadcast({
80
80
  type: "permission_response",
81
- sessionId: event.sessionId,
81
+ taskId: event.taskId,
82
82
  requestId: event.requestId,
83
83
  optionName,
84
84
  denied: false,
85
85
  });
86
86
  }
87
- function maybeAutoApprovePermission(event, sessions, store, bridge, sseManager) {
88
- const mode = store.getSession(event.sessionId)?.mode ?? "";
87
+ function maybeAutoApprovePermission(event, tasks, store, bridge, sseManager) {
88
+ const mode = store.getTask(event.taskId)?.mode ?? "";
89
89
  if (!isAutopilotMode(mode))
90
90
  return false;
91
91
  const opt = event.options.find((o) => o.kind === "allow_once");
92
92
  if (!opt)
93
93
  return false;
94
- performAutoApprove(event, opt, sessions, store, bridge, sseManager, true);
94
+ performAutoApprove(event, opt, tasks, store, bridge, sseManager, true);
95
95
  return true;
96
96
  }
97
- function maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManager, config) {
97
+ function maybeAutoApproveAttachmentRead(event, tasks, store, bridge, sseManager, config) {
98
98
  // Plan §1.4 — async attachment-read auto-approve runs *after* the
99
99
  // permission_request has already been broadcast (so the UI shows it
100
100
  // briefly), then if the request matches we follow up with a
@@ -106,7 +106,7 @@ function maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManag
106
106
  if (!opt)
107
107
  return;
108
108
  void shouldAutoApproveAttachmentRead({
109
- sessionId: event.sessionId,
109
+ taskId: event.taskId,
110
110
  toolKind: event.toolKind,
111
111
  toolName: event.toolName,
112
112
  locations: event.locations,
@@ -121,119 +121,172 @@ function maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManag
121
121
  return;
122
122
  // Race guard: the user (or another client) may have already
123
123
  // resolved the permission while we were realpath-ing.
124
- if (!sessions.pendingPermissions.has(event.requestId))
124
+ if (!tasks.pendingPermissions.has(event.requestId))
125
125
  return;
126
- performAutoApprove(event, opt, sessions, store, bridge, sseManager, false);
126
+ performAutoApprove(event, opt, tasks, store, bridge, sseManager, false);
127
127
  }, (err) => {
128
128
  ailog.warn("unexpected error", { error: err.message });
129
129
  });
130
130
  }
131
- function handlePermissionRequest(event, sessions, store, bridge, sseManager, config) {
132
- sessions.flushBuffers(event.sessionId);
133
- sessions.state.patch(event.sessionId, {
131
+ function handlePermissionRequest(event, tasks, store, bridge, sseManager, config) {
132
+ tasks.flushBuffers(event.taskId);
133
+ tasks.state.patch(event.taskId, {
134
134
  runtime: { streaming: { assistant: false, thinking: false } },
135
135
  });
136
- store.saveEvent(event.sessionId, event.type, {
136
+ store.saveEvent(event.taskId, event.type, {
137
137
  requestId: event.requestId,
138
138
  title: event.title,
139
139
  options: event.options,
140
140
  }, { from_ref: "agent" });
141
- sessions.pendingPermissions.set(event.requestId, {
141
+ tasks.pendingPermissions.set(event.requestId, {
142
142
  requestId: event.requestId,
143
- sessionId: event.sessionId,
143
+ taskId: event.taskId,
144
144
  title: event.title,
145
145
  options: event.options.map((o) => ({
146
146
  optionId: o.optionId,
147
147
  label: o.label ?? o.name ?? o.optionId,
148
148
  })),
149
149
  });
150
- sessions.syncPendingPermissions(event.sessionId);
151
- const autopiloted = maybeAutoApprovePermission(event, sessions, store, bridge, sseManager);
150
+ tasks.syncPendingPermissions(event.taskId);
151
+ const autopiloted = maybeAutoApprovePermission(event, tasks, store, bridge, sseManager);
152
152
  if (autopiloted)
153
153
  return true;
154
154
  // Async attachment-read auto-approve runs after the request broadcasts.
155
- maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManager, config);
155
+ maybeAutoApproveAttachmentRead(event, tasks, store, bridge, sseManager, config);
156
156
  return false;
157
157
  }
158
- function handlePromptDone(event, sessions, store) {
159
- const cancelStatus = sessions.state.getState(event.sessionId).runtime.busy?.cancelStatus ?? null;
158
+ function handlePromptDone(event, tasks, store, bridge) {
159
+ const cancelStatus = tasks.state.getState(event.taskId).runtime.busy?.cancelStatus ?? null;
160
160
  if (cancelStatus !== null) {
161
161
  clog.info("agent completed after request", {
162
- sessionId: event.sessionId.slice(0, 8),
162
+ taskId: event.taskId.slice(0, 8),
163
163
  requestedStatus: cancelStatus,
164
164
  stopReason: event.stopReason,
165
165
  });
166
166
  }
167
167
  // The tail this turn buffered must always land, even when the turn has
168
168
  // already been superseded — it is the only copy of that text.
169
- const isCurrent = sessions.isCurrentPrompt(event.sessionId, event.promptId);
169
+ const isCurrent = tasks.isCurrentPrompt(event.taskId, event.promptId);
170
+ const taskBeforeIdle = isCurrent ? store.getTask(event.taskId) : null;
171
+ // Only Agent-created delegated Tasks owe a lifecycle handoff. User-created
172
+ // Tasks may receive the same collaboration content but remain interactive.
173
+ const needsHandoffReminder = isCurrent &&
174
+ taskBeforeIdle?.source === "agent" &&
175
+ event.stopReason !== "cancelled" &&
176
+ taskBeforeIdle.workflow_status === "running";
170
177
  if (isCurrent) {
171
- sessions.activePrompts.delete(event.sessionId);
172
- sessions.syncBusy(event.sessionId);
178
+ tasks.activePrompts.delete(event.taskId);
179
+ tasks.syncBusy(event.taskId);
173
180
  }
174
181
  else {
175
182
  clog.info("completion from a superseded turn", {
176
- sessionId: event.sessionId.slice(0, 8),
183
+ taskId: event.taskId.slice(0, 8),
177
184
  promptId: event.promptId,
178
185
  stopReason: event.stopReason,
179
186
  });
180
187
  }
181
- sessions.flushBuffers(event.sessionId);
182
- sessions.state.patch(event.sessionId, {
188
+ tasks.flushBuffers(event.taskId);
189
+ tasks.state.patch(event.taskId, {
183
190
  runtime: { streaming: { assistant: false, thinking: false } },
184
191
  });
185
- store.saveEvent(event.sessionId, event.type, {
192
+ store.saveEvent(event.taskId, event.type, {
186
193
  stopReason: event.stopReason,
187
194
  // Replay must be able to make the same judgement the live path does.
188
195
  ...(event.promptId ? { promptId: event.promptId } : {}),
189
196
  }, { from_ref: "agent" });
197
+ if (isCurrent) {
198
+ if (taskBeforeIdle?.workflow_status === "running") {
199
+ store.updateTaskWorkflowStatus(event.taskId, "idle");
200
+ }
201
+ // Defer past the synchronous prompt_done broadcast below: a busy patch
202
+ // minted by the drain must never race ahead of the finished turn's own
203
+ // terminator, or clients drop the terminator as a superseded turn and
204
+ // strand its pending tool/permission UI.
205
+ void Promise.resolve()
206
+ .then(async () => {
207
+ const drained = await tasks.drainCollaborationDeliveries(bridge, event.taskId);
208
+ if (!drained && needsHandoffReminder) {
209
+ await tasks.promptHandoffReminder(bridge, event.taskId);
210
+ }
211
+ })
212
+ .catch((error) => {
213
+ clog.warn("handoff recovery failed", {
214
+ taskId: event.taskId.slice(0, 8),
215
+ error,
216
+ });
217
+ });
218
+ }
190
219
  }
191
- function handleError(event, sessions, store) {
192
- if (event.sessionId) {
220
+ function handleError(event, tasks, store, bridge) {
221
+ if (event.taskId) {
222
+ const taskId = event.taskId;
193
223
  // Same attribution as a completion: a superseded turn failing late must
194
224
  // not end the turn that replaced it. The buffered tail still flushes.
195
- if (sessions.isCurrentPrompt(event.sessionId, event.promptId)) {
196
- sessions.activePrompts.delete(event.sessionId);
197
- sessions.syncBusy(event.sessionId);
225
+ const isCurrent = tasks.isCurrentPrompt(event.taskId, event.promptId);
226
+ const taskBeforeIdle = isCurrent ? store.getTask(event.taskId) : null;
227
+ const needsHandoffReminder = isCurrent &&
228
+ taskBeforeIdle?.source === "agent" &&
229
+ taskBeforeIdle.workflow_status === "running";
230
+ if (isCurrent) {
231
+ tasks.activePrompts.delete(event.taskId);
232
+ tasks.syncBusy(event.taskId);
198
233
  }
199
234
  else {
200
235
  clog.info("failure from a superseded turn", {
201
- sessionId: event.sessionId.slice(0, 8),
236
+ taskId: event.taskId.slice(0, 8),
202
237
  promptId: event.promptId,
203
238
  message: event.message,
204
239
  });
205
240
  }
206
- sessions.flushBuffers(event.sessionId);
207
- sessions.state.patch(event.sessionId, {
241
+ tasks.flushBuffers(event.taskId);
242
+ tasks.state.patch(event.taskId, {
208
243
  runtime: { streaming: { assistant: false, thinking: false } },
209
244
  });
210
- store.saveEvent(event.sessionId, event.type, {
245
+ store.saveEvent(event.taskId, event.type, {
211
246
  message: event.message,
212
247
  ...(event.promptId ? { promptId: event.promptId } : {}),
213
248
  }, { from_ref: "agent" });
249
+ if (isCurrent) {
250
+ if (taskBeforeIdle?.workflow_status === "running") {
251
+ store.updateTaskWorkflowStatus(event.taskId, "idle");
252
+ }
253
+ void Promise.resolve()
254
+ .then(async () => {
255
+ const drained = await tasks.drainCollaborationDeliveries(bridge, taskId);
256
+ if (!drained && needsHandoffReminder) {
257
+ await tasks.promptHandoffReminder(bridge, taskId);
258
+ }
259
+ })
260
+ .catch((recoveryError) => {
261
+ clog.warn("handoff recovery failed after agent error", {
262
+ taskId: taskId.slice(0, 8),
263
+ error: recoveryError,
264
+ });
265
+ });
266
+ }
214
267
  }
215
268
  }
216
- function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager) {
269
+ function dispatchAgentEvent(event, tasks, store, bridge, config, sseManager) {
217
270
  // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only handles events with side effects
218
271
  switch (event.type) {
219
272
  case "connected":
220
- handleConnected(event, sessions, config);
273
+ handleConnected(event, tasks, config);
221
274
  return false;
222
- case "session_created":
275
+ case "task_created":
223
276
  case "config_option_update":
224
- handleConfigLikeEvent(event, sessions);
277
+ handleConfigLikeEvent(event, tasks);
225
278
  return false;
226
279
  case "message_chunk":
227
- handleMessageChunk(event, sessions);
280
+ handleMessageChunk(event, tasks);
228
281
  return false;
229
282
  case "thought_chunk":
230
- handleThoughtChunk(event, sessions);
283
+ handleThoughtChunk(event, tasks);
231
284
  return false;
232
285
  case "tool_call":
233
- handleToolCall(event, sessions, store);
286
+ handleToolCall(event, tasks, store);
234
287
  return false;
235
288
  case "tool_call_update":
236
- store.saveEvent(event.sessionId, event.type, {
289
+ store.saveEvent(event.taskId, event.type, {
237
290
  id: event.id,
238
291
  status: event.status,
239
292
  content: event.content,
@@ -244,21 +297,21 @@ function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager)
244
297
  }, { from_ref: "agent" });
245
298
  return false;
246
299
  case "plan":
247
- handlePlan(event, sessions, store);
300
+ handlePlan(event, tasks, store);
248
301
  return false;
249
302
  case "permission_request":
250
- return handlePermissionRequest(event, sessions, store, bridge, sseManager, config);
303
+ return handlePermissionRequest(event, tasks, store, bridge, sseManager, config);
251
304
  case "prompt_done":
252
- handlePromptDone(event, sessions, store);
305
+ handlePromptDone(event, tasks, store, bridge);
253
306
  return false;
254
307
  case "error":
255
- handleError(event, sessions, store);
308
+ handleError(event, tasks, store, bridge);
256
309
  return false;
257
310
  }
258
311
  return false;
259
312
  }
260
313
  function maybePushNotify(event, pushService) {
261
- if (!("sessionId" in event) || !event.sessionId)
314
+ if (!("taskId" in event) || !event.taskId)
262
315
  return;
263
316
  const pushEvent = {
264
317
  type: event.type,
@@ -271,41 +324,41 @@ function maybePushNotify(event, pushService) {
271
324
  // bash_done has `code` not `exitCode`; command not stored in the event
272
325
  pushEvent.exitCode = event.code ?? undefined;
273
326
  }
274
- pushService.sendForEvent(event.sessionId, pushEvent).catch((err) => {
327
+ pushService.sendForEvent(event.taskId, pushEvent).catch((err) => {
275
328
  plog.error("failed to send", { error: err });
276
329
  });
277
330
  }
278
- export function handleAgentEvent(event, sessions, store, bridge, config, sseManager, pushService, _clientRegistry) {
331
+ export function handleAgentEvent(event, tasks, store, bridge, config, sseManager, pushService, _clientRegistry) {
279
332
  if (event.type === "usage_update") {
280
- handleUsageUpdate(event, sessions);
333
+ handleUsageUpdate(event, tasks);
281
334
  return;
282
335
  }
283
336
  if (event.type === "available_commands_update") {
284
- const snapshot = sessions.updateAgentCommands(event.sessionId, event.commands);
285
- if (sessions.restoringSessions.has(event.sessionId))
337
+ const snapshot = tasks.updateAgentCommands(event.taskId, event.commands);
338
+ if (tasks.restoringTasks.has(event.taskId))
286
339
  return;
287
340
  sseManager.broadcast({ ...event, ...snapshot });
288
341
  return;
289
342
  }
290
343
  if (event.type === "agent_reloading" || event.type === "agent_disconnected") {
291
- for (const sessionId of sessions.liveSessions) {
292
- sessions.flushBuffers(sessionId);
344
+ for (const taskId of tasks.liveTasks) {
345
+ tasks.flushBuffers(taskId);
293
346
  }
294
- sessions.state.clearStreaming();
295
- sessions.state.clearPlans();
296
- sessions.state.clearContextUsage();
297
- for (const snapshot of sessions.clearAgentCommands()) {
347
+ tasks.state.clearStreaming();
348
+ tasks.state.clearPlans();
349
+ tasks.state.clearContextUsage();
350
+ for (const snapshot of tasks.clearAgentCommands()) {
298
351
  sseManager.broadcast({
299
352
  type: "available_commands_update",
300
353
  ...snapshot,
301
354
  });
302
355
  }
303
356
  }
304
- if ("sessionId" in event &&
305
- event.sessionId &&
306
- sessions.restoringSessions.has(event.sessionId))
357
+ if ("taskId" in event &&
358
+ event.taskId &&
359
+ tasks.restoringTasks.has(event.taskId))
307
360
  return;
308
- const suppress = dispatchAgentEvent(event, sessions, store, bridge, config, sseManager);
361
+ const suppress = dispatchAgentEvent(event, tasks, store, bridge, config, sseManager);
309
362
  if (suppress)
310
363
  return;
311
364
  sseManager.broadcast(event);
@@ -2,7 +2,7 @@
2
2
  * File viewer HTTP routes — read-only access to arbitrary local files.
3
3
  *
4
4
  * URL space claimed: `/api/v1/files/{info,list,content}`.
5
- * Sessionless by design (confirmed): the caller passes an absolute or
5
+ * Task-less by design (confirmed): the caller passes an absolute or
6
6
  * `~`-prefixed path; the server own `~` expansion + realpath canonicalization.
7
7
  * Relative paths are rejected. Bearer auth is enforced by the shared
8
8
  * `/api/**` gate in routes.ts — these paths are deliberately NOT in the
@@ -0,0 +1,74 @@
1
+ import { randomBytes } from "node:crypto";
2
+ const CAPABILITY_PREFIX = "mcp_";
3
+ /**
4
+ * Per-task MCP capability store.
5
+ *
6
+ * Holds the mapping between opaque capability tokens handed to ACP
7
+ * `mcpServers` definitions and the WebAgent task they were minted for.
8
+ * Lifecycle (minting on task create, revocation on task delete) is
9
+ * driven exclusively by TaskManager — this class is deliberately free of
10
+ * any lifecycle logic so the two tables can never drift.
11
+ *
12
+ * Tokens never touch the persistent store: they exist only in this map and
13
+ * die with the process, so a restart invalidates every outstanding
14
+ * capability by construction.
15
+ */
16
+ export class CapabilityStore {
17
+ byToken = new Map();
18
+ byTask = new Map();
19
+ mintToken(taskId) {
20
+ const token = `${CAPABILITY_PREFIX}${randomBytes(32).toString("base64url")}`;
21
+ this.byToken.set(token, taskId);
22
+ const tokens = this.byTask.get(taskId) ?? new Set();
23
+ tokens.add(token);
24
+ this.byTask.set(taskId, tokens);
25
+ return token;
26
+ }
27
+ /** Mint a fresh capability for one task, replacing any prior one. */
28
+ mint(taskId) {
29
+ this.revokeByTask(taskId);
30
+ return this.mintToken(taskId);
31
+ }
32
+ /** Mint a replacement while keeping the current execution capability valid. */
33
+ mintAdditional(taskId) {
34
+ return this.mintToken(taskId);
35
+ }
36
+ /** Revoke one capability token (no-op when it is unknown). */
37
+ revoke(token) {
38
+ const taskId = this.byToken.get(token);
39
+ if (!taskId)
40
+ return;
41
+ this.byToken.delete(token);
42
+ const tokens = this.byTask.get(taskId);
43
+ tokens?.delete(token);
44
+ if (tokens?.size === 0)
45
+ this.byTask.delete(taskId);
46
+ }
47
+ /** Revoke all capabilities except the one used by a replacement execution. */
48
+ revokeOtherTokens(taskId, keepToken) {
49
+ for (const token of this.byTask.get(taskId) ?? []) {
50
+ if (token !== keepToken)
51
+ this.revoke(token);
52
+ }
53
+ }
54
+ /** Revoke every capability minted for a task (no-op when none exists). */
55
+ revokeByTask(taskId) {
56
+ for (const token of this.byTask.get(taskId) ?? []) {
57
+ this.byToken.delete(token);
58
+ }
59
+ this.byTask.delete(taskId);
60
+ }
61
+ /**
62
+ * Resolve a capability token to its task, or null when unknown.
63
+ * Fail-closed: any token that was never minted, was revoked, or whose
64
+ * process died resolves to null.
65
+ */
66
+ resolve(token) {
67
+ return this.byToken.get(token) ?? null;
68
+ }
69
+ /** Drop every capability (server shutdown). */
70
+ clear() {
71
+ this.byToken.clear();
72
+ this.byTask.clear();
73
+ }
74
+ }
@@ -0,0 +1,148 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { registerMcpTools } from "./tools.js";
4
+ import { HTTP_STATUS } from "../http-status.js";
5
+ /**
6
+ * WebAgent MCP server endpoint.
7
+ *
8
+ * Serves the MCP control plane for ACP sessions over Streamable HTTP.
9
+ * Each request is authenticated by the capability token minted for its
10
+ * session (carried as `Authorization: Bearer <capability>`); the endpoint
11
+ * fails closed — an unknown, revoked, or out-of-scope capability never
12
+ * reaches the MCP protocol layer.
13
+ *
14
+ * The endpoint lives outside `/api/**`, so the shared Bearer auth gate does
15
+ * not apply to it (mirroring the share viewer's `/s/*` pattern): identity is
16
+ * the per-task capability, distinct from operator UI tokens.
17
+ */
18
+ /** Uniquely-named WebAgent MCP server appended to an ACP session's mcpServers. */
19
+ export const MCP_SERVER_NAME = "webagent";
20
+ /**
21
+ * Short, transport-level usage guidance advertised through MCP initialize.
22
+ * Keep this generic and bounded: detailed workflow guidance belongs in tool
23
+ * descriptions, the Task Manual, or an on-demand skill.
24
+ */
25
+ export const MCP_SERVER_INSTRUCTIONS = [
26
+ "Use task_create for a direct child, then immediately use task_send to give it its first instruction.",
27
+ "Use task_send for normal coordination and for continuing or resuming existing Tasks; task_send is not a lifecycle handoff. Use task_update(done|blocked) for typed lifecycle handoffs. A done Task remains available and is not deleted or permanently closed.",
28
+ "After dispatching work, end the current turn; do not poll with task_query.",
29
+ "Use task_query and task_get_record only for history recovery, diagnosis, or audit.",
30
+ "Omit task_id to inspect the current Task's persisted history.",
31
+ ].join("\n");
32
+ const DEFAULT_PATH = "/mcp";
33
+ /**
34
+ * Build the ACP `McpServer` definition for one session: an HTTP entry whose
35
+ * Authorization header carries the session's freshly minted capability.
36
+ * `authBaseUrl` is the WebAgent's own origin (e.g. `http://127.0.0.1:6800`);
37
+ * the endpoint path is appended here so callers pass the base only.
38
+ */
39
+ export function buildMcpServerEntry(capability, authBaseUrl) {
40
+ const base = authBaseUrl.replace(/\/$/, "");
41
+ return {
42
+ type: "http",
43
+ name: MCP_SERVER_NAME,
44
+ url: `${base}${DEFAULT_PATH}`,
45
+ headers: [{ name: "Authorization", value: `Bearer ${capability}` }],
46
+ // ACP reserves _meta for extension metadata. pi-acp translates this
47
+ // generic direct-tools hint into the adapter's internal setting; agents
48
+ // that do not understand it can safely ignore the metadata.
49
+ _meta: { directTools: true },
50
+ };
51
+ }
52
+ function capabilityFromRequest(req) {
53
+ const raw = req.headers.authorization;
54
+ if (typeof raw !== "string")
55
+ return null;
56
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(raw.trim());
57
+ return match ? match[1] : null;
58
+ }
59
+ /**
60
+ * Create an MCP request handler. Returns a function that handles requests
61
+ * for the endpoint path and returns `false` for everything else so the main
62
+ * router can continue dispatching.
63
+ */
64
+ export function createMcpEndpoint(options) {
65
+ const path = options.path ?? DEFAULT_PATH;
66
+ const { capabilities, isTaskActive } = options;
67
+ return async (req, res) => {
68
+ const url = req.url ?? "/";
69
+ const pathname = url.split("?")[0] ?? url;
70
+ if (pathname !== path)
71
+ return false;
72
+ const method = req.method ?? "GET";
73
+ if (method !== "POST") {
74
+ // Stateless mode has no SSE stream and no session reuse, so the MCP
75
+ // client drives request/response over POST only — mirrors the SDK's
76
+ // stateless example, which rejects other methods with 405.
77
+ res.writeHead(HTTP_STATUS.METHOD_NOT_ALLOWED, {
78
+ Allow: "POST",
79
+ "Content-Type": "application/json",
80
+ });
81
+ res.end(JSON.stringify({
82
+ jsonrpc: "2.0",
83
+ error: { code: -32000, message: "Method not allowed." },
84
+ id: null,
85
+ }));
86
+ return true;
87
+ }
88
+ // --- Capability gate (fail closed) ---
89
+ const capability = capabilityFromRequest(req);
90
+ const taskId = capability ? capabilities.resolve(capability) : null;
91
+ if (!taskId || !isTaskActive(taskId)) {
92
+ res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
93
+ "Content-Type": "application/json",
94
+ "WWW-Authenticate": "Bearer",
95
+ });
96
+ res.end(JSON.stringify({
97
+ jsonrpc: "2.0",
98
+ error: { code: -32000, message: "Unauthorized" },
99
+ id: null,
100
+ }));
101
+ return true;
102
+ }
103
+ // --- MCP protocol (stateless, one server+transport per request) ---
104
+ const server = new McpServer({ name: MCP_SERVER_NAME, version: "0.1.0" }, { instructions: MCP_SERVER_INSTRUCTIONS });
105
+ registerMcpTools(server, taskId, options.taskTools);
106
+ const transport = new StreamableHTTPServerTransport({
107
+ sessionIdGenerator: undefined,
108
+ // JSON responses for POST round trips (no SSE streaming needed for the
109
+ // agent-driven request/response shape; the SDK default SSE response is
110
+ // harder for clients without an event-stream consumer).
111
+ enableJsonResponse: true,
112
+ });
113
+ try {
114
+ await server.connect(transport);
115
+ await transport.handleRequest(req, res);
116
+ // Transport already owns response handling for protocol errors; a
117
+ // thrown error here means the response was not (or only partially)
118
+ // written. Emit a JSON-RPC internal error instead of leaking details.
119
+ }
120
+ catch {
121
+ try {
122
+ if (!res.headersSent) {
123
+ res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, {
124
+ "Content-Type": "application/json",
125
+ });
126
+ res.end(JSON.stringify({
127
+ jsonrpc: "2.0",
128
+ error: { code: -32603, message: "Internal server error" },
129
+ id: null,
130
+ }));
131
+ }
132
+ else {
133
+ res.end();
134
+ }
135
+ }
136
+ catch {
137
+ // Nothing more we can do; the connection is gone.
138
+ }
139
+ }
140
+ finally {
141
+ res.once("close", () => {
142
+ void transport.close().catch(() => { });
143
+ void server.close().catch(() => { });
144
+ });
145
+ }
146
+ return true;
147
+ };
148
+ }