@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/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 "./
|
|
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
|
-
|
|
13
|
+
permissionRequestTasks = new Map();
|
|
13
14
|
silentSessions = new Set(); // Sessions that don't emit events
|
|
14
|
-
silentBuffers = new Map(); // Text buffers for silent
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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: "
|
|
145
|
-
|
|
213
|
+
type: "task_created",
|
|
214
|
+
taskId,
|
|
146
215
|
cwd,
|
|
216
|
+
cwdDisplay: abbreviateHomePath(cwd),
|
|
147
217
|
configOptions,
|
|
148
218
|
});
|
|
149
|
-
return {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
193
|
-
promptParts.push(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
+
taskId,
|
|
229
311
|
message,
|
|
230
312
|
...(promptId ? { promptId } : {}),
|
|
231
313
|
});
|
|
232
314
|
}
|
|
233
315
|
finally {
|
|
234
|
-
this.pendingAborts.delete(
|
|
316
|
+
this.pendingAborts.delete(taskId);
|
|
235
317
|
}
|
|
236
318
|
}
|
|
237
|
-
async cancel(
|
|
238
|
-
for (const [requestId,
|
|
239
|
-
|
|
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 [
|
|
348
|
+
for (const [taskId, abort] of aborts) {
|
|
265
349
|
this.emit("event", {
|
|
266
350
|
type: "error",
|
|
267
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
417
|
+
async restart(tasks) {
|
|
334
418
|
if (this.reloading)
|
|
335
419
|
throw new Error("Already reloading");
|
|
336
420
|
this.reloading = true;
|
|
337
|
-
const
|
|
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
|
|
343
|
-
const proc =
|
|
426
|
+
for (const taskId of [...tasks.activePrompts]) {
|
|
427
|
+
const proc = tasks.runningBashProcs.get(taskId);
|
|
344
428
|
if (proc) {
|
|
345
429
|
interruptBashProc(proc);
|
|
346
|
-
|
|
430
|
+
tasks.runningBashProcs.delete(taskId);
|
|
347
431
|
}
|
|
348
432
|
try {
|
|
349
|
-
await this.cancel(
|
|
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
|
|
357
|
-
|
|
440
|
+
for (const taskId of liveTaskIds) {
|
|
441
|
+
tasks.flushBuffers(taskId);
|
|
358
442
|
}
|
|
359
|
-
// 3. Clean up
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
...
|
|
364
|
-
...
|
|
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
|
|
367
|
-
|
|
450
|
+
for (const id of busyTaskIds) {
|
|
451
|
+
tasks.state.patch(id, { runtime: { busy: null } });
|
|
368
452
|
}
|
|
369
|
-
for (const submissionId of
|
|
370
|
-
|
|
453
|
+
for (const submissionId of tasks.pendingPromptSubmissions.values()) {
|
|
454
|
+
tasks.cancelledPromptSubmissions.add(submissionId);
|
|
371
455
|
}
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
// 5. Clear
|
|
380
|
-
|
|
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
|
|
384
|
-
|
|
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
|
|
391
|
-
|
|
474
|
+
for (const taskId of liveTaskIds) {
|
|
475
|
+
tasks.flushBuffers(taskId);
|
|
392
476
|
}
|
|
393
|
-
|
|
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.
|
|
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.
|
|
558
|
+
this.permissionRequestTasks.set(requestId, taskId);
|
|
471
559
|
this.emit("event", {
|
|
472
560
|
type: "permission_request",
|
|
473
561
|
requestId,
|
|
474
|
-
|
|
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
|
|
488
|
-
if (this.silentSessions.has(
|
|
489
|
-
this.captureSilentText(
|
|
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
|
|
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(
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
663
|
+
taskId,
|
|
545
664
|
commands: update.availableCommands.map((command) => ({
|
|
546
665
|
name: command.name,
|
|
547
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()) {
|