@lelouchhe/webagent 0.2.6 → 0.4.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 +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +102 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +127 -9
- package/lib/daemon.js +185 -40
- package/lib/event-handler.js +209 -90
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1218 -144
- package/lib/server.js +159 -32
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +94 -0
- package/lib/store.js +654 -24
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.4FZ67UW4.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
package/lib/bridge.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { spawn
|
|
1
|
+
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
5
|
import { interruptBashProc } from "./session-manager.js";
|
|
6
|
+
import { log } from "./log.js";
|
|
7
|
+
const blog = log.scope("bridge");
|
|
6
8
|
export class AgentBridge extends EventEmitter {
|
|
7
9
|
proc = null;
|
|
8
10
|
conn = null;
|
|
@@ -10,37 +12,76 @@ export class AgentBridge extends EventEmitter {
|
|
|
10
12
|
permissionRequestSessions = new Map();
|
|
11
13
|
silentSessions = new Set(); // Sessions that don't emit events
|
|
12
14
|
silentBuffers = new Map(); // Text buffers for silent sessions
|
|
15
|
+
pendingAborts = new Map();
|
|
16
|
+
deadReason = null;
|
|
17
|
+
stderrTail = "";
|
|
13
18
|
agentCmd;
|
|
14
19
|
reloading = false;
|
|
20
|
+
attachmentDispatcher = null;
|
|
15
21
|
constructor(agentCmd) {
|
|
16
22
|
super();
|
|
17
23
|
this.agentCmd = agentCmd;
|
|
18
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Inject the dispatcher used to translate client attachment refs into
|
|
27
|
+
* ACP prompt blocks. Set once at server boot; staying optional so unit
|
|
28
|
+
* tests that don't exercise attachments can construct a bare bridge.
|
|
29
|
+
*/
|
|
30
|
+
setAttachmentDispatcher(dispatcher) {
|
|
31
|
+
this.attachmentDispatcher = dispatcher;
|
|
32
|
+
}
|
|
19
33
|
async start() {
|
|
20
34
|
const [cmd, ...args] = this.agentCmd.split(/\s+/);
|
|
21
35
|
this.proc = spawn(cmd, args, {
|
|
22
|
-
stdio: ["pipe", "pipe", "
|
|
36
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
23
37
|
});
|
|
24
|
-
if (!this.proc.stdin || !this.proc.stdout) {
|
|
38
|
+
if (!this.proc.stdin || !this.proc.stdout || !this.proc.stderr) {
|
|
25
39
|
throw new Error(`Failed to start: ${this.agentCmd}`);
|
|
26
40
|
}
|
|
41
|
+
// Reset dead state for fresh start, and capture stderr for diagnostics.
|
|
42
|
+
this.deadReason = null;
|
|
43
|
+
this.stderrTail = "";
|
|
44
|
+
this.proc.stderr.on("data", (chunk) => {
|
|
45
|
+
process.stderr.write(chunk);
|
|
46
|
+
this.stderrTail = (this.stderrTail + chunk.toString()).slice(-4096);
|
|
47
|
+
});
|
|
48
|
+
// Detect unexpected agent death. restart() and shutdown() set
|
|
49
|
+
// `reloading=true` so they own the lifecycle and we skip auto-marking.
|
|
50
|
+
const proc = this.proc;
|
|
51
|
+
proc.on("exit", (code, signal) => {
|
|
52
|
+
if (this.reloading)
|
|
53
|
+
return;
|
|
54
|
+
if (proc !== this.proc)
|
|
55
|
+
return; // already replaced
|
|
56
|
+
const tail = this.stderrTail.trim().split("\n").slice(-3).join("\n");
|
|
57
|
+
const why = signal ? `signal=${signal}` : `code=${code}`;
|
|
58
|
+
const reason = `Agent process exited unexpectedly (${why}).` +
|
|
59
|
+
(tail ? `\nLast stderr:\n${tail}` : "") +
|
|
60
|
+
`\nCheck '${this.agentCmd}' is properly configured (e.g. authenticated).`;
|
|
61
|
+
this.markAgentDead(reason);
|
|
62
|
+
});
|
|
63
|
+
proc.on("error", (err) => {
|
|
64
|
+
if (this.reloading)
|
|
65
|
+
return;
|
|
66
|
+
if (proc !== this.proc)
|
|
67
|
+
return;
|
|
68
|
+
this.markAgentDead(`Agent process error: ${err.message}`);
|
|
69
|
+
});
|
|
27
70
|
const input = Writable.toWeb(this.proc.stdin);
|
|
28
71
|
const output = Readable.toWeb(this.proc.stdout);
|
|
29
72
|
const stream = acp.ndJsonStream(input, output);
|
|
30
73
|
const client = {
|
|
31
74
|
requestPermission: async (params) => this.handlePermission(params),
|
|
32
75
|
sessionUpdate: async (params) => this.handleSessionUpdate(params),
|
|
33
|
-
readTextFile: async (params) => this.handleReadFile(params),
|
|
34
|
-
writeTextFile: async (params) => this.handleWriteFile(params),
|
|
35
76
|
};
|
|
36
77
|
this.conn = new acp.ClientSideConnection((_agent) => client, stream);
|
|
37
|
-
const init = await this.conn.initialize({
|
|
78
|
+
const init = (await this.conn.initialize({
|
|
38
79
|
protocolVersion: acp.PROTOCOL_VERSION,
|
|
39
80
|
clientCapabilities: {
|
|
40
|
-
fs: { readTextFile:
|
|
81
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
41
82
|
terminal: true,
|
|
42
83
|
},
|
|
43
|
-
});
|
|
84
|
+
}));
|
|
44
85
|
const agentInfo = init.agentInfo;
|
|
45
86
|
this.emit("event", {
|
|
46
87
|
type: "connected",
|
|
@@ -54,50 +95,103 @@ export class AgentBridge extends EventEmitter {
|
|
|
54
95
|
async newSession(cwd, opts) {
|
|
55
96
|
if (!this.conn)
|
|
56
97
|
throw new Error("Not connected");
|
|
57
|
-
const session = await this.conn.newSession({
|
|
98
|
+
const session = (await this.conn.newSession({
|
|
99
|
+
cwd,
|
|
100
|
+
mcpServers: [],
|
|
101
|
+
}));
|
|
102
|
+
const configOptions = (session.configOptions ??
|
|
103
|
+
[]);
|
|
58
104
|
if (!opts?.silent) {
|
|
59
105
|
this.emit("event", {
|
|
60
106
|
type: "session_created",
|
|
61
107
|
sessionId: session.sessionId,
|
|
62
108
|
cwd,
|
|
63
|
-
configOptions
|
|
109
|
+
configOptions,
|
|
64
110
|
});
|
|
65
111
|
}
|
|
66
|
-
return session.sessionId;
|
|
112
|
+
return { sessionId: session.sessionId, configOptions };
|
|
67
113
|
}
|
|
68
114
|
async loadSession(sessionId, cwd) {
|
|
69
115
|
if (!this.conn)
|
|
70
116
|
throw new Error("Not connected");
|
|
71
|
-
|
|
117
|
+
let session;
|
|
118
|
+
try {
|
|
119
|
+
session = (await this.conn.loadSession({
|
|
120
|
+
sessionId,
|
|
121
|
+
cwd,
|
|
122
|
+
mcpServers: [],
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
// -32002 = Resource not found. Some agents (e.g. claude-agent-acp) don't
|
|
127
|
+
// persist sessions across process restarts, so a session in our DB may
|
|
128
|
+
// be unknown to the live agent. Translate the JSON-RPC error into a
|
|
129
|
+
// user-actionable message; routes returns it as 500 / SSE 'error' event.
|
|
130
|
+
const code = err.code;
|
|
131
|
+
if (code === -32002) {
|
|
132
|
+
throw new Error(`The agent no longer remembers session ${sessionId.slice(0, 8)}… ` +
|
|
133
|
+
`(it may not persist sessions across restarts). Use /new to start a fresh one.`, { cause: err });
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
const configOptions = (session.configOptions ??
|
|
138
|
+
[]);
|
|
72
139
|
this.emit("event", {
|
|
73
140
|
type: "session_created",
|
|
74
|
-
sessionId
|
|
141
|
+
sessionId,
|
|
75
142
|
cwd,
|
|
76
|
-
configOptions
|
|
143
|
+
configOptions,
|
|
77
144
|
});
|
|
78
|
-
return { sessionId
|
|
145
|
+
return { sessionId, configOptions };
|
|
79
146
|
}
|
|
80
147
|
async setConfigOption(sessionId, configId, value) {
|
|
81
148
|
if (!this.conn)
|
|
82
149
|
throw new Error("Not connected");
|
|
83
|
-
const result = await this.conn.setSessionConfigOption({
|
|
84
|
-
|
|
150
|
+
const result = (await this.conn.setSessionConfigOption({
|
|
151
|
+
sessionId,
|
|
152
|
+
configId,
|
|
153
|
+
value,
|
|
154
|
+
}));
|
|
155
|
+
return result.configOptions;
|
|
85
156
|
}
|
|
86
|
-
async prompt(sessionId, text,
|
|
157
|
+
async prompt(sessionId, text, attachments) {
|
|
158
|
+
if (this.deadReason) {
|
|
159
|
+
this.emit("event", {
|
|
160
|
+
type: "error",
|
|
161
|
+
sessionId,
|
|
162
|
+
message: this.deadReason,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
87
166
|
if (!this.conn)
|
|
88
167
|
throw new Error("Not connected");
|
|
168
|
+
let abortReject = () => { };
|
|
169
|
+
const abortPromise = new Promise((_, rej) => {
|
|
170
|
+
abortReject = rej;
|
|
171
|
+
});
|
|
172
|
+
this.pendingAborts.set(sessionId, abortReject);
|
|
89
173
|
try {
|
|
90
174
|
const promptParts = [];
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
175
|
+
if (attachments && attachments.length > 0) {
|
|
176
|
+
if (!this.attachmentDispatcher) {
|
|
177
|
+
// Misconfiguration: routes accepted attachments but bridge has no
|
|
178
|
+
// dispatcher wired. Fail loud so tests / dev catch it; production
|
|
179
|
+
// server.ts always calls setAttachmentDispatcher().
|
|
180
|
+
throw new Error("attachment dispatcher not configured");
|
|
181
|
+
}
|
|
182
|
+
for (const ref of attachments) {
|
|
183
|
+
const block = await this.attachmentDispatcher.dispatch(sessionId, ref);
|
|
184
|
+
promptParts.push(block);
|
|
94
185
|
}
|
|
95
186
|
}
|
|
96
187
|
promptParts.push({ type: "text", text });
|
|
97
|
-
const result = await
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
188
|
+
const result = (await Promise.race([
|
|
189
|
+
this.conn.prompt({
|
|
190
|
+
sessionId,
|
|
191
|
+
prompt: promptParts,
|
|
192
|
+
}),
|
|
193
|
+
abortPromise,
|
|
194
|
+
]));
|
|
101
195
|
this.emit("event", {
|
|
102
196
|
type: "prompt_done",
|
|
103
197
|
sessionId,
|
|
@@ -105,7 +199,11 @@ export class AgentBridge extends EventEmitter {
|
|
|
105
199
|
});
|
|
106
200
|
}
|
|
107
201
|
catch (err) {
|
|
108
|
-
const message = err instanceof Error
|
|
202
|
+
const message = err instanceof Error
|
|
203
|
+
? err.message
|
|
204
|
+
: typeof err === "string"
|
|
205
|
+
? err
|
|
206
|
+
: JSON.stringify(err);
|
|
109
207
|
if (/cancel/i.test(message)) {
|
|
110
208
|
this.emit("event", {
|
|
111
209
|
type: "prompt_done",
|
|
@@ -114,29 +212,79 @@ export class AgentBridge extends EventEmitter {
|
|
|
114
212
|
});
|
|
115
213
|
return;
|
|
116
214
|
}
|
|
117
|
-
this.emit("event", {
|
|
215
|
+
this.emit("event", {
|
|
216
|
+
type: "error",
|
|
217
|
+
sessionId,
|
|
218
|
+
message,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
this.pendingAborts.delete(sessionId);
|
|
118
223
|
}
|
|
119
224
|
}
|
|
120
225
|
async cancel(sessionId) {
|
|
121
|
-
for (const [requestId, requestSessionId] of this
|
|
226
|
+
for (const [requestId, requestSessionId] of this
|
|
227
|
+
.permissionRequestSessions) {
|
|
122
228
|
if (requestSessionId === sessionId) {
|
|
123
229
|
this.denyPermission(requestId);
|
|
124
230
|
}
|
|
125
231
|
}
|
|
126
232
|
await this.conn?.cancel({ sessionId });
|
|
127
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Mark the agent subprocess as dead. Rejects in-flight prompts and emits
|
|
236
|
+
* an `error` event for each so the frontend can exit the busy state and
|
|
237
|
+
* show a useful message instead of hanging forever.
|
|
238
|
+
*
|
|
239
|
+
* Called from `start()`'s `proc.on("exit"|"error")` handlers when the
|
|
240
|
+
* subprocess dies outside of `restart()` / `shutdown()` (which set
|
|
241
|
+
* `reloading=true` to claim the lifecycle). Does NOT auto-restart — for
|
|
242
|
+
* config errors like missing auth, restart would loop into the same
|
|
243
|
+
* failure. User fixes the config and runs `/reload`.
|
|
244
|
+
*/
|
|
245
|
+
markAgentDead(reason) {
|
|
246
|
+
if (this.deadReason)
|
|
247
|
+
return;
|
|
248
|
+
this.deadReason = reason;
|
|
249
|
+
blog.error("agent subprocess dead", { reason });
|
|
250
|
+
const aborts = [...this.pendingAborts.entries()];
|
|
251
|
+
this.pendingAborts.clear();
|
|
252
|
+
for (const [sessionId, abort] of aborts) {
|
|
253
|
+
this.emit("event", {
|
|
254
|
+
type: "error",
|
|
255
|
+
sessionId,
|
|
256
|
+
message: reason,
|
|
257
|
+
});
|
|
258
|
+
abort(new Error(reason));
|
|
259
|
+
}
|
|
260
|
+
this.conn = null;
|
|
261
|
+
}
|
|
128
262
|
/** Send a prompt and collect the full text response without emitting events. */
|
|
129
263
|
async promptForText(sessionId, text) {
|
|
264
|
+
if (this.deadReason)
|
|
265
|
+
throw new Error(this.deadReason);
|
|
130
266
|
if (!this.conn)
|
|
131
267
|
throw new Error("Not connected");
|
|
132
268
|
this.silentSessions.add(sessionId);
|
|
133
269
|
this.silentBuffers.set(sessionId, "");
|
|
270
|
+
let abortReject = () => { };
|
|
271
|
+
const abortPromise = new Promise((_, rej) => {
|
|
272
|
+
abortReject = rej;
|
|
273
|
+
});
|
|
274
|
+
this.pendingAborts.set(sessionId, abortReject);
|
|
134
275
|
try {
|
|
135
|
-
await
|
|
276
|
+
await Promise.race([
|
|
277
|
+
this.conn.prompt({ sessionId, prompt: [{ type: "text", text }] }),
|
|
278
|
+
abortPromise,
|
|
279
|
+
]);
|
|
136
280
|
return this.silentBuffers.get(sessionId) ?? "";
|
|
137
281
|
}
|
|
138
282
|
catch (err) {
|
|
139
|
-
const message = err instanceof Error
|
|
283
|
+
const message = err instanceof Error
|
|
284
|
+
? err.message
|
|
285
|
+
: typeof err === "string"
|
|
286
|
+
? err
|
|
287
|
+
: JSON.stringify(err);
|
|
140
288
|
if (/cancel/i.test(message)) {
|
|
141
289
|
return "";
|
|
142
290
|
}
|
|
@@ -145,6 +293,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
145
293
|
finally {
|
|
146
294
|
this.silentSessions.delete(sessionId);
|
|
147
295
|
this.silentBuffers.delete(sessionId);
|
|
296
|
+
this.pendingAborts.delete(sessionId);
|
|
148
297
|
}
|
|
149
298
|
}
|
|
150
299
|
resolvePermission(requestId, optionId) {
|
|
@@ -173,7 +322,7 @@ export class AgentBridge extends EventEmitter {
|
|
|
173
322
|
throw new Error("Already reloading");
|
|
174
323
|
this.reloading = true;
|
|
175
324
|
this.emit("event", { type: "agent_reloading" });
|
|
176
|
-
|
|
325
|
+
blog.info("reloading agent...");
|
|
177
326
|
try {
|
|
178
327
|
// 1. Cancel all active prompts + kill bash procs
|
|
179
328
|
for (const sessionId of [...sessions.activePrompts]) {
|
|
@@ -185,7 +334,9 @@ export class AgentBridge extends EventEmitter {
|
|
|
185
334
|
try {
|
|
186
335
|
await this.cancel(sessionId);
|
|
187
336
|
}
|
|
188
|
-
catch {
|
|
337
|
+
catch {
|
|
338
|
+
/* best-effort */
|
|
339
|
+
}
|
|
189
340
|
}
|
|
190
341
|
// 2. Flush buffers to persist partial content
|
|
191
342
|
for (const sessionId of sessions.liveSessions) {
|
|
@@ -193,6 +344,9 @@ export class AgentBridge extends EventEmitter {
|
|
|
193
344
|
}
|
|
194
345
|
// 3. Clean up SessionManager state
|
|
195
346
|
sessions.pendingPermissions.clear();
|
|
347
|
+
for (const id of sessions.activePrompts) {
|
|
348
|
+
sessions.state.patch(id, { runtime: { busy: null } });
|
|
349
|
+
}
|
|
196
350
|
sessions.activePrompts.clear();
|
|
197
351
|
// 4. Clean up bridge-side silent session state
|
|
198
352
|
this.silentSessions.clear();
|
|
@@ -201,6 +355,10 @@ export class AgentBridge extends EventEmitter {
|
|
|
201
355
|
titleService.invalidate();
|
|
202
356
|
// 5. Clear liveSessions so ensureResumed() will re-register on next access
|
|
203
357
|
sessions.liveSessions.clear();
|
|
358
|
+
// Also clear the global configOptions cache — a restarted agent may
|
|
359
|
+
// speak a different schema (e.g. agent upgrade removed a model). The
|
|
360
|
+
// next resumeSession will warm it from the user's stored config.
|
|
361
|
+
sessions.cachedConfigOptions = [];
|
|
204
362
|
// 6. Shutdown old process
|
|
205
363
|
await this.shutdown();
|
|
206
364
|
// 7. Start new process with retry (exponential backoff, max 3 attempts)
|
|
@@ -213,17 +371,20 @@ export class AgentBridge extends EventEmitter {
|
|
|
213
371
|
}
|
|
214
372
|
catch (err) {
|
|
215
373
|
lastError = err;
|
|
216
|
-
|
|
374
|
+
blog.error("start attempt failed", { attempt: i + 1, error: err });
|
|
217
375
|
if (i < 2)
|
|
218
376
|
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
|
|
219
377
|
}
|
|
220
378
|
}
|
|
221
379
|
if (lastError || !this.conn) {
|
|
222
380
|
const msg = lastError instanceof Error ? lastError.message : String(lastError);
|
|
223
|
-
this.emit("event", {
|
|
381
|
+
this.emit("event", {
|
|
382
|
+
type: "agent_reloading_failed",
|
|
383
|
+
error: msg,
|
|
384
|
+
});
|
|
224
385
|
throw lastError;
|
|
225
386
|
}
|
|
226
|
-
|
|
387
|
+
blog.info("agent reloaded successfully");
|
|
227
388
|
}
|
|
228
389
|
finally {
|
|
229
390
|
this.reloading = false;
|
|
@@ -231,12 +392,12 @@ export class AgentBridge extends EventEmitter {
|
|
|
231
392
|
}
|
|
232
393
|
async shutdown() {
|
|
233
394
|
// Reject all pending permissions
|
|
234
|
-
for (const [
|
|
395
|
+
for (const [_id, resolve] of this.permissionResolvers) {
|
|
235
396
|
resolve({ outcome: { outcome: "cancelled" } });
|
|
236
397
|
}
|
|
237
398
|
this.permissionResolvers.clear();
|
|
238
399
|
this.permissionRequestSessions.clear();
|
|
239
|
-
if (this.proc
|
|
400
|
+
if (this.proc?.exitCode === null) {
|
|
240
401
|
const proc = this.proc;
|
|
241
402
|
await new Promise((resolve) => {
|
|
242
403
|
const timer = setTimeout(() => {
|
|
@@ -256,8 +417,12 @@ export class AgentBridge extends EventEmitter {
|
|
|
256
417
|
// --- ACP Client callbacks ---
|
|
257
418
|
handlePermission(params) {
|
|
258
419
|
const requestId = crypto.randomUUID();
|
|
259
|
-
const
|
|
260
|
-
|
|
420
|
+
const toolCall = params.toolCall;
|
|
421
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- toolCall may be undefined in practice
|
|
422
|
+
const title = toolCall?.title ?? "Permission requested";
|
|
423
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- toolCall may be undefined in practice
|
|
424
|
+
const toolCallId = toolCall?.toolCallId;
|
|
425
|
+
const tc = toolCall;
|
|
261
426
|
return new Promise((resolve) => {
|
|
262
427
|
// Register resolver BEFORE emitting, so synchronous auto-approve can find it
|
|
263
428
|
this.permissionResolvers.set(requestId, resolve);
|
|
@@ -269,85 +434,71 @@ export class AgentBridge extends EventEmitter {
|
|
|
269
434
|
title,
|
|
270
435
|
toolCallId,
|
|
271
436
|
options: params.options,
|
|
437
|
+
toolKind: tc?.kind,
|
|
438
|
+
toolName: tc?.name,
|
|
439
|
+
locations: tc?.locations,
|
|
440
|
+
rawInput: tc?.rawInput,
|
|
272
441
|
});
|
|
273
442
|
});
|
|
274
443
|
}
|
|
275
444
|
handleSessionUpdate(params) {
|
|
276
445
|
const update = params.update;
|
|
277
446
|
const sessionId = params.sessionId;
|
|
278
|
-
// Silent sessions: only buffer text, don't emit events
|
|
279
447
|
if (this.silentSessions.has(sessionId)) {
|
|
280
|
-
|
|
281
|
-
const buf = (this.silentBuffers.get(sessionId) ?? "") + update.content.text;
|
|
282
|
-
this.silentBuffers.set(sessionId, buf);
|
|
283
|
-
}
|
|
448
|
+
this.captureSilentText(sessionId, update);
|
|
284
449
|
return Promise.resolve();
|
|
285
450
|
}
|
|
451
|
+
const event = this.sessionUpdateToEvent(sessionId, update);
|
|
452
|
+
if (event)
|
|
453
|
+
this.emit("event", event);
|
|
454
|
+
return Promise.resolve();
|
|
455
|
+
}
|
|
456
|
+
captureSilentText(sessionId, update) {
|
|
457
|
+
if (update.sessionUpdate === "agent_message_chunk" &&
|
|
458
|
+
update.content.type === "text") {
|
|
459
|
+
const buf = (this.silentBuffers.get(sessionId) ?? "") + update.content.text;
|
|
460
|
+
this.silentBuffers.set(sessionId, buf);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
sessionUpdateToEvent(sessionId, update) {
|
|
464
|
+
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only handles events with UI effects
|
|
286
465
|
switch (update.sessionUpdate) {
|
|
287
466
|
case "agent_message_chunk":
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
sessionId,
|
|
292
|
-
text: update.content.text,
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
break;
|
|
467
|
+
return update.content.type === "text"
|
|
468
|
+
? { type: "message_chunk", sessionId, text: update.content.text }
|
|
469
|
+
: null;
|
|
296
470
|
case "agent_thought_chunk":
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
sessionId,
|
|
301
|
-
text: update.content.text,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
break;
|
|
471
|
+
return update.content.type === "text"
|
|
472
|
+
? { type: "thought_chunk", sessionId, text: update.content.text }
|
|
473
|
+
: null;
|
|
305
474
|
case "tool_call":
|
|
306
|
-
|
|
475
|
+
return {
|
|
307
476
|
type: "tool_call",
|
|
308
477
|
sessionId,
|
|
309
|
-
id: update.toolCallId
|
|
310
|
-
title: update.title
|
|
478
|
+
id: update.toolCallId,
|
|
479
|
+
title: update.title,
|
|
311
480
|
kind: update.kind ?? "unknown",
|
|
312
481
|
rawInput: update.rawInput,
|
|
313
|
-
}
|
|
314
|
-
break;
|
|
482
|
+
};
|
|
315
483
|
case "tool_call_update":
|
|
316
|
-
|
|
484
|
+
return {
|
|
317
485
|
type: "tool_call_update",
|
|
318
486
|
sessionId,
|
|
319
|
-
id: update.toolCallId
|
|
487
|
+
id: update.toolCallId,
|
|
320
488
|
status: update.status ?? "",
|
|
321
|
-
content: update.content ?? undefined,
|
|
322
|
-
}
|
|
323
|
-
break;
|
|
489
|
+
content: (update.content ?? undefined),
|
|
490
|
+
};
|
|
324
491
|
case "plan":
|
|
325
|
-
|
|
326
|
-
type: "plan",
|
|
327
|
-
sessionId,
|
|
328
|
-
entries: update.entries ?? [],
|
|
329
|
-
});
|
|
330
|
-
break;
|
|
492
|
+
return { type: "plan", sessionId, entries: update.entries };
|
|
331
493
|
case "config_option_update":
|
|
332
|
-
|
|
494
|
+
return {
|
|
333
495
|
type: "config_option_update",
|
|
334
496
|
sessionId,
|
|
335
|
-
configOptions: update
|
|
336
|
-
|
|
337
|
-
|
|
497
|
+
configOptions: update
|
|
498
|
+
.configOptions ?? [],
|
|
499
|
+
};
|
|
500
|
+
default:
|
|
501
|
+
return null;
|
|
338
502
|
}
|
|
339
|
-
return Promise.resolve();
|
|
340
|
-
}
|
|
341
|
-
async handleReadFile(params) {
|
|
342
|
-
const { readFile } = await import("node:fs/promises");
|
|
343
|
-
const content = await readFile(params.path, "utf-8");
|
|
344
|
-
return { content };
|
|
345
|
-
}
|
|
346
|
-
async handleWriteFile(params) {
|
|
347
|
-
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
348
|
-
const { dirname } = await import("node:path");
|
|
349
|
-
await mkdir(dirname(params.path), { recursive: true });
|
|
350
|
-
await writeFile(params.path, params.content);
|
|
351
|
-
return {};
|
|
352
503
|
}
|
|
353
504
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClientRegistry — in-memory tracking of connected clients.
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-client metadata that survives SSE disconnect:
|
|
5
|
+
* - capabilities advertised by the client on /hello.
|
|
6
|
+
* - focus: clientId's current sessionId (set via /focus). Used by push
|
|
7
|
+
* visibility suppression to know which session each client is viewing.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle: clients call /hello on SSE connect (register) and /focus when
|
|
10
|
+
* they switch sessions. SSE disconnect does not remove a client — it stays
|
|
11
|
+
* in the registry until an explicit /goodbye or TTL eviction (caller's
|
|
12
|
+
* responsibility). This lets visibility state outlive transient drops.
|
|
13
|
+
*/
|
|
14
|
+
export class ClientRegistry {
|
|
15
|
+
clients = new Map();
|
|
16
|
+
register(id, data) {
|
|
17
|
+
const existing = this.clients.get(id);
|
|
18
|
+
if (existing) {
|
|
19
|
+
existing.capabilities = data.capabilities;
|
|
20
|
+
existing.lastSeen = Date.now();
|
|
21
|
+
return existing;
|
|
22
|
+
}
|
|
23
|
+
const entry = {
|
|
24
|
+
id,
|
|
25
|
+
capabilities: data.capabilities,
|
|
26
|
+
focus: null,
|
|
27
|
+
lastSeen: Date.now(),
|
|
28
|
+
};
|
|
29
|
+
this.clients.set(id, entry);
|
|
30
|
+
return entry;
|
|
31
|
+
}
|
|
32
|
+
remove(id) {
|
|
33
|
+
this.clients.delete(id);
|
|
34
|
+
}
|
|
35
|
+
setFocus(id, sessionId) {
|
|
36
|
+
const entry = this.clients.get(id);
|
|
37
|
+
if (!entry)
|
|
38
|
+
return;
|
|
39
|
+
entry.focus = sessionId;
|
|
40
|
+
entry.lastSeen = Date.now();
|
|
41
|
+
}
|
|
42
|
+
updateCapabilities(id, caps) {
|
|
43
|
+
const entry = this.clients.get(id);
|
|
44
|
+
if (!entry)
|
|
45
|
+
return;
|
|
46
|
+
entry.capabilities = caps;
|
|
47
|
+
entry.lastSeen = Date.now();
|
|
48
|
+
}
|
|
49
|
+
touch(id) {
|
|
50
|
+
const entry = this.clients.get(id);
|
|
51
|
+
if (entry)
|
|
52
|
+
entry.lastSeen = Date.now();
|
|
53
|
+
}
|
|
54
|
+
get(id) {
|
|
55
|
+
return this.clients.get(id);
|
|
56
|
+
}
|
|
57
|
+
list() {
|
|
58
|
+
return Array.from(this.clients.values());
|
|
59
|
+
}
|
|
60
|
+
}
|