@lelouchhe/webagent 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +43 -15
  2. package/config.toml +7 -27
  3. package/dist/index.html +21 -5
  4. package/dist/js/app.INIQQEGD.js +5 -0
  5. package/dist/js/chunk.3CLGCUHW.js +1 -0
  6. package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
  7. package/dist/js/chunk.AOTG3PL7.js +20 -0
  8. package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
  9. package/dist/js/viewer.RHZMFYWJ.js +1 -0
  10. package/dist/login.html +2 -2
  11. package/dist/share-viewer.html +6 -6
  12. package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
  13. package/dist/sw.js +6 -6
  14. package/lib/agent-key.js +6 -0
  15. package/lib/attachment-dispatch.js +60 -31
  16. package/lib/attachment-interceptor.js +7 -7
  17. package/lib/attachment-labels.js +1 -1
  18. package/lib/attachments.js +69 -7
  19. package/lib/auth-middleware.js +11 -4
  20. package/lib/auth.js +2 -2
  21. package/lib/bridge.js +209 -90
  22. package/lib/client-registry.js +12 -12
  23. package/lib/config.js +2 -31
  24. package/lib/event-handler.js +166 -85
  25. package/lib/files/limits.js +15 -0
  26. package/lib/files/paths.js +155 -0
  27. package/lib/files/routes.js +232 -0
  28. package/lib/home-path.js +35 -0
  29. package/lib/http-status.js +1 -0
  30. package/lib/mcp/capability.js +74 -0
  31. package/lib/mcp/server.js +148 -0
  32. package/lib/mcp/task-history.js +245 -0
  33. package/lib/mcp/task-host.js +253 -0
  34. package/lib/mcp/tools.js +168 -0
  35. package/lib/mode-bucket.js +1 -1
  36. package/lib/push-service.js +33 -35
  37. package/lib/routes.js +1022 -475
  38. package/lib/server.js +84 -34
  39. package/lib/share/routes.js +97 -85
  40. package/lib/shared/task-reference.js +20 -0
  41. package/lib/sse-manager.js +8 -8
  42. package/lib/store.js +992 -284
  43. package/lib/task-collaboration.js +15 -0
  44. package/lib/task-manager.js +1409 -0
  45. package/lib/task-path.js +131 -0
  46. package/lib/{session-state.js → task-state.js} +90 -38
  47. package/lib/task-tree-lock.js +74 -0
  48. package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
  49. package/lib/tokens.js +1 -1
  50. package/lib/types.js +2 -2
  51. package/package.json +8 -1
  52. package/dist/js/app.XBFXH37R.js +0 -2
  53. package/dist/js/chunk.UMQMOGWO.js +0 -1
  54. package/dist/js/viewer.CVWXSKJM.js +0 -1
  55. package/lib/session-manager.js +0 -613
  56. package/lib/title-service.js +0 -95
