@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.
- package/README.md +39 -14
- package/config.toml +7 -27
- package/dist/index.html +4 -4
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +5 -5
- package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
- package/dist/sw.js +6 -6
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +25 -0
- package/lib/auth-middleware.js +2 -2
- package/lib/auth.js +2 -2
- package/lib/bridge.js +109 -83
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +143 -90
- package/lib/files/routes.js +1 -1
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +947 -489
- package/lib/server.js +64 -16
- package/lib/share/routes.js +88 -88
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +941 -314
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +64 -41
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +7 -1
- package/dist/js/app.QC7IRDTP.js +0 -5
- package/dist/js/viewer.GP5VXAUY.js +0 -1
- package/lib/session-manager.js +0 -638
- package/lib/title-service.js +0 -95
package/lib/event-handler.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
12
|
+
tasks.agentInfo = event.agent;
|
|
13
13
|
}
|
|
14
|
-
function handleConfigLikeEvent(event,
|
|
15
|
-
|
|
14
|
+
function handleConfigLikeEvent(event, tasks) {
|
|
15
|
+
tasks.recordConfigOptions(event.taskId, event.configOptions);
|
|
16
16
|
}
|
|
17
|
-
function handleMessageChunk(event,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
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,
|
|
44
|
-
|
|
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,
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
65
|
+
store.saveEvent(event.taskId, event.type, { entries: event.entries }, { from_ref: "agent" });
|
|
66
66
|
}
|
|
67
|
-
function performAutoApprove(event, opt,
|
|
67
|
+
function performAutoApprove(event, opt, tasks, store, bridge, sseManager, broadcastRequest) {
|
|
68
68
|
bridge.resolvePermission(event.requestId, opt.optionId);
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
tasks.pendingPermissions.delete(event.requestId);
|
|
70
|
+
tasks.syncPendingPermissions(event.taskId);
|
|
71
71
|
const optionName = opt.label ?? opt.optionId;
|
|
72
|
-
store.saveEvent(event.
|
|
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
|
-
|
|
81
|
+
taskId: event.taskId,
|
|
82
82
|
requestId: event.requestId,
|
|
83
83
|
optionName,
|
|
84
84
|
denied: false,
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
-
function maybeAutoApprovePermission(event,
|
|
88
|
-
const mode = store.
|
|
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,
|
|
94
|
+
performAutoApprove(event, opt, tasks, store, bridge, sseManager, true);
|
|
95
95
|
return true;
|
|
96
96
|
}
|
|
97
|
-
function maybeAutoApproveAttachmentRead(event,
|
|
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
|
-
|
|
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 (!
|
|
124
|
+
if (!tasks.pendingPermissions.has(event.requestId))
|
|
125
125
|
return;
|
|
126
|
-
performAutoApprove(event, opt,
|
|
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,
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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
|
-
|
|
141
|
+
tasks.pendingPermissions.set(event.requestId, {
|
|
142
142
|
requestId: event.requestId,
|
|
143
|
-
|
|
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
|
-
|
|
151
|
-
const autopiloted = maybeAutoApprovePermission(event,
|
|
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,
|
|
155
|
+
maybeAutoApproveAttachmentRead(event, tasks, store, bridge, sseManager, config);
|
|
156
156
|
return false;
|
|
157
157
|
}
|
|
158
|
-
function handlePromptDone(event,
|
|
159
|
-
const cancelStatus =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
183
|
+
taskId: event.taskId.slice(0, 8),
|
|
177
184
|
promptId: event.promptId,
|
|
178
185
|
stopReason: event.stopReason,
|
|
179
186
|
});
|
|
180
187
|
}
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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,
|
|
192
|
-
if (event.
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
236
|
+
taskId: event.taskId.slice(0, 8),
|
|
202
237
|
promptId: event.promptId,
|
|
203
238
|
message: event.message,
|
|
204
239
|
});
|
|
205
240
|
}
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
273
|
+
handleConnected(event, tasks, config);
|
|
221
274
|
return false;
|
|
222
|
-
case "
|
|
275
|
+
case "task_created":
|
|
223
276
|
case "config_option_update":
|
|
224
|
-
handleConfigLikeEvent(event,
|
|
277
|
+
handleConfigLikeEvent(event, tasks);
|
|
225
278
|
return false;
|
|
226
279
|
case "message_chunk":
|
|
227
|
-
handleMessageChunk(event,
|
|
280
|
+
handleMessageChunk(event, tasks);
|
|
228
281
|
return false;
|
|
229
282
|
case "thought_chunk":
|
|
230
|
-
handleThoughtChunk(event,
|
|
283
|
+
handleThoughtChunk(event, tasks);
|
|
231
284
|
return false;
|
|
232
285
|
case "tool_call":
|
|
233
|
-
handleToolCall(event,
|
|
286
|
+
handleToolCall(event, tasks, store);
|
|
234
287
|
return false;
|
|
235
288
|
case "tool_call_update":
|
|
236
|
-
store.saveEvent(event.
|
|
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,
|
|
300
|
+
handlePlan(event, tasks, store);
|
|
248
301
|
return false;
|
|
249
302
|
case "permission_request":
|
|
250
|
-
return handlePermissionRequest(event,
|
|
303
|
+
return handlePermissionRequest(event, tasks, store, bridge, sseManager, config);
|
|
251
304
|
case "prompt_done":
|
|
252
|
-
handlePromptDone(event,
|
|
305
|
+
handlePromptDone(event, tasks, store, bridge);
|
|
253
306
|
return false;
|
|
254
307
|
case "error":
|
|
255
|
-
handleError(event,
|
|
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 (!("
|
|
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.
|
|
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,
|
|
331
|
+
export function handleAgentEvent(event, tasks, store, bridge, config, sseManager, pushService, _clientRegistry) {
|
|
279
332
|
if (event.type === "usage_update") {
|
|
280
|
-
handleUsageUpdate(event,
|
|
333
|
+
handleUsageUpdate(event, tasks);
|
|
281
334
|
return;
|
|
282
335
|
}
|
|
283
336
|
if (event.type === "available_commands_update") {
|
|
284
|
-
const snapshot =
|
|
285
|
-
if (
|
|
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
|
|
292
|
-
|
|
344
|
+
for (const taskId of tasks.liveTasks) {
|
|
345
|
+
tasks.flushBuffers(taskId);
|
|
293
346
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
for (const snapshot of
|
|
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 ("
|
|
305
|
-
event.
|
|
306
|
-
|
|
357
|
+
if ("taskId" in event &&
|
|
358
|
+
event.taskId &&
|
|
359
|
+
tasks.restoringTasks.has(event.taskId))
|
|
307
360
|
return;
|
|
308
|
-
const suppress = dispatchAgentEvent(event,
|
|
361
|
+
const suppress = dispatchAgentEvent(event, tasks, store, bridge, config, sseManager);
|
|
309
362
|
if (suppress)
|
|
310
363
|
return;
|
|
311
364
|
sseManager.broadcast(event);
|
package/lib/files/routes.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
+
}
|