@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/bridge.js
CHANGED
|
@@ -2,7 +2,7 @@ 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 "./
|
|
5
|
+
import { interruptBashProc } from "./task-manager.js";
|
|
6
6
|
import { abbreviateHomePath } from "./home-path.js";
|
|
7
7
|
import { log } from "./log.js";
|
|
8
8
|
const blog = log.scope("bridge");
|
|
@@ -10,14 +10,16 @@ export class AgentBridge extends EventEmitter {
|
|
|
10
10
|
proc = null;
|
|
11
11
|
conn = null;
|
|
12
12
|
permissionResolvers = new Map();
|
|
13
|
-
|
|
13
|
+
permissionRequestTasks = new Map();
|
|
14
14
|
silentSessions = new Set(); // Sessions that don't emit events
|
|
15
|
-
silentBuffers = new Map(); // Text buffers for silent
|
|
15
|
+
silentBuffers = new Map(); // Text buffers for silent tasks
|
|
16
16
|
pendingNewSessions = 0;
|
|
17
17
|
unboundNewSessionIds = new Set();
|
|
18
18
|
pendingSessionUpdates = new Map();
|
|
19
19
|
pendingAborts = new Map();
|
|
20
20
|
deadReason = null;
|
|
21
|
+
/** Capabilities advertised by the agent at initialize; gates retire calls. */
|
|
22
|
+
sessionCapabilities = null;
|
|
21
23
|
stderrTail = "";
|
|
22
24
|
closedProcesses = new WeakSet();
|
|
23
25
|
agentCmd;
|
|
@@ -29,10 +31,10 @@ export class AgentBridge extends EventEmitter {
|
|
|
29
31
|
this.agentCmd = agentCmd;
|
|
30
32
|
this.sessionIds = sessionIds;
|
|
31
33
|
}
|
|
32
|
-
agentSessionId(
|
|
33
|
-
const id = this.sessionIds.getAgentSessionId(
|
|
34
|
+
agentSessionId(taskId) {
|
|
35
|
+
const id = this.sessionIds.getAgentSessionId(taskId);
|
|
34
36
|
if (!id)
|
|
35
|
-
throw new Error(`
|
|
37
|
+
throw new Error(`Task is not available for the current agent: ${taskId}`);
|
|
36
38
|
return id;
|
|
37
39
|
}
|
|
38
40
|
/**
|
|
@@ -99,6 +101,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
99
101
|
},
|
|
100
102
|
}));
|
|
101
103
|
const agentInfo = init.agentInfo;
|
|
104
|
+
this.sessionCapabilities = agentInfo?.sessionCapabilities ?? null;
|
|
102
105
|
this.emit("event", {
|
|
103
106
|
type: "connected",
|
|
104
107
|
agent: {
|
|
@@ -115,7 +118,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
115
118
|
try {
|
|
116
119
|
const session = await this.conn.newSession({
|
|
117
120
|
cwd,
|
|
118
|
-
mcpServers: [],
|
|
121
|
+
mcpServers: opts?.mcpServers ?? [],
|
|
119
122
|
});
|
|
120
123
|
if (opts?.silent) {
|
|
121
124
|
this.pendingSessionUpdates.delete(session.sessionId);
|
|
@@ -154,26 +157,52 @@ export class AgentBridge extends EventEmitter {
|
|
|
154
157
|
this.unboundNewSessionIds.delete(agentSessionId);
|
|
155
158
|
this.pendingSessionUpdates.delete(agentSessionId);
|
|
156
159
|
}
|
|
157
|
-
|
|
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),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async loadSession(taskId, cwd, mcpServers) {
|
|
158
187
|
if (!this.conn)
|
|
159
188
|
throw new Error("Not connected");
|
|
160
|
-
const agentSessionId = this.agentSessionId(
|
|
189
|
+
const agentSessionId = this.agentSessionId(taskId);
|
|
161
190
|
let session;
|
|
162
191
|
try {
|
|
163
192
|
session = await this.conn.loadSession({
|
|
164
193
|
sessionId: agentSessionId,
|
|
165
194
|
cwd,
|
|
166
|
-
mcpServers: [],
|
|
195
|
+
mcpServers: mcpServers ?? [],
|
|
167
196
|
});
|
|
168
197
|
}
|
|
169
198
|
catch (err) {
|
|
170
199
|
// -32002 = Resource not found. Some agents (e.g. claude-agent-acp) don't
|
|
171
|
-
// persist
|
|
200
|
+
// persist tasks across process restarts, so a session in our DB may
|
|
172
201
|
// be unknown to the live agent. Translate the JSON-RPC error into a
|
|
173
202
|
// user-actionable message; routes returns it as 500 / SSE 'error' event.
|
|
174
203
|
const code = err.code;
|
|
175
204
|
if (code === -32002) {
|
|
176
|
-
throw new Error(`The agent no longer remembers
|
|
205
|
+
throw new Error(`The agent no longer remembers task ${taskId.slice(0, 8)}… ` +
|
|
177
206
|
`(it may not persist sessions across restarts). Use /new to start a fresh one.`, { cause: err });
|
|
178
207
|
}
|
|
179
208
|
throw err;
|
|
@@ -181,19 +210,19 @@ export class AgentBridge extends EventEmitter {
|
|
|
181
210
|
const configOptions = (session.configOptions ??
|
|
182
211
|
[]);
|
|
183
212
|
this.emit("event", {
|
|
184
|
-
type: "
|
|
185
|
-
|
|
213
|
+
type: "task_created",
|
|
214
|
+
taskId,
|
|
186
215
|
cwd,
|
|
187
216
|
cwdDisplay: abbreviateHomePath(cwd),
|
|
188
217
|
configOptions,
|
|
189
218
|
});
|
|
190
|
-
return {
|
|
219
|
+
return { taskId, configOptions };
|
|
191
220
|
}
|
|
192
|
-
async setConfigOption(
|
|
221
|
+
async setConfigOption(taskId, configId, value) {
|
|
193
222
|
if (!this.conn)
|
|
194
223
|
throw new Error("Not connected");
|
|
195
224
|
const result = await this.conn.setSessionConfigOption({
|
|
196
|
-
sessionId: this.agentSessionId(
|
|
225
|
+
sessionId: this.agentSessionId(taskId),
|
|
197
226
|
configId,
|
|
198
227
|
...(typeof value === "boolean"
|
|
199
228
|
? { type: "boolean", value }
|
|
@@ -213,14 +242,14 @@ export class AgentBridge extends EventEmitter {
|
|
|
213
242
|
});
|
|
214
243
|
return result.configOptions;
|
|
215
244
|
}
|
|
216
|
-
async prompt(
|
|
245
|
+
async prompt(taskId, text, attachments,
|
|
217
246
|
/** Turn identity echoed back on this prompt's terminal event, so a
|
|
218
247
|
* completion that outlives its turn can be told apart from the live one. */
|
|
219
248
|
promptId) {
|
|
220
249
|
if (this.deadReason) {
|
|
221
250
|
this.emit("event", {
|
|
222
251
|
type: "error",
|
|
223
|
-
|
|
252
|
+
taskId,
|
|
224
253
|
message: this.deadReason,
|
|
225
254
|
});
|
|
226
255
|
return;
|
|
@@ -231,7 +260,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
231
260
|
const abortPromise = new Promise((_, rej) => {
|
|
232
261
|
abortReject = rej;
|
|
233
262
|
});
|
|
234
|
-
this.pendingAborts.set(
|
|
263
|
+
this.pendingAborts.set(taskId, abortReject);
|
|
235
264
|
try {
|
|
236
265
|
const promptParts = [];
|
|
237
266
|
if (attachments && attachments.length > 0) {
|
|
@@ -242,21 +271,21 @@ export class AgentBridge extends EventEmitter {
|
|
|
242
271
|
throw new Error("attachment dispatcher not configured");
|
|
243
272
|
}
|
|
244
273
|
for (const ref of attachments) {
|
|
245
|
-
const
|
|
246
|
-
promptParts.push(
|
|
274
|
+
const blocks = await this.attachmentDispatcher.dispatch(taskId, ref);
|
|
275
|
+
promptParts.push(...blocks);
|
|
247
276
|
}
|
|
248
277
|
}
|
|
249
278
|
promptParts.push({ type: "text", text });
|
|
250
279
|
const result = (await Promise.race([
|
|
251
280
|
this.conn.prompt({
|
|
252
|
-
sessionId: this.agentSessionId(
|
|
281
|
+
sessionId: this.agentSessionId(taskId),
|
|
253
282
|
prompt: promptParts,
|
|
254
283
|
}),
|
|
255
284
|
abortPromise,
|
|
256
285
|
]));
|
|
257
286
|
this.emit("event", {
|
|
258
287
|
type: "prompt_done",
|
|
259
|
-
|
|
288
|
+
taskId,
|
|
260
289
|
stopReason: result.stopReason ?? "end_turn",
|
|
261
290
|
...(promptId ? { promptId } : {}),
|
|
262
291
|
});
|
|
@@ -270,7 +299,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
270
299
|
if (/cancel/i.test(message)) {
|
|
271
300
|
this.emit("event", {
|
|
272
301
|
type: "prompt_done",
|
|
273
|
-
|
|
302
|
+
taskId,
|
|
274
303
|
stopReason: "cancelled",
|
|
275
304
|
...(promptId ? { promptId } : {}),
|
|
276
305
|
});
|
|
@@ -278,23 +307,22 @@ export class AgentBridge extends EventEmitter {
|
|
|
278
307
|
}
|
|
279
308
|
this.emit("event", {
|
|
280
309
|
type: "error",
|
|
281
|
-
|
|
310
|
+
taskId,
|
|
282
311
|
message,
|
|
283
312
|
...(promptId ? { promptId } : {}),
|
|
284
313
|
});
|
|
285
314
|
}
|
|
286
315
|
finally {
|
|
287
|
-
this.pendingAborts.delete(
|
|
316
|
+
this.pendingAborts.delete(taskId);
|
|
288
317
|
}
|
|
289
318
|
}
|
|
290
|
-
async cancel(
|
|
291
|
-
for (const [requestId,
|
|
292
|
-
|
|
293
|
-
if (requestSessionId === sessionId) {
|
|
319
|
+
async cancel(taskId) {
|
|
320
|
+
for (const [requestId, requestTaskId] of this.permissionRequestTasks) {
|
|
321
|
+
if (requestTaskId === taskId) {
|
|
294
322
|
this.denyPermission(requestId);
|
|
295
323
|
}
|
|
296
324
|
}
|
|
297
|
-
await this.conn?.cancel({ sessionId: this.agentSessionId(
|
|
325
|
+
await this.conn?.cancel({ sessionId: this.agentSessionId(taskId) });
|
|
298
326
|
}
|
|
299
327
|
async cancelAgentSession(agentSessionId) {
|
|
300
328
|
await this.conn?.cancel({ sessionId: agentSessionId });
|
|
@@ -317,10 +345,10 @@ export class AgentBridge extends EventEmitter {
|
|
|
317
345
|
blog.error("agent subprocess dead", { reason });
|
|
318
346
|
const aborts = [...this.pendingAborts.entries()];
|
|
319
347
|
this.pendingAborts.clear();
|
|
320
|
-
for (const [
|
|
348
|
+
for (const [taskId, abort] of aborts) {
|
|
321
349
|
this.emit("event", {
|
|
322
350
|
type: "error",
|
|
323
|
-
|
|
351
|
+
taskId,
|
|
324
352
|
message: reason,
|
|
325
353
|
});
|
|
326
354
|
abort(new Error(reason));
|
|
@@ -370,7 +398,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
370
398
|
if (resolve) {
|
|
371
399
|
resolve({ outcome: { outcome: "selected", optionId } });
|
|
372
400
|
this.permissionResolvers.delete(requestId);
|
|
373
|
-
this.
|
|
401
|
+
this.permissionRequestTasks.delete(requestId);
|
|
374
402
|
}
|
|
375
403
|
}
|
|
376
404
|
denyPermission(requestId) {
|
|
@@ -378,77 +406,75 @@ export class AgentBridge extends EventEmitter {
|
|
|
378
406
|
if (resolve) {
|
|
379
407
|
resolve({ outcome: { outcome: "cancelled" } });
|
|
380
408
|
this.permissionResolvers.delete(requestId);
|
|
381
|
-
this.
|
|
409
|
+
this.permissionRequestTasks.delete(requestId);
|
|
382
410
|
}
|
|
383
411
|
}
|
|
384
412
|
/**
|
|
385
413
|
* Restart the agent subprocess. Cancels all active work, cleans up state,
|
|
386
|
-
* shuts down the old process, and starts a new one.
|
|
414
|
+
* shuts down the old process, and starts a new one. Tasks are restored
|
|
387
415
|
* lazily via ensureResumed() on next user interaction.
|
|
388
416
|
*/
|
|
389
|
-
async restart(
|
|
417
|
+
async restart(tasks) {
|
|
390
418
|
if (this.reloading)
|
|
391
419
|
throw new Error("Already reloading");
|
|
392
420
|
this.reloading = true;
|
|
393
|
-
const
|
|
421
|
+
const liveTaskIds = [...tasks.liveTasks];
|
|
394
422
|
this.emit("event", { type: "agent_reloading" });
|
|
395
423
|
blog.info("reloading agent...");
|
|
396
424
|
try {
|
|
397
425
|
// 1. Cancel all active prompts + kill bash procs
|
|
398
|
-
for (const
|
|
399
|
-
const proc =
|
|
426
|
+
for (const taskId of [...tasks.activePrompts]) {
|
|
427
|
+
const proc = tasks.runningBashProcs.get(taskId);
|
|
400
428
|
if (proc) {
|
|
401
429
|
interruptBashProc(proc);
|
|
402
|
-
|
|
430
|
+
tasks.runningBashProcs.delete(taskId);
|
|
403
431
|
}
|
|
404
432
|
try {
|
|
405
|
-
await this.cancel(
|
|
433
|
+
await this.cancel(taskId);
|
|
406
434
|
}
|
|
407
435
|
catch {
|
|
408
436
|
/* best-effort */
|
|
409
437
|
}
|
|
410
438
|
}
|
|
411
439
|
// 2. Flush buffers to persist partial content
|
|
412
|
-
for (const
|
|
413
|
-
|
|
440
|
+
for (const taskId of liveTaskIds) {
|
|
441
|
+
tasks.flushBuffers(taskId);
|
|
414
442
|
}
|
|
415
|
-
// 3. Clean up
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
...
|
|
420
|
-
...
|
|
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(),
|
|
421
449
|
]);
|
|
422
|
-
for (const id of
|
|
423
|
-
|
|
450
|
+
for (const id of busyTaskIds) {
|
|
451
|
+
tasks.state.patch(id, { runtime: { busy: null } });
|
|
424
452
|
}
|
|
425
|
-
for (const submissionId of
|
|
426
|
-
|
|
453
|
+
for (const submissionId of tasks.pendingPromptSubmissions.values()) {
|
|
454
|
+
tasks.cancelledPromptSubmissions.add(submissionId);
|
|
427
455
|
}
|
|
428
|
-
|
|
429
|
-
|
|
456
|
+
tasks.activePrompts.clear();
|
|
457
|
+
tasks.pendingPromptSubmissions.clear();
|
|
430
458
|
// 4. Clean up bridge-side silent session state
|
|
431
459
|
this.silentSessions.clear();
|
|
432
460
|
this.silentBuffers.clear();
|
|
433
461
|
this.unboundNewSessionIds.clear();
|
|
434
462
|
this.pendingSessionUpdates.clear();
|
|
435
|
-
// 5.
|
|
436
|
-
|
|
437
|
-
// 5. Clear liveSessions so ensureResumed() will re-register on next access
|
|
438
|
-
sessions.liveSessions.clear();
|
|
463
|
+
// 5. Clear liveTasks so ensureResumed() will re-register on next access
|
|
464
|
+
tasks.liveTasks.clear();
|
|
439
465
|
// Also clear the global configOptions cache — a restarted agent may
|
|
440
466
|
// speak a different schema (e.g. agent upgrade removed a model). The
|
|
441
|
-
// next
|
|
442
|
-
|
|
467
|
+
// next resumeTask will warm it from the user's stored config.
|
|
468
|
+
tasks.cachedConfigOptions = [];
|
|
443
469
|
// 6. Shutdown old process
|
|
444
470
|
await this.shutdown();
|
|
445
471
|
// Cancellation is asynchronous: the old agent may emit final chunks
|
|
446
472
|
// before shutdown completes. Persist that tail and make the terminal
|
|
447
473
|
// stream state authoritative before starting the replacement process.
|
|
448
|
-
for (const
|
|
449
|
-
|
|
474
|
+
for (const taskId of liveTaskIds) {
|
|
475
|
+
tasks.flushBuffers(taskId);
|
|
450
476
|
}
|
|
451
|
-
|
|
477
|
+
tasks.state.clearStreaming();
|
|
452
478
|
// 7. Start new process with retry (exponential backoff, max 3 attempts)
|
|
453
479
|
let lastError;
|
|
454
480
|
for (let i = 0; i < 3; i++) {
|
|
@@ -484,7 +510,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
484
510
|
resolve({ outcome: { outcome: "cancelled" } });
|
|
485
511
|
}
|
|
486
512
|
this.permissionResolvers.clear();
|
|
487
|
-
this.
|
|
513
|
+
this.permissionRequestTasks.clear();
|
|
488
514
|
const proc = this.proc;
|
|
489
515
|
if (proc && !this.closedProcesses.has(proc)) {
|
|
490
516
|
await new Promise((resolve) => {
|
|
@@ -515,8 +541,8 @@ export class AgentBridge extends EventEmitter {
|
|
|
515
541
|
}
|
|
516
542
|
// --- ACP Client callbacks ---
|
|
517
543
|
handlePermission(params) {
|
|
518
|
-
const
|
|
519
|
-
if (!
|
|
544
|
+
const taskId = this.sessionIds.getTaskId(params.sessionId);
|
|
545
|
+
if (!taskId) {
|
|
520
546
|
return Promise.resolve({ outcome: { outcome: "cancelled" } });
|
|
521
547
|
}
|
|
522
548
|
const requestId = crypto.randomUUID();
|
|
@@ -529,11 +555,11 @@ export class AgentBridge extends EventEmitter {
|
|
|
529
555
|
return new Promise((resolve) => {
|
|
530
556
|
// Register resolver BEFORE emitting, so synchronous auto-approve can find it
|
|
531
557
|
this.permissionResolvers.set(requestId, resolve);
|
|
532
|
-
this.
|
|
558
|
+
this.permissionRequestTasks.set(requestId, taskId);
|
|
533
559
|
this.emit("event", {
|
|
534
560
|
type: "permission_request",
|
|
535
561
|
requestId,
|
|
536
|
-
|
|
562
|
+
taskId: taskId,
|
|
537
563
|
title,
|
|
538
564
|
toolCallId,
|
|
539
565
|
options: params.options,
|
|
@@ -551,8 +577,8 @@ export class AgentBridge extends EventEmitter {
|
|
|
551
577
|
this.captureSilentText(agentSessionId, update);
|
|
552
578
|
return Promise.resolve();
|
|
553
579
|
}
|
|
554
|
-
const
|
|
555
|
-
if (!
|
|
580
|
+
const taskId = this.sessionIds.getTaskId(agentSessionId);
|
|
581
|
+
if (!taskId) {
|
|
556
582
|
if (this.pendingNewSessions > 0 ||
|
|
557
583
|
this.unboundNewSessionIds.has(agentSessionId)) {
|
|
558
584
|
const updates = this.pendingSessionUpdates.get(agentSessionId) ?? [];
|
|
@@ -565,7 +591,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
565
591
|
});
|
|
566
592
|
return Promise.resolve();
|
|
567
593
|
}
|
|
568
|
-
const event = this.sessionUpdateToEvent(
|
|
594
|
+
const event = this.sessionUpdateToEvent(taskId, update);
|
|
569
595
|
if (event)
|
|
570
596
|
this.emit("event", event);
|
|
571
597
|
return Promise.resolve();
|
|
@@ -577,21 +603,21 @@ export class AgentBridge extends EventEmitter {
|
|
|
577
603
|
this.silentBuffers.set(sessionId, buf);
|
|
578
604
|
}
|
|
579
605
|
}
|
|
580
|
-
sessionUpdateToEvent(
|
|
606
|
+
sessionUpdateToEvent(taskId, update) {
|
|
581
607
|
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only handles events with UI effects
|
|
582
608
|
switch (update.sessionUpdate) {
|
|
583
609
|
case "agent_message_chunk":
|
|
584
610
|
return update.content.type === "text"
|
|
585
|
-
? { type: "message_chunk",
|
|
611
|
+
? { type: "message_chunk", taskId, text: update.content.text }
|
|
586
612
|
: null;
|
|
587
613
|
case "agent_thought_chunk":
|
|
588
614
|
return update.content.type === "text"
|
|
589
|
-
? { type: "thought_chunk",
|
|
615
|
+
? { type: "thought_chunk", taskId, text: update.content.text }
|
|
590
616
|
: null;
|
|
591
617
|
case "tool_call":
|
|
592
618
|
return {
|
|
593
619
|
type: "tool_call",
|
|
594
|
-
|
|
620
|
+
taskId,
|
|
595
621
|
id: update.toolCallId,
|
|
596
622
|
title: update.title,
|
|
597
623
|
kind: update.kind ?? "unknown",
|
|
@@ -600,7 +626,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
600
626
|
case "tool_call_update":
|
|
601
627
|
return {
|
|
602
628
|
type: "tool_call_update",
|
|
603
|
-
|
|
629
|
+
taskId,
|
|
604
630
|
id: update.toolCallId,
|
|
605
631
|
status: update.status ?? "",
|
|
606
632
|
content: (update.content ?? undefined),
|
|
@@ -615,11 +641,11 @@ export class AgentBridge extends EventEmitter {
|
|
|
615
641
|
: {}),
|
|
616
642
|
};
|
|
617
643
|
case "plan":
|
|
618
|
-
return { type: "plan",
|
|
644
|
+
return { type: "plan", taskId, entries: update.entries };
|
|
619
645
|
case "usage_update":
|
|
620
646
|
return {
|
|
621
647
|
type: "usage_update",
|
|
622
|
-
|
|
648
|
+
taskId,
|
|
623
649
|
used: update.used,
|
|
624
650
|
size: update.size,
|
|
625
651
|
cost: update.cost,
|
|
@@ -627,14 +653,14 @@ export class AgentBridge extends EventEmitter {
|
|
|
627
653
|
case "config_option_update":
|
|
628
654
|
return {
|
|
629
655
|
type: "config_option_update",
|
|
630
|
-
|
|
656
|
+
taskId,
|
|
631
657
|
configOptions: update
|
|
632
658
|
.configOptions ?? [],
|
|
633
659
|
};
|
|
634
660
|
case "available_commands_update":
|
|
635
661
|
return {
|
|
636
662
|
type: "available_commands_update",
|
|
637
|
-
|
|
663
|
+
taskId,
|
|
638
664
|
commands: update.availableCommands.map((command) => ({
|
|
639
665
|
name: command.name,
|
|
640
666
|
description: command.description,
|
package/lib/client-registry.js
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
|
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 !==
|
|
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
|
-
//
|
|
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 `
|
|
82
|
-
|
|
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 !==
|
|
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 `
|
|
95
|
-
|
|
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 !==
|
|
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
|
|
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
|
|
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()) {
|
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
|