package/lib/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 menu display limit; 0 = show all
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 session share links.
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
@@ -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, 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 handlePlan(event, sessions, store) {
44
- sessions.flushBuffers(event.sessionId);
45
- sessions.state.patch(event.sessionId, {
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.sessionId, event.type, { entries: event.entries }, { from_ref: "agent" });
65
+ store.saveEvent(event.taskId, event.type, { entries: event.entries }, { from_ref: "agent" });
55
66
  }
56
- function performAutoApprove(event, opt, sessions, store, bridge, sseManager, broadcastRequest) {
67
+ function performAutoApprove(event, opt, tasks, store, bridge, sseManager, broadcastRequest) {
57
68
  bridge.resolvePermission(event.requestId, opt.optionId);
58
- sessions.pendingPermissions.delete(event.requestId);
59
- sessions.syncPendingPermissions(event.sessionId);
69
+ tasks.pendingPermissions.delete(event.requestId);
70
+ tasks.syncPendingPermissions(event.taskId);
60
71
  const optionName = opt.label ?? opt.optionId;
61
- store.saveEvent(event.sessionId, "permission_response", {
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
- sessionId: event.sessionId,
81
+ taskId: event.taskId,
71
82
  requestId: event.requestId,
72
83
  optionName,
73
84
  denied: false,
74
85
  });
75
86
  }
76
- function maybeAutoApprovePermission(event, sessions, store, bridge, sseManager) {
77
- const mode = store.getSession(event.sessionId)?.mode ?? "";
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, sessions, store, bridge, sseManager, true);
94
+ performAutoApprove(event, opt, tasks, store, bridge, sseManager, true);
84
95
  return true;
85
96
  }
86
- function maybeAutoApproveAttachmentRead(event, sessions, store, bridge, sseManager, config) {
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
- sessionId: event.sessionId,
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 (!sessions.pendingPermissions.has(event.requestId))
124
+ if (!tasks.pendingPermissions.has(event.requestId))
114
125
  return;
115
- performAutoApprove(event, opt, sessions, store, bridge, sseManager, false);
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, sessions, store, bridge, sseManager, config) {
121
- sessions.flushBuffers(event.sessionId);
122
- 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, {
123
134
  runtime: { streaming: { assistant: false, thinking: false } },
124
135
  });
125
- store.saveEvent(event.sessionId, event.type, {
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
- sessions.pendingPermissions.set(event.requestId, {
141
+ tasks.pendingPermissions.set(event.requestId, {
131
142
  requestId: event.requestId,
132
- sessionId: event.sessionId,
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
- sessions.syncPendingPermissions(event.sessionId);
140
- const autopiloted = maybeAutoApprovePermission(event, sessions, store, bridge, sseManager);
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, sessions, store, bridge, sseManager, config);
155
+ maybeAutoApproveAttachmentRead(event, tasks, store, bridge, sseManager, config);
145
156
  return false;
146
157
  }
147
- function handlePromptDone(event, sessions, store) {
148
- 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;
149
160
  if (cancelStatus !== null) {
150
161
  clog.info("agent completed after request", {
151
- sessionId: event.sessionId.slice(0, 8),
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 = 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";
159
177
  if (isCurrent) {
160
- sessions.activePrompts.delete(event.sessionId);
161
- sessions.syncBusy(event.sessionId);
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
- sessionId: event.sessionId.slice(0, 8),
183
+ taskId: event.taskId.slice(0, 8),
166
184
  promptId: event.promptId,
167
185
  stopReason: event.stopReason,
168
186
  });
169
187
  }
170
- sessions.flushBuffers(event.sessionId);
171
- sessions.state.patch(event.sessionId, {
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.sessionId, event.type, {
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, sessions) {
181
- if (event.sessionId) {
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
- if (sessions.isCurrentPrompt(event.sessionId, event.promptId)) {
185
- sessions.activePrompts.delete(event.sessionId);
186
- 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);
187
233
  }
188
234
  else {
189
235
  clog.info("failure from a superseded turn", {
190
- sessionId: event.sessionId.slice(0, 8),
236
+ taskId: event.taskId.slice(0, 8),
191
237
  promptId: event.promptId,
192
238
  message: event.message,
193
239
  });
194
240
  }
195
- sessions.flushBuffers(event.sessionId);
196
- sessions.state.patch(event.sessionId, {
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, sessions, store, bridge, config, sseManager) {
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, sessions, config);
273
+ handleConnected(event, tasks, config);
206
274
  return false;
207
- case "session_created":
275
+ case "task_created":
208
276
  case "config_option_update":
209
- handleConfigLikeEvent(event, sessions);
277
+ handleConfigLikeEvent(event, tasks);
210
278
  return false;
211
279
  case "message_chunk":
212
- handleMessageChunk(event, sessions);
280
+ handleMessageChunk(event, tasks);
213
281
  return false;
214
282
  case "thought_chunk":
215
- handleThoughtChunk(event, sessions);
283
+ handleThoughtChunk(event, tasks);
216
284
  return false;
217
285
  case "tool_call":
218
- handleToolCall(event, sessions, store);
286
+ handleToolCall(event, tasks, store);
219
287
  return false;
220
288
  case "tool_call_update":
221
- store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content }, { from_ref: "agent" });
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, sessions, store);
300
+ handlePlan(event, tasks, store);
225
301
  return false;
226
302
  case "permission_request":
227
- return handlePermissionRequest(event, sessions, store, bridge, sseManager, config);
303
+ return handlePermissionRequest(event, tasks, store, bridge, sseManager, config);
228
304
  case "prompt_done":
229
- handlePromptDone(event, sessions, store);
305
+ handlePromptDone(event, tasks, store, bridge);
230
306
  return false;
231
307
  case "error":
232
- handleError(event, sessions);
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 (!("sessionId" in event) || !event.sessionId)
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.sessionId, pushEvent).catch((err) => {
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, sessions, store, bridge, config, sseManager, pushService, _clientRegistry) {
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 = sessions.updateAgentCommands(event.sessionId, event.commands);
258
- if (sessions.restoringSessions.has(event.sessionId))
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 sessionId of sessions.liveSessions) {
265
- sessions.flushBuffers(sessionId);
344
+ for (const taskId of tasks.liveTasks) {
345
+ tasks.flushBuffers(taskId);
266
346
  }
267
- sessions.state.clearStreaming();
268
- sessions.state.clearPlans();
269
- 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()) {
270
351
  sseManager.broadcast({
271
352
  type: "available_commands_update",
272
353
  ...snapshot,
273
354
  });
274
355
  }
275
356
  }
276
- if ("sessionId" in event &&
277
- event.sessionId &&
278
- sessions.restoringSessions.has(event.sessionId))
357
+ if ("taskId" in event &&
358
+ event.taskId &&
359
+ tasks.restoringTasks.has(event.taskId))
279
360
  return;
280
- const suppress = dispatchAgentEvent(event, sessions, store, bridge, config, sseManager);
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
+ }