@lelouchhe/webagent 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -15
- package/config.toml +7 -27
- package/dist/index.html +21 -5
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +6 -6
- package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
- package/dist/sw.js +6 -6
- package/lib/agent-key.js +6 -0
- 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 +69 -7
- package/lib/auth-middleware.js +11 -4
- package/lib/auth.js +2 -2
- package/lib/bridge.js +209 -90
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +166 -85
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- 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 +1022 -475
- package/lib/server.js +84 -34
- package/lib/share/routes.js +97 -85
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +992 -284
- 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} +90 -38
- 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 +8 -1
- package/dist/js/app.XBFXH37R.js +0 -2
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.CVWXSKJM.js +0 -1
- package/lib/session-manager.js +0 -613
- package/lib/title-service.js +0 -95
package/lib/config.js
CHANGED
|
@@ -20,7 +20,7 @@ export const ConfigSchema = z.object({
|
|
|
20
20
|
image_upload: z.number().int().positive().default(10_485_760), // 10 MB
|
|
21
21
|
file_upload: z.number().int().positive().default(52_428_800), // 50 MB — non-image attachments
|
|
22
22
|
cancel_timeout: z.number().int().nonnegative().default(10_000), // 10s; 0 disables
|
|
23
|
-
recent_paths: z.number().int().nonnegative().default(10), // /new
|
|
23
|
+
recent_paths: z.number().int().nonnegative().default(10), // /new + path-picker display limit; 0 = show all
|
|
24
24
|
recent_paths_ttl: z.number().int().nonnegative().default(30), // days before auto-cleanup; 0 = keep forever
|
|
25
25
|
})
|
|
26
26
|
.default({
|
|
@@ -40,35 +40,6 @@ export const ConfigSchema = z.object({
|
|
|
40
40
|
vapid_subject: "mailto:noreply@example.com",
|
|
41
41
|
global_visibility_suppression: true,
|
|
42
42
|
}),
|
|
43
|
-
// [title] — title generation sub-session configuration.
|
|
44
|
-
//
|
|
45
|
-
// `models` is an array of case-insensitive substring patterns. When the
|
|
46
|
-
// title sub-session is created, we look at the model list the agent
|
|
47
|
-
// reports (ACP `availableModels`) and pick the first model whose id
|
|
48
|
-
// matches any pattern in order. Match → call `setConfigOption` with
|
|
49
|
-
// that model id; no match → skip the call and inherit the agent's
|
|
50
|
-
// default model (`currentModelId`).
|
|
51
|
-
//
|
|
52
|
-
// Default list targets the cheap/fast tier across major providers:
|
|
53
|
-
// - "haiku" → Anthropic (claude-haiku-*)
|
|
54
|
-
// - "flash-lite" → Google Gemini (gemini-*-flash-lite) [must precede "flash"]
|
|
55
|
-
// - "nano" → OpenAI (gpt-*-nano), Gemini Nano
|
|
56
|
-
// - "mini" → OpenAI (gpt-*-mini, 4o-mini), Mistral
|
|
57
|
-
// - "flash" → Google Gemini (gemini-*-flash)
|
|
58
|
-
// - "lite" → Cohere, generic
|
|
59
|
-
//
|
|
60
|
-
// Set `models = []` to disable substring matching entirely and always
|
|
61
|
-
// inherit the agent's default model. To pin one specific model, pass a
|
|
62
|
-
// single-element array: `models = ["claude-haiku-4.5"]`.
|
|
63
|
-
title: z
|
|
64
|
-
.object({
|
|
65
|
-
models: z
|
|
66
|
-
.array(z.string())
|
|
67
|
-
.default(["haiku", "flash-lite", "nano", "mini", "flash", "lite"]),
|
|
68
|
-
})
|
|
69
|
-
.default({
|
|
70
|
-
models: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
|
|
71
|
-
}),
|
|
72
43
|
// [debug] — frontend log level.
|
|
73
44
|
// level ∈ off | debug | info | warn | error. Default "off".
|
|
74
45
|
// Users can override per page-load via `?debug=<level>` in the URL,
|
|
@@ -86,7 +57,7 @@ export const ConfigSchema = z.object({
|
|
|
86
57
|
unprocessed_ttl_days: z.number().int().nonnegative().default(30),
|
|
87
58
|
})
|
|
88
59
|
.default({ unprocessed_ttl_days: 30 }),
|
|
89
|
-
// [share] — public read-only
|
|
60
|
+
// [share] — public read-only task share links.
|
|
90
61
|
// Default: disabled. Dogfood manually flips `enabled = true` after
|
|
91
62
|
// CF Access bypass + Rate Limiting are configured. See docs/share.md.
|
|
92
63
|
// enabled — master kill switch; when false, all share routes
|
package/lib/event-handler.js
CHANGED
|
@@ -4,45 +4,56 @@ 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
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
function handleUsageUpdate(event, tasks) {
|
|
44
|
+
tasks.state.patch(event.taskId, {
|
|
45
|
+
runtime: {
|
|
46
|
+
contextUsage: {
|
|
47
|
+
used: event.used,
|
|
48
|
+
size: event.size,
|
|
49
|
+
...(event.cost !== undefined ? { cost: event.cost } : {}),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function handlePlan(event, tasks, store) {
|
|
55
|
+
tasks.flushBuffers(event.taskId);
|
|
56
|
+
tasks.state.patch(event.taskId, {
|
|
46
57
|
runtime: {
|
|
47
58
|
streaming: { assistant: false, thinking: false },
|
|
48
59
|
plan: event.entries.length > 0 &&
|
|
@@ -51,14 +62,14 @@ function handlePlan(event, sessions, store) {
|
|
|
51
62
|
: null,
|
|
52
63
|
},
|
|
53
64
|
});
|
|
54
|
-
store.saveEvent(event.
|
|
65
|
+
store.saveEvent(event.taskId, event.type, { entries: event.entries }, { from_ref: "agent" });
|
|
55
66
|
}
|
|
56
|
-
function performAutoApprove(event, opt,
|
|
67
|
+
function performAutoApprove(event, opt, tasks, store, bridge, sseManager, broadcastRequest) {
|
|
57
68
|
bridge.resolvePermission(event.requestId, opt.optionId);
|
|
58
|
-
|
|
59
|
-
|
|
69
|
+
tasks.pendingPermissions.delete(event.requestId);
|
|
70
|
+
tasks.syncPendingPermissions(event.taskId);
|
|
60
71
|
const optionName = opt.label ?? opt.optionId;
|
|
61
|
-
store.saveEvent(event.
|
|
72
|
+
store.saveEvent(event.taskId, "permission_response", {
|
|
62
73
|
requestId: event.requestId,
|
|
63
74
|
optionName,
|
|
64
75
|
denied: false,
|
|
@@ -67,23 +78,23 @@ function performAutoApprove(event, opt, sessions, store, bridge, sseManager, bro
|
|
|
67
78
|
sseManager.broadcast(event);
|
|
68
79
|
sseManager.broadcast({
|
|
69
80
|
type: "permission_response",
|
|
70
|
-
|
|
81
|
+
taskId: event.taskId,
|
|
71
82
|
requestId: event.requestId,
|
|
72
83
|
optionName,
|
|
73
84
|
denied: false,
|
|
74
85
|
});
|
|
75
86
|
}
|
|
76
|
-
function maybeAutoApprovePermission(event,
|
|
77
|
-
const mode = store.
|
|
87
|
+
function maybeAutoApprovePermission(event, tasks, store, bridge, sseManager) {
|
|
88
|
+
const mode = store.getTask(event.taskId)?.mode ?? "";
|
|
78
89
|
if (!isAutopilotMode(mode))
|
|
79
90
|
return false;
|
|
80
91
|
const opt = event.options.find((o) => o.kind === "allow_once");
|
|
81
92
|
if (!opt)
|
|
82
93
|
return false;
|
|
83
|
-
performAutoApprove(event, opt,
|
|
94
|
+
performAutoApprove(event, opt, tasks, store, bridge, sseManager, true);
|
|
84
95
|
return true;
|
|
85
96
|
}
|
|
86
|
-
function maybeAutoApproveAttachmentRead(event,
|
|
97
|
+
function maybeAutoApproveAttachmentRead(event, tasks, store, bridge, sseManager, config) {
|
|
87
98
|
// Plan §1.4 — async attachment-read auto-approve runs *after* the
|
|
88
99
|
// permission_request has already been broadcast (so the UI shows it
|
|
89
100
|
// briefly), then if the request matches we follow up with a
|
|
@@ -95,7 +106,7 @@ function maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManag
|
|
|
95
106
|
if (!opt)
|
|
96
107
|
return;
|
|
97
108
|
void shouldAutoApproveAttachmentRead({
|
|
98
|
-
|
|
109
|
+
taskId: event.taskId,
|
|
99
110
|
toolKind: event.toolKind,
|
|
100
111
|
toolName: event.toolName,
|
|
101
112
|
locations: event.locations,
|
|
@@ -110,132 +121,197 @@ function maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManag
|
|
|
110
121
|
return;
|
|
111
122
|
// Race guard: the user (or another client) may have already
|
|
112
123
|
// resolved the permission while we were realpath-ing.
|
|
113
|
-
if (!
|
|
124
|
+
if (!tasks.pendingPermissions.has(event.requestId))
|
|
114
125
|
return;
|
|
115
|
-
performAutoApprove(event, opt,
|
|
126
|
+
performAutoApprove(event, opt, tasks, store, bridge, sseManager, false);
|
|
116
127
|
}, (err) => {
|
|
117
128
|
ailog.warn("unexpected error", { error: err.message });
|
|
118
129
|
});
|
|
119
130
|
}
|
|
120
|
-
function handlePermissionRequest(event,
|
|
121
|
-
|
|
122
|
-
|
|
131
|
+
function handlePermissionRequest(event, tasks, store, bridge, sseManager, config) {
|
|
132
|
+
tasks.flushBuffers(event.taskId);
|
|
133
|
+
tasks.state.patch(event.taskId, {
|
|
123
134
|
runtime: { streaming: { assistant: false, thinking: false } },
|
|
124
135
|
});
|
|
125
|
-
store.saveEvent(event.
|
|
136
|
+
store.saveEvent(event.taskId, event.type, {
|
|
126
137
|
requestId: event.requestId,
|
|
127
138
|
title: event.title,
|
|
128
139
|
options: event.options,
|
|
129
140
|
}, { from_ref: "agent" });
|
|
130
|
-
|
|
141
|
+
tasks.pendingPermissions.set(event.requestId, {
|
|
131
142
|
requestId: event.requestId,
|
|
132
|
-
|
|
143
|
+
taskId: event.taskId,
|
|
133
144
|
title: event.title,
|
|
134
145
|
options: event.options.map((o) => ({
|
|
135
146
|
optionId: o.optionId,
|
|
136
147
|
label: o.label ?? o.name ?? o.optionId,
|
|
137
148
|
})),
|
|
138
149
|
});
|
|
139
|
-
|
|
140
|
-
const autopiloted = maybeAutoApprovePermission(event,
|
|
150
|
+
tasks.syncPendingPermissions(event.taskId);
|
|
151
|
+
const autopiloted = maybeAutoApprovePermission(event, tasks, store, bridge, sseManager);
|
|
141
152
|
if (autopiloted)
|
|
142
153
|
return true;
|
|
143
154
|
// Async attachment-read auto-approve runs after the request broadcasts.
|
|
144
|
-
maybeAutoApproveAttachmentRead(event,
|
|
155
|
+
maybeAutoApproveAttachmentRead(event, tasks, store, bridge, sseManager, config);
|
|
145
156
|
return false;
|
|
146
157
|
}
|
|
147
|
-
function handlePromptDone(event,
|
|
148
|
-
const cancelStatus =
|
|
158
|
+
function handlePromptDone(event, tasks, store, bridge) {
|
|
159
|
+
const cancelStatus = tasks.state.getState(event.taskId).runtime.busy?.cancelStatus ?? null;
|
|
149
160
|
if (cancelStatus !== null) {
|
|
150
161
|
clog.info("agent completed after request", {
|
|
151
|
-
|
|
162
|
+
taskId: event.taskId.slice(0, 8),
|
|
152
163
|
requestedStatus: cancelStatus,
|
|
153
164
|
stopReason: event.stopReason,
|
|
154
165
|
});
|
|
155
166
|
}
|
|
156
167
|
// The tail this turn buffered must always land, even when the turn has
|
|
157
168
|
// already been superseded — it is the only copy of that text.
|
|
158
|
-
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";
|
|
159
177
|
if (isCurrent) {
|
|
160
|
-
|
|
161
|
-
|
|
178
|
+
tasks.activePrompts.delete(event.taskId);
|
|
179
|
+
tasks.syncBusy(event.taskId);
|
|
162
180
|
}
|
|
163
181
|
else {
|
|
164
182
|
clog.info("completion from a superseded turn", {
|
|
165
|
-
|
|
183
|
+
taskId: event.taskId.slice(0, 8),
|
|
166
184
|
promptId: event.promptId,
|
|
167
185
|
stopReason: event.stopReason,
|
|
168
186
|
});
|
|
169
187
|
}
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
tasks.flushBuffers(event.taskId);
|
|
189
|
+
tasks.state.patch(event.taskId, {
|
|
172
190
|
runtime: { streaming: { assistant: false, thinking: false } },
|
|
173
191
|
});
|
|
174
|
-
store.saveEvent(event.
|
|
192
|
+
store.saveEvent(event.taskId, event.type, {
|
|
175
193
|
stopReason: event.stopReason,
|
|
176
194
|
// Replay must be able to make the same judgement the live path does.
|
|
177
195
|
...(event.promptId ? { promptId: event.promptId } : {}),
|
|
178
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
|
+
}
|
|
179
219
|
}
|
|
180
|
-
function handleError(event,
|
|
181
|
-
if (event.
|
|
220
|
+
function handleError(event, tasks, store, bridge) {
|
|
221
|
+
if (event.taskId) {
|
|
222
|
+
const taskId = event.taskId;
|
|
182
223
|
// Same attribution as a completion: a superseded turn failing late must
|
|
183
224
|
// not end the turn that replaced it. The buffered tail still flushes.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
233
|
}
|
|
188
234
|
else {
|
|
189
235
|
clog.info("failure from a superseded turn", {
|
|
190
|
-
|
|
236
|
+
taskId: event.taskId.slice(0, 8),
|
|
191
237
|
promptId: event.promptId,
|
|
192
238
|
message: event.message,
|
|
193
239
|
});
|
|
194
240
|
}
|
|
195
|
-
|
|
196
|
-
|
|
241
|
+
tasks.flushBuffers(event.taskId);
|
|
242
|
+
tasks.state.patch(event.taskId, {
|
|
197
243
|
runtime: { streaming: { assistant: false, thinking: false } },
|
|
198
244
|
});
|
|
245
|
+
store.saveEvent(event.taskId, event.type, {
|
|
246
|
+
message: event.message,
|
|
247
|
+
...(event.promptId ? { promptId: event.promptId } : {}),
|
|
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
|
+
}
|
|
199
267
|
}
|
|
200
268
|
}
|
|
201
|
-
function dispatchAgentEvent(event,
|
|
269
|
+
function dispatchAgentEvent(event, tasks, store, bridge, config, sseManager) {
|
|
202
270
|
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only handles events with side effects
|
|
203
271
|
switch (event.type) {
|
|
204
272
|
case "connected":
|
|
205
|
-
handleConnected(event,
|
|
273
|
+
handleConnected(event, tasks, config);
|
|
206
274
|
return false;
|
|
207
|
-
case "
|
|
275
|
+
case "task_created":
|
|
208
276
|
case "config_option_update":
|
|
209
|
-
handleConfigLikeEvent(event,
|
|
277
|
+
handleConfigLikeEvent(event, tasks);
|
|
210
278
|
return false;
|
|
211
279
|
case "message_chunk":
|
|
212
|
-
handleMessageChunk(event,
|
|
280
|
+
handleMessageChunk(event, tasks);
|
|
213
281
|
return false;
|
|
214
282
|
case "thought_chunk":
|
|
215
|
-
handleThoughtChunk(event,
|
|
283
|
+
handleThoughtChunk(event, tasks);
|
|
216
284
|
return false;
|
|
217
285
|
case "tool_call":
|
|
218
|
-
handleToolCall(event,
|
|
286
|
+
handleToolCall(event, tasks, store);
|
|
219
287
|
return false;
|
|
220
288
|
case "tool_call_update":
|
|
221
|
-
store.saveEvent(event.
|
|
289
|
+
store.saveEvent(event.taskId, event.type, {
|
|
290
|
+
id: event.id,
|
|
291
|
+
status: event.status,
|
|
292
|
+
content: event.content,
|
|
293
|
+
title: event.title,
|
|
294
|
+
kind: event.kind,
|
|
295
|
+
rawInput: event.rawInput,
|
|
296
|
+
locations: event.locations,
|
|
297
|
+
}, { from_ref: "agent" });
|
|
222
298
|
return false;
|
|
223
299
|
case "plan":
|
|
224
|
-
handlePlan(event,
|
|
300
|
+
handlePlan(event, tasks, store);
|
|
225
301
|
return false;
|
|
226
302
|
case "permission_request":
|
|
227
|
-
return handlePermissionRequest(event,
|
|
303
|
+
return handlePermissionRequest(event, tasks, store, bridge, sseManager, config);
|
|
228
304
|
case "prompt_done":
|
|
229
|
-
handlePromptDone(event,
|
|
305
|
+
handlePromptDone(event, tasks, store, bridge);
|
|
230
306
|
return false;
|
|
231
307
|
case "error":
|
|
232
|
-
handleError(event,
|
|
308
|
+
handleError(event, tasks, store, bridge);
|
|
233
309
|
return false;
|
|
234
310
|
}
|
|
235
311
|
return false;
|
|
236
312
|
}
|
|
237
313
|
function maybePushNotify(event, pushService) {
|
|
238
|
-
if (!("
|
|
314
|
+
if (!("taskId" in event) || !event.taskId)
|
|
239
315
|
return;
|
|
240
316
|
const pushEvent = {
|
|
241
317
|
type: event.type,
|
|
@@ -248,36 +324,41 @@ function maybePushNotify(event, pushService) {
|
|
|
248
324
|
// bash_done has `code` not `exitCode`; command not stored in the event
|
|
249
325
|
pushEvent.exitCode = event.code ?? undefined;
|
|
250
326
|
}
|
|
251
|
-
pushService.sendForEvent(event.
|
|
327
|
+
pushService.sendForEvent(event.taskId, pushEvent).catch((err) => {
|
|
252
328
|
plog.error("failed to send", { error: err });
|
|
253
329
|
});
|
|
254
330
|
}
|
|
255
|
-
export function handleAgentEvent(event,
|
|
331
|
+
export function handleAgentEvent(event, tasks, store, bridge, config, sseManager, pushService, _clientRegistry) {
|
|
332
|
+
if (event.type === "usage_update") {
|
|
333
|
+
handleUsageUpdate(event, tasks);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
256
336
|
if (event.type === "available_commands_update") {
|
|
257
|
-
const snapshot =
|
|
258
|
-
if (
|
|
337
|
+
const snapshot = tasks.updateAgentCommands(event.taskId, event.commands);
|
|
338
|
+
if (tasks.restoringTasks.has(event.taskId))
|
|
259
339
|
return;
|
|
260
340
|
sseManager.broadcast({ ...event, ...snapshot });
|
|
261
341
|
return;
|
|
262
342
|
}
|
|
263
343
|
if (event.type === "agent_reloading" || event.type === "agent_disconnected") {
|
|
264
|
-
for (const
|
|
265
|
-
|
|
344
|
+
for (const taskId of tasks.liveTasks) {
|
|
345
|
+
tasks.flushBuffers(taskId);
|
|
266
346
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
347
|
+
tasks.state.clearStreaming();
|
|
348
|
+
tasks.state.clearPlans();
|
|
349
|
+
tasks.state.clearContextUsage();
|
|
350
|
+
for (const snapshot of tasks.clearAgentCommands()) {
|
|
270
351
|
sseManager.broadcast({
|
|
271
352
|
type: "available_commands_update",
|
|
272
353
|
...snapshot,
|
|
273
354
|
});
|
|
274
355
|
}
|
|
275
356
|
}
|
|
276
|
-
if ("
|
|
277
|
-
event.
|
|
278
|
-
|
|
357
|
+
if ("taskId" in event &&
|
|
358
|
+
event.taskId &&
|
|
359
|
+
tasks.restoringTasks.has(event.taskId))
|
|
279
360
|
return;
|
|
280
|
-
const suppress = dispatchAgentEvent(event,
|
|
361
|
+
const suppress = dispatchAgentEvent(event, tasks, store, bridge, config, sseManager);
|
|
281
362
|
if (suppress)
|
|
282
363
|
return;
|
|
283
364
|
sseManager.broadcast(event);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File viewer size limits.
|
|
3
|
+
*
|
|
4
|
+
* Kept as plain exported constants for now so the routes have a single,
|
|
5
|
+
* testable knob; wiring these into `[limits]` config is a later milestone.
|
|
6
|
+
* Values stay in the same order of magnitude as attachment limits and keep
|
|
7
|
+
* rendered previews bounded. Files outside preview limits stream as downloads
|
|
8
|
+
* instead of being buffered in server or browser memory.
|
|
9
|
+
*/
|
|
10
|
+
/** Directory listings cap — beyond this the response is truncated + flagged. */
|
|
11
|
+
export const MAX_LIST_ITEMS = 2000;
|
|
12
|
+
/** Text/Markdown/code preview + highlight cap; larger files download. */
|
|
13
|
+
export const MAX_TEXT_PREVIEW_BYTES = 1024 * 1024; // 1 MiB
|
|
14
|
+
/** Image render cap — larger images stream as downloads. */
|
|
15
|
+
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution and directory listing helpers for the file viewer.
|
|
3
|
+
*
|
|
4
|
+
* Contract (confirmed design): the viewer accepts *absolute* paths or `~`
|
|
5
|
+
* prefixes only — relative paths are rejected outright so there is no base
|
|
6
|
+
* ambiguity to exploit. `~` expands to HOME, then `realpath` canonicalizes
|
|
7
|
+
* symlinks and `..` segments once; every path on the system is legal by
|
|
8
|
+
* design (single-owner personal tool), so there is deliberately no escape
|
|
9
|
+
* rejection logic — the guards here prevent blocking on special files,
|
|
10
|
+
* unbounded directory scans, and whole-file buffering.
|
|
11
|
+
*/
|
|
12
|
+
import { constants } from "node:fs";
|
|
13
|
+
import { open, opendir, realpath, stat, } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { isAbsolute, join, basename } from "node:path";
|
|
16
|
+
import { expandHomePath } from "../home-path.js";
|
|
17
|
+
import { MAX_LIST_ITEMS } from "./limits.js";
|
|
18
|
+
/** HTTP status-backed error; routes map it to a JSON error response. */
|
|
19
|
+
export class FilePathError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
constructor(status, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Expand a user-supplied path string into an absolute path.
|
|
28
|
+
* Accepts `/absolute/path` and `~/...` / `~`. Everything else (including
|
|
29
|
+
* `~user` and bare relative paths) is rejected with 400.
|
|
30
|
+
*/
|
|
31
|
+
export function expandPath(raw, home = homedir()) {
|
|
32
|
+
if (raw.length === 0)
|
|
33
|
+
throw new FilePathError(400, "Missing path");
|
|
34
|
+
if (raw.includes("\0"))
|
|
35
|
+
throw new FilePathError(400, "Invalid path");
|
|
36
|
+
const expanded = expandHomePath(raw, home);
|
|
37
|
+
if (raw.startsWith("~") && expanded === raw) {
|
|
38
|
+
throw new FilePathError(400, "Unsupported ~user expansion");
|
|
39
|
+
}
|
|
40
|
+
if (!isAbsolute(expanded)) {
|
|
41
|
+
throw new FilePathError(400, "Path must be absolute or start with ~");
|
|
42
|
+
}
|
|
43
|
+
return expanded;
|
|
44
|
+
}
|
|
45
|
+
/** realpath canonicalization; missing paths map to 404. */
|
|
46
|
+
export async function canonicalize(target) {
|
|
47
|
+
try {
|
|
48
|
+
return await realpath(target);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.code === "ENOENT") {
|
|
52
|
+
throw new FilePathError(404, "Path does not exist");
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** stat a canonical path; non-regular files (fifo/socket/device) → 400. */
|
|
58
|
+
export async function statMeta(path) {
|
|
59
|
+
const s = await stat(path);
|
|
60
|
+
let kind;
|
|
61
|
+
if (s.isFile())
|
|
62
|
+
kind = "file";
|
|
63
|
+
else if (s.isDirectory())
|
|
64
|
+
kind = "dir";
|
|
65
|
+
else
|
|
66
|
+
throw new FilePathError(400, "Not a regular file or directory");
|
|
67
|
+
return {
|
|
68
|
+
path,
|
|
69
|
+
name: basename(path),
|
|
70
|
+
kind,
|
|
71
|
+
size: s.size,
|
|
72
|
+
mtime: s.mtimeMs,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* List one directory: scan at most MAX_LIST_ITEMS plus one sentinel raw entry,
|
|
77
|
+
* omit dotfiles/special nodes, then sort the bounded result dirs-first and
|
|
78
|
+
* lexicographically. Entries that vanish mid-list are skipped.
|
|
79
|
+
*/
|
|
80
|
+
export async function listDirectory(target) {
|
|
81
|
+
const dir = await opendir(target);
|
|
82
|
+
const entries = [];
|
|
83
|
+
let scanned = 0;
|
|
84
|
+
let truncated = false;
|
|
85
|
+
// Dir's async iterator closes the descriptor both at EOF and on break.
|
|
86
|
+
for await (const entry of dir) {
|
|
87
|
+
scanned++;
|
|
88
|
+
// Read one sentinel beyond the cap so `truncated` is authoritative,
|
|
89
|
+
// but never materialize/sort an unbounded directory.
|
|
90
|
+
if (scanned > MAX_LIST_ITEMS) {
|
|
91
|
+
truncated = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (entry.name.startsWith("."))
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
const s = await stat(join(target, entry.name));
|
|
98
|
+
// Only expose targets the viewer can actually open. Symlinks to
|
|
99
|
+
// regular files/dirs pass because stat follows them; fifos, sockets,
|
|
100
|
+
// and devices are omitted rather than mislabelled as files.
|
|
101
|
+
if (!s.isDirectory() && !s.isFile())
|
|
102
|
+
continue;
|
|
103
|
+
entries.push({
|
|
104
|
+
name: entry.name,
|
|
105
|
+
kind: s.isDirectory() ? "dir" : "file",
|
|
106
|
+
size: s.isFile() ? s.size : null,
|
|
107
|
+
mtime: s.mtimeMs,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Raced deletion — skip rather than fail the whole listing.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
entries.sort(compareEntries);
|
|
115
|
+
return { entries, truncated };
|
|
116
|
+
}
|
|
117
|
+
function compareEntries(a, b) {
|
|
118
|
+
if (a.kind !== b.kind)
|
|
119
|
+
return a.kind === "dir" ? -1 : 1;
|
|
120
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
121
|
+
}
|
|
122
|
+
/** Read up to `n` bytes from the head of a regular file (mime sniffing). */
|
|
123
|
+
export async function readHead(file, n = 4096) {
|
|
124
|
+
const h = await open(file, "r");
|
|
125
|
+
try {
|
|
126
|
+
const st = await h.stat();
|
|
127
|
+
const len = Math.min(st.size, n);
|
|
128
|
+
const buf = Buffer.alloc(len);
|
|
129
|
+
if (len > 0)
|
|
130
|
+
await h.read(buf, 0, len, 0);
|
|
131
|
+
return buf;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await h.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Open without following a final symlink and without blocking on a FIFO.
|
|
139
|
+
* Windows lacks equivalent POSIX flags; fstat below remains authoritative.
|
|
140
|
+
*/
|
|
141
|
+
export function openForStreaming(file) {
|
|
142
|
+
const safeFlags = process.platform === "win32"
|
|
143
|
+
? constants.O_RDONLY
|
|
144
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
145
|
+
return open(file, safeFlags);
|
|
146
|
+
}
|
|
147
|
+
/** Read mime-sniff bytes from an already validated descriptor. */
|
|
148
|
+
export async function readHandleHead(handle, size, n = 4096) {
|
|
149
|
+
const len = Math.min(size, n);
|
|
150
|
+
const buf = Buffer.alloc(len);
|
|
151
|
+
if (len === 0)
|
|
152
|
+
return buf;
|
|
153
|
+
const { bytesRead } = await handle.read(buf, 0, len, 0);
|
|
154
|
+
return buf.subarray(0, bytesRead);
|
|
155
|
+
}
|