@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/bridge.js CHANGED
@@ -2,26 +2,40 @@ import { spawn } from "node:child_process";
2
2
  import { Writable, Readable } from "node:stream";
3
3
  import { EventEmitter } from "node:events";
4
4
  import * as acp from "@agentclientprotocol/sdk";
5
- import { interruptBashProc } from "./session-manager.js";
5
+ import { interruptBashProc } from "./task-manager.js";
6
+ import { abbreviateHomePath } from "./home-path.js";
6
7
  import { log } from "./log.js";
7
8
  const blog = log.scope("bridge");
8
9
  export class AgentBridge extends EventEmitter {
9
10
  proc = null;
10
11
  conn = null;
11
12
  permissionResolvers = new Map();
12
- permissionRequestSessions = new Map();
13
+ permissionRequestTasks = new Map();
13
14
  silentSessions = new Set(); // Sessions that don't emit events
14
- silentBuffers = new Map(); // Text buffers for silent sessions
15
+ silentBuffers = new Map(); // Text buffers for silent tasks
16
+ pendingNewSessions = 0;
17
+ unboundNewSessionIds = new Set();
18
+ pendingSessionUpdates = new Map();
15
19
  pendingAborts = new Map();
16
20
  deadReason = null;
21
+ /** Capabilities advertised by the agent at initialize; gates retire calls. */
22
+ sessionCapabilities = null;
17
23
  stderrTail = "";
18
24
  closedProcesses = new WeakSet();
19
25
  agentCmd;
26
+ sessionIds;
20
27
  reloading = false;
21
28
  attachmentDispatcher = null;
22
- constructor(agentCmd) {
29
+ constructor(agentCmd, sessionIds) {
23
30
  super();
24
31
  this.agentCmd = agentCmd;
32
+ this.sessionIds = sessionIds;
33
+ }
34
+ agentSessionId(taskId) {
35
+ const id = this.sessionIds.getAgentSessionId(taskId);
36
+ if (!id)
37
+ throw new Error(`Task is not available for the current agent: ${taskId}`);
38
+ return id;
25
39
  }
26
40
  /**
27
41
  * Inject the dispatcher used to translate client attachment refs into
@@ -87,6 +101,7 @@ export class AgentBridge extends EventEmitter {
87
101
  },
88
102
  }));
89
103
  const agentInfo = init.agentInfo;
104
+ this.sessionCapabilities = agentInfo?.sessionCapabilities ?? null;
90
105
  this.emit("event", {
91
106
  type: "connected",
92
107
  agent: {
@@ -99,41 +114,95 @@ export class AgentBridge extends EventEmitter {
99
114
  async newSession(cwd, opts) {
100
115
  if (!this.conn)
101
116
  throw new Error("Not connected");
102
- const session = await this.conn.newSession({
103
- cwd,
104
- mcpServers: [],
105
- });
106
- const configOptions = (session.configOptions ??
107
- []);
108
- if (!opts?.silent) {
109
- this.emit("event", {
110
- type: "session_created",
111
- sessionId: session.sessionId,
117
+ this.pendingNewSessions++;
118
+ try {
119
+ const session = await this.conn.newSession({
112
120
  cwd,
113
- configOptions,
121
+ mcpServers: opts?.mcpServers ?? [],
122
+ });
123
+ if (opts?.silent) {
124
+ this.pendingSessionUpdates.delete(session.sessionId);
125
+ this.silentSessions.add(session.sessionId);
126
+ }
127
+ else {
128
+ this.unboundNewSessionIds.add(session.sessionId);
129
+ }
130
+ const configOptions = (session.configOptions ??
131
+ []);
132
+ return { sessionId: session.sessionId, configOptions };
133
+ }
134
+ finally {
135
+ this.pendingNewSessions--;
136
+ if (this.pendingNewSessions === 0) {
137
+ for (const sessionId of this.pendingSessionUpdates.keys()) {
138
+ if (!this.unboundNewSessionIds.has(sessionId)) {
139
+ this.pendingSessionUpdates.delete(sessionId);
140
+ blog.warn("discarded update for unrelated unmapped ACP session", {
141
+ sessionId,
142
+ });
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ sessionMapped(agentSessionId) {
149
+ this.unboundNewSessionIds.delete(agentSessionId);
150
+ const updates = this.pendingSessionUpdates.get(agentSessionId) ?? [];
151
+ this.pendingSessionUpdates.delete(agentSessionId);
152
+ for (const update of updates) {
153
+ void this.handleSessionUpdate({ sessionId: agentSessionId, update });
154
+ }
155
+ }
156
+ discardUnboundSession(agentSessionId) {
157
+ this.unboundNewSessionIds.delete(agentSessionId);
158
+ this.pendingSessionUpdates.delete(agentSessionId);
159
+ }
160
+ /**
161
+ * Explicitly retire an ACP execution whose WebAgent binding has been
162
+ * rotated away or deleted. Best-effort: prefers `session/delete` when the
163
+ * agent advertises it, falls back to `session/close`, and skips silently
164
+ * when the agent supports neither. Failures are logged but never thrown,
165
+ * so retirement can never roll back an already-successful rotation.
166
+ */
167
+ async retireExecution(agentSessionId) {
168
+ if (!this.conn)
169
+ return;
170
+ const params = { sessionId: agentSessionId };
171
+ try {
172
+ if (this.sessionCapabilities?.delete) {
173
+ await this.conn.deleteSession(params);
174
+ }
175
+ else if (this.sessionCapabilities?.close) {
176
+ await this.conn.closeSession(params);
177
+ }
178
+ }
179
+ catch (err) {
180
+ blog.warn("failed to retire retired ACP session", {
181
+ agentSessionId,
182
+ error: err instanceof Error ? err.message : String(err),
114
183
  });
115
184
  }
116
- return { sessionId: session.sessionId, configOptions };
117
185
  }
118
- async loadSession(sessionId, cwd) {
186
+ async loadSession(taskId, cwd, mcpServers) {
119
187
  if (!this.conn)
120
188
  throw new Error("Not connected");
189
+ const agentSessionId = this.agentSessionId(taskId);
121
190
  let session;
122
191
  try {
123
192
  session = await this.conn.loadSession({
124
- sessionId,
193
+ sessionId: agentSessionId,
125
194
  cwd,
126
- mcpServers: [],
195
+ mcpServers: mcpServers ?? [],
127
196
  });
128
197
  }
129
198
  catch (err) {
130
199
  // -32002 = Resource not found. Some agents (e.g. claude-agent-acp) don't
131
- // persist sessions across process restarts, so a session in our DB may
200
+ // persist tasks across process restarts, so a session in our DB may
132
201
  // be unknown to the live agent. Translate the JSON-RPC error into a
133
202
  // user-actionable message; routes returns it as 500 / SSE 'error' event.
134
203
  const code = err.code;
135
204
  if (code === -32002) {
136
- throw new Error(`The agent no longer remembers session ${sessionId.slice(0, 8)}… ` +
205
+ throw new Error(`The agent no longer remembers task ${taskId.slice(0, 8)}… ` +
137
206
  `(it may not persist sessions across restarts). Use /new to start a fresh one.`, { cause: err });
138
207
  }
139
208
  throw err;
@@ -141,18 +210,31 @@ export class AgentBridge extends EventEmitter {
141
210
  const configOptions = (session.configOptions ??
142
211
  []);
143
212
  this.emit("event", {
144
- type: "session_created",
145
- sessionId,
213
+ type: "task_created",
214
+ taskId,
146
215
  cwd,
216
+ cwdDisplay: abbreviateHomePath(cwd),
147
217
  configOptions,
148
218
  });
149
- return { sessionId, configOptions };
219
+ return { taskId, configOptions };
220
+ }
221
+ async setConfigOption(taskId, configId, value) {
222
+ if (!this.conn)
223
+ throw new Error("Not connected");
224
+ const result = await this.conn.setSessionConfigOption({
225
+ sessionId: this.agentSessionId(taskId),
226
+ configId,
227
+ ...(typeof value === "boolean"
228
+ ? { type: "boolean", value }
229
+ : { value }),
230
+ });
231
+ return result.configOptions;
150
232
  }
151
- async setConfigOption(sessionId, configId, value) {
233
+ async setAgentConfigOption(agentSessionId, configId, value) {
152
234
  if (!this.conn)
153
235
  throw new Error("Not connected");
154
236
  const result = await this.conn.setSessionConfigOption({
155
- sessionId,
237
+ sessionId: agentSessionId,
156
238
  configId,
157
239
  ...(typeof value === "boolean"
158
240
  ? { type: "boolean", value }
@@ -160,14 +242,14 @@ export class AgentBridge extends EventEmitter {
160
242
  });
161
243
  return result.configOptions;
162
244
  }
163
- async prompt(sessionId, text, attachments,
245
+ async prompt(taskId, text, attachments,
164
246
  /** Turn identity echoed back on this prompt's terminal event, so a
165
247
  * completion that outlives its turn can be told apart from the live one. */
166
248
  promptId) {
167
249
  if (this.deadReason) {
168
250
  this.emit("event", {
169
251
  type: "error",
170
- sessionId,
252
+ taskId,
171
253
  message: this.deadReason,
172
254
  });
173
255
  return;
@@ -178,7 +260,7 @@ export class AgentBridge extends EventEmitter {
178
260
  const abortPromise = new Promise((_, rej) => {
179
261
  abortReject = rej;
180
262
  });
181
- this.pendingAborts.set(sessionId, abortReject);
263
+ this.pendingAborts.set(taskId, abortReject);
182
264
  try {
183
265
  const promptParts = [];
184
266
  if (attachments && attachments.length > 0) {
@@ -189,21 +271,21 @@ export class AgentBridge extends EventEmitter {
189
271
  throw new Error("attachment dispatcher not configured");
190
272
  }
191
273
  for (const ref of attachments) {
192
- const block = await this.attachmentDispatcher.dispatch(sessionId, ref);
193
- promptParts.push(block);
274
+ const blocks = await this.attachmentDispatcher.dispatch(taskId, ref);
275
+ promptParts.push(...blocks);
194
276
  }
195
277
  }
196
278
  promptParts.push({ type: "text", text });
197
279
  const result = (await Promise.race([
198
280
  this.conn.prompt({
199
- sessionId,
281
+ sessionId: this.agentSessionId(taskId),
200
282
  prompt: promptParts,
201
283
  }),
202
284
  abortPromise,
203
285
  ]));
204
286
  this.emit("event", {
205
287
  type: "prompt_done",
206
- sessionId,
288
+ taskId,
207
289
  stopReason: result.stopReason ?? "end_turn",
208
290
  ...(promptId ? { promptId } : {}),
209
291
  });
@@ -217,7 +299,7 @@ export class AgentBridge extends EventEmitter {
217
299
  if (/cancel/i.test(message)) {
218
300
  this.emit("event", {
219
301
  type: "prompt_done",
220
- sessionId,
302
+ taskId,
221
303
  stopReason: "cancelled",
222
304
  ...(promptId ? { promptId } : {}),
223
305
  });
@@ -225,23 +307,25 @@ export class AgentBridge extends EventEmitter {
225
307
  }
226
308
  this.emit("event", {
227
309
  type: "error",
228
- sessionId,
310
+ taskId,
229
311
  message,
230
312
  ...(promptId ? { promptId } : {}),
231
313
  });
232
314
  }
233
315
  finally {
234
- this.pendingAborts.delete(sessionId);
316
+ this.pendingAborts.delete(taskId);
235
317
  }
236
318
  }
237
- async cancel(sessionId) {
238
- for (const [requestId, requestSessionId] of this
239
- .permissionRequestSessions) {
240
- if (requestSessionId === sessionId) {
319
+ async cancel(taskId) {
320
+ for (const [requestId, requestTaskId] of this.permissionRequestTasks) {
321
+ if (requestTaskId === taskId) {
241
322
  this.denyPermission(requestId);
242
323
  }
243
324
  }
244
- await this.conn?.cancel({ sessionId });
325
+ await this.conn?.cancel({ sessionId: this.agentSessionId(taskId) });
326
+ }
327
+ async cancelAgentSession(agentSessionId) {
328
+ await this.conn?.cancel({ sessionId: agentSessionId });
245
329
  }
246
330
  /**
247
331
  * Mark the agent subprocess as dead. Rejects in-flight prompts and emits
@@ -261,10 +345,10 @@ export class AgentBridge extends EventEmitter {
261
345
  blog.error("agent subprocess dead", { reason });
262
346
  const aborts = [...this.pendingAborts.entries()];
263
347
  this.pendingAborts.clear();
264
- for (const [sessionId, abort] of aborts) {
348
+ for (const [taskId, abort] of aborts) {
265
349
  this.emit("event", {
266
350
  type: "error",
267
- sessionId,
351
+ taskId,
268
352
  message: reason,
269
353
  });
270
354
  abort(new Error(reason));
@@ -314,7 +398,7 @@ export class AgentBridge extends EventEmitter {
314
398
  if (resolve) {
315
399
  resolve({ outcome: { outcome: "selected", optionId } });
316
400
  this.permissionResolvers.delete(requestId);
317
- this.permissionRequestSessions.delete(requestId);
401
+ this.permissionRequestTasks.delete(requestId);
318
402
  }
319
403
  }
320
404
  denyPermission(requestId) {
@@ -322,75 +406,75 @@ export class AgentBridge extends EventEmitter {
322
406
  if (resolve) {
323
407
  resolve({ outcome: { outcome: "cancelled" } });
324
408
  this.permissionResolvers.delete(requestId);
325
- this.permissionRequestSessions.delete(requestId);
409
+ this.permissionRequestTasks.delete(requestId);
326
410
  }
327
411
  }
328
412
  /**
329
413
  * Restart the agent subprocess. Cancels all active work, cleans up state,
330
- * shuts down the old process, and starts a new one. Sessions are restored
414
+ * shuts down the old process, and starts a new one. Tasks are restored
331
415
  * lazily via ensureResumed() on next user interaction.
332
416
  */
333
- async restart(sessions, titleService) {
417
+ async restart(tasks) {
334
418
  if (this.reloading)
335
419
  throw new Error("Already reloading");
336
420
  this.reloading = true;
337
- const liveSessionIds = [...sessions.liveSessions];
421
+ const liveTaskIds = [...tasks.liveTasks];
338
422
  this.emit("event", { type: "agent_reloading" });
339
423
  blog.info("reloading agent...");
340
424
  try {
341
425
  // 1. Cancel all active prompts + kill bash procs
342
- for (const sessionId of [...sessions.activePrompts]) {
343
- const proc = sessions.runningBashProcs.get(sessionId);
426
+ for (const taskId of [...tasks.activePrompts]) {
427
+ const proc = tasks.runningBashProcs.get(taskId);
344
428
  if (proc) {
345
429
  interruptBashProc(proc);
346
- sessions.runningBashProcs.delete(sessionId);
430
+ tasks.runningBashProcs.delete(taskId);
347
431
  }
348
432
  try {
349
- await this.cancel(sessionId);
433
+ await this.cancel(taskId);
350
434
  }
351
435
  catch {
352
436
  /* best-effort */
353
437
  }
354
438
  }
355
439
  // 2. Flush buffers to persist partial content
356
- for (const sessionId of liveSessionIds) {
357
- sessions.flushBuffers(sessionId);
440
+ for (const taskId of liveTaskIds) {
441
+ tasks.flushBuffers(taskId);
358
442
  }
359
- // 3. Clean up SessionManager state
360
- sessions.pendingPermissions.clear();
361
- sessions.state.clearPlans();
362
- const busySessionIds = new Set([
363
- ...sessions.activePrompts,
364
- ...sessions.pendingPromptSubmissions.keys(),
443
+ // 3. Clean up TaskManager state
444
+ tasks.pendingPermissions.clear();
445
+ tasks.state.clearPlans();
446
+ const busyTaskIds = new Set([
447
+ ...tasks.activePrompts,
448
+ ...tasks.pendingPromptSubmissions.keys(),
365
449
  ]);
366
- for (const id of busySessionIds) {
367
- sessions.state.patch(id, { runtime: { busy: null } });
450
+ for (const id of busyTaskIds) {
451
+ tasks.state.patch(id, { runtime: { busy: null } });
368
452
  }
369
- for (const submissionId of sessions.pendingPromptSubmissions.values()) {
370
- sessions.cancelledPromptSubmissions.add(submissionId);
453
+ for (const submissionId of tasks.pendingPromptSubmissions.values()) {
454
+ tasks.cancelledPromptSubmissions.add(submissionId);
371
455
  }
372
- sessions.activePrompts.clear();
373
- sessions.pendingPromptSubmissions.clear();
456
+ tasks.activePrompts.clear();
457
+ tasks.pendingPromptSubmissions.clear();
374
458
  // 4. Clean up bridge-side silent session state
375
459
  this.silentSessions.clear();
376
460
  this.silentBuffers.clear();
377
- // 5. Invalidate title service session
378
- titleService.invalidate();
379
- // 5. Clear liveSessions so ensureResumed() will re-register on next access
380
- sessions.liveSessions.clear();
461
+ this.unboundNewSessionIds.clear();
462
+ this.pendingSessionUpdates.clear();
463
+ // 5. Clear liveTasks so ensureResumed() will re-register on next access
464
+ tasks.liveTasks.clear();
381
465
  // Also clear the global configOptions cache — a restarted agent may
382
466
  // speak a different schema (e.g. agent upgrade removed a model). The
383
- // next resumeSession will warm it from the user's stored config.
384
- sessions.cachedConfigOptions = [];
467
+ // next resumeTask will warm it from the user's stored config.
468
+ tasks.cachedConfigOptions = [];
385
469
  // 6. Shutdown old process
386
470
  await this.shutdown();
387
471
  // Cancellation is asynchronous: the old agent may emit final chunks
388
472
  // before shutdown completes. Persist that tail and make the terminal
389
473
  // stream state authoritative before starting the replacement process.
390
- for (const sessionId of liveSessionIds) {
391
- sessions.flushBuffers(sessionId);
474
+ for (const taskId of liveTaskIds) {
475
+ tasks.flushBuffers(taskId);
392
476
  }
393
- sessions.state.clearStreaming();
477
+ tasks.state.clearStreaming();
394
478
  // 7. Start new process with retry (exponential backoff, max 3 attempts)
395
479
  let lastError;
396
480
  for (let i = 0; i < 3; i++) {
@@ -426,7 +510,7 @@ export class AgentBridge extends EventEmitter {
426
510
  resolve({ outcome: { outcome: "cancelled" } });
427
511
  }
428
512
  this.permissionResolvers.clear();
429
- this.permissionRequestSessions.clear();
513
+ this.permissionRequestTasks.clear();
430
514
  const proc = this.proc;
431
515
  if (proc && !this.closedProcesses.has(proc)) {
432
516
  await new Promise((resolve) => {
@@ -457,6 +541,10 @@ export class AgentBridge extends EventEmitter {
457
541
  }
458
542
  // --- ACP Client callbacks ---
459
543
  handlePermission(params) {
544
+ const taskId = this.sessionIds.getTaskId(params.sessionId);
545
+ if (!taskId) {
546
+ return Promise.resolve({ outcome: { outcome: "cancelled" } });
547
+ }
460
548
  const requestId = crypto.randomUUID();
461
549
  const toolCall = params.toolCall;
462
550
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- toolCall may be undefined in practice
@@ -467,11 +555,11 @@ export class AgentBridge extends EventEmitter {
467
555
  return new Promise((resolve) => {
468
556
  // Register resolver BEFORE emitting, so synchronous auto-approve can find it
469
557
  this.permissionResolvers.set(requestId, resolve);
470
- this.permissionRequestSessions.set(requestId, params.sessionId);
558
+ this.permissionRequestTasks.set(requestId, taskId);
471
559
  this.emit("event", {
472
560
  type: "permission_request",
473
561
  requestId,
474
- sessionId: params.sessionId,
562
+ taskId: taskId,
475
563
  title,
476
564
  toolCallId,
477
565
  options: params.options,
@@ -484,12 +572,26 @@ export class AgentBridge extends EventEmitter {
484
572
  }
485
573
  handleSessionUpdate(params) {
486
574
  const update = params.update;
487
- const sessionId = params.sessionId;
488
- if (this.silentSessions.has(sessionId)) {
489
- this.captureSilentText(sessionId, update);
575
+ const agentSessionId = params.sessionId;
576
+ if (this.silentSessions.has(agentSessionId)) {
577
+ this.captureSilentText(agentSessionId, update);
490
578
  return Promise.resolve();
491
579
  }
492
- const event = this.sessionUpdateToEvent(sessionId, update);
580
+ const taskId = this.sessionIds.getTaskId(agentSessionId);
581
+ if (!taskId) {
582
+ if (this.pendingNewSessions > 0 ||
583
+ this.unboundNewSessionIds.has(agentSessionId)) {
584
+ const updates = this.pendingSessionUpdates.get(agentSessionId) ?? [];
585
+ updates.push(update);
586
+ this.pendingSessionUpdates.set(agentSessionId, updates);
587
+ return Promise.resolve();
588
+ }
589
+ blog.warn("ignored event for unmapped ACP session", {
590
+ sessionId: agentSessionId,
591
+ });
592
+ return Promise.resolve();
593
+ }
594
+ const event = this.sessionUpdateToEvent(taskId, update);
493
595
  if (event)
494
596
  this.emit("event", event);
495
597
  return Promise.resolve();
@@ -501,21 +603,21 @@ export class AgentBridge extends EventEmitter {
501
603
  this.silentBuffers.set(sessionId, buf);
502
604
  }
503
605
  }
504
- sessionUpdateToEvent(sessionId, update) {
606
+ sessionUpdateToEvent(taskId, update) {
505
607
  // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only handles events with UI effects
506
608
  switch (update.sessionUpdate) {
507
609
  case "agent_message_chunk":
508
610
  return update.content.type === "text"
509
- ? { type: "message_chunk", sessionId, text: update.content.text }
611
+ ? { type: "message_chunk", taskId, text: update.content.text }
510
612
  : null;
511
613
  case "agent_thought_chunk":
512
614
  return update.content.type === "text"
513
- ? { type: "thought_chunk", sessionId, text: update.content.text }
615
+ ? { type: "thought_chunk", taskId, text: update.content.text }
514
616
  : null;
515
617
  case "tool_call":
516
618
  return {
517
619
  type: "tool_call",
518
- sessionId,
620
+ taskId,
519
621
  id: update.toolCallId,
520
622
  title: update.title,
521
623
  kind: update.kind ?? "unknown",
@@ -524,24 +626,41 @@ export class AgentBridge extends EventEmitter {
524
626
  case "tool_call_update":
525
627
  return {
526
628
  type: "tool_call_update",
527
- sessionId,
629
+ taskId,
528
630
  id: update.toolCallId,
529
631
  status: update.status ?? "",
530
632
  content: (update.content ?? undefined),
633
+ ...(typeof update.title === "string" ? { title: update.title } : {}),
634
+ ...(typeof update.kind === "string" ? { kind: update.kind } : {}),
635
+ ...(update.rawInput ? { rawInput: update.rawInput } : {}),
636
+ ...(Object.hasOwn(update, "rawOutput")
637
+ ? { rawOutput: update.rawOutput }
638
+ : {}),
639
+ ...(Array.isArray(update.locations)
640
+ ? { locations: update.locations }
641
+ : {}),
531
642
  };
532
643
  case "plan":
533
- return { type: "plan", sessionId, entries: update.entries };
644
+ return { type: "plan", taskId, entries: update.entries };
645
+ case "usage_update":
646
+ return {
647
+ type: "usage_update",
648
+ taskId,
649
+ used: update.used,
650
+ size: update.size,
651
+ cost: update.cost,
652
+ };
534
653
  case "config_option_update":
535
654
  return {
536
655
  type: "config_option_update",
537
- sessionId,
656
+ taskId,
538
657
  configOptions: update
539
658
  .configOptions ?? [],
540
659
  };
541
660
  case "available_commands_update":
542
661
  return {
543
662
  type: "available_commands_update",
544
- sessionId,
663
+ taskId,
545
664
  commands: update.availableCommands.map((command) => ({
546
665
  name: command.name,
547
666
  description: command.description,
@@ -48,7 +48,7 @@ export class ClientRegistry {
48
48
  * - Returns `becameVisibleFor=X` only on first transition into
49
49
  * (visible:true, active:X) — heartbeat refreshes return null so
50
50
  * callers can fire edge-triggered side effects exactly once.
51
- * - Session-switch while visible (active X→Y) restarts the TTL clock
51
+ * - Task-switch while visible (active X→Y) restarts the TTL clock
52
52
  * even when the patch doesn't carry an explicit visible:true.
53
53
  *
54
54
  * No-op on unknown client.
@@ -57,7 +57,7 @@ export class ClientRegistry {
57
57
  const entry = this.clients.get(id);
58
58
  if (!entry)
59
59
  return { becameVisibleFor: null };
60
- const wasVisibleForSession = entry.visible && entry.active != null ? entry.active : null;
60
+ const wasVisibleForTask = entry.visible && entry.active != null ? entry.active : null;
61
61
  if (patch.visible !== undefined) {
62
62
  entry.visible = patch.visible;
63
63
  entry.visibleSince = patch.visible ? this.now() : 0;
@@ -67,37 +67,37 @@ export class ClientRegistry {
67
67
  }
68
68
  const becameVisibleFor = entry.visible &&
69
69
  entry.active != null &&
70
- entry.active !== wasVisibleForSession
70
+ entry.active !== wasVisibleForTask
71
71
  ? entry.active
72
72
  : null;
73
73
  if (becameVisibleFor) {
74
74
  // Any transition into "visible + active=X" restarts TTL — including
75
- // session-switches that arrive without an explicit visible:true.
75
+ // task-switches that arrive without an explicit visible:true.
76
76
  entry.visibleSince = this.now();
77
77
  }
78
78
  entry.lastSeen = this.now();
79
79
  return { becameVisibleFor };
80
80
  }
81
- /** Is this specific client currently visible & viewing `sessionId` & fresh? */
82
- isVisibleForSession(id, sessionId) {
81
+ /** Is this specific client currently visible & viewing `taskId` & fresh? */
82
+ isVisibleForTask(id, taskId) {
83
83
  const entry = this.clients.get(id);
84
84
  if (!entry)
85
85
  return false;
86
86
  if (!entry.visible)
87
87
  return false;
88
- if (entry.active !== sessionId)
88
+ if (entry.active !== taskId)
89
89
  return false;
90
90
  if (this.now() - entry.visibleSince > this.visibilityTtlMs)
91
91
  return false;
92
92
  return true;
93
93
  }
94
- /** Is at least one fresh visible client viewing `sessionId`? */
95
- isSessionVisibleToAnyClient(sessionId) {
94
+ /** Is at least one fresh visible client viewing `taskId`? */
95
+ isTaskVisibleToAnyClient(taskId) {
96
96
  const now = this.now();
97
97
  for (const e of this.clients.values()) {
98
98
  if (!e.visible)
99
99
  continue;
100
- if (e.active !== sessionId)
100
+ if (e.active !== taskId)
101
101
  continue;
102
102
  if (now - e.visibleSince > this.visibilityTtlMs)
103
103
  continue;
@@ -105,7 +105,7 @@ export class ClientRegistry {
105
105
  }
106
106
  return false;
107
107
  }
108
- /** Is this specific client currently fresh-visible (any session)? */
108
+ /** Is this specific client currently fresh-visible (any task)? */
109
109
  isClientVisible(id) {
110
110
  const entry = this.clients.get(id);
111
111
  if (!entry)
@@ -116,7 +116,7 @@ export class ClientRegistry {
116
116
  return false;
117
117
  return true;
118
118
  }
119
- /** Is at least one fresh visible client connected (any session)? */
119
+ /** Is at least one fresh visible client connected (any task)? */
120
120
  hasAnyVisibleClient() {
121
121
  const now = this.now();
122
122
  for (const e of this.clients.values()) {