@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.
Files changed (57) hide show
  1. package/README.md +58 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +102 -3
  4. package/dist/index.html +64 -41
  5. package/dist/js/app.GSAIYHML.js +4 -0
  6. package/dist/js/chunk.AJZBJBMO.js +1 -0
  7. package/dist/js/chunk.CGWFHJI2.js +76 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.VZXGXFNN.js +5 -0
  10. package/dist/js/login.PYIK52HN.js +1 -0
  11. package/dist/js/viewer.6DT53STL.js +1 -0
  12. package/dist/login.html +49 -0
  13. package/dist/share-viewer.00gubshk.css +114 -0
  14. package/dist/share-viewer.html +53 -0
  15. package/dist/styles.012p32dz.css +1443 -0
  16. package/dist/sw.js +79 -27
  17. package/dist/theme-init.js +6 -0
  18. package/lib/agent-detect.js +110 -0
  19. package/lib/atomic-write.js +50 -0
  20. package/lib/attachment-dispatch.js +86 -0
  21. package/lib/attachment-interceptor.js +130 -0
  22. package/lib/attachment-labels.js +139 -0
  23. package/lib/attachments.js +154 -0
  24. package/lib/auth-middleware.js +102 -0
  25. package/lib/auth-store.js +269 -0
  26. package/lib/auth.js +89 -0
  27. package/lib/bootstrap.js +70 -0
  28. package/lib/bridge.js +244 -93
  29. package/lib/client-registry.js +60 -0
  30. package/lib/config.js +127 -9
  31. package/lib/daemon.js +185 -40
  32. package/lib/event-handler.js +209 -90
  33. package/lib/log-fmt.js +67 -0
  34. package/lib/log.js +83 -0
  35. package/lib/message-cleanup.js +48 -0
  36. package/lib/mode-bucket.js +62 -0
  37. package/lib/preflight.js +195 -0
  38. package/lib/push-service.js +338 -45
  39. package/lib/routes.js +1218 -144
  40. package/lib/server.js +159 -32
  41. package/lib/session-manager.js +164 -18
  42. package/lib/session-state.js +160 -0
  43. package/lib/sessions-anchor.js +28 -0
  44. package/lib/share/cleanup.js +45 -0
  45. package/lib/share/routes.js +972 -0
  46. package/lib/share/sanitize.js +179 -0
  47. package/lib/sse-manager.js +94 -8
  48. package/lib/sse-ticket.js +45 -0
  49. package/lib/startup-checks.js +94 -0
  50. package/lib/store.js +654 -24
  51. package/lib/title-service.js +42 -9
  52. package/lib/tokens.js +50 -0
  53. package/lib/types.js +23 -0
  54. package/package.json +38 -4
  55. package/dist/js/app.4FZ67UW4.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. package/lib/shared/constants.js +0 -17
package/lib/bridge.js CHANGED
@@ -1,8 +1,10 @@
1
- import { spawn, ChildProcess } from "node:child_process";
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", "inherit"],
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: true, writeTextFile: true },
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({ cwd, mcpServers: [] });
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: session.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
- const session = await this.conn.loadSession({ sessionId, cwd, mcpServers: [] });
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: session.sessionId,
141
+ sessionId,
75
142
  cwd,
76
- configOptions: session.configOptions ?? [],
143
+ configOptions,
77
144
  });
78
- return { sessionId: session.sessionId, configOptions: session.configOptions ?? [] };
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({ sessionId, configId, value });
84
- return result.configOptions ?? [];
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, images) {
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 (images) {
92
- for (const img of images) {
93
- promptParts.push({ type: "image", data: img.data, mimeType: img.mimeType });
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 this.conn.prompt({
98
- sessionId,
99
- prompt: promptParts,
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 ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
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", { type: "error", sessionId, message });
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.permissionRequestSessions) {
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 this.conn.prompt({ sessionId, prompt: [{ type: "text", text }] });
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 ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
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
- console.log("[bridge] reloading agent...");
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 { /* best-effort */ }
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
- console.error(`[bridge] start attempt ${i + 1} failed:`, err);
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", { type: "agent_reloading_failed", error: msg });
381
+ this.emit("event", {
382
+ type: "agent_reloading_failed",
383
+ error: msg,
384
+ });
224
385
  throw lastError;
225
386
  }
226
- console.log("[bridge] agent reloaded successfully");
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 [id, resolve] of this.permissionResolvers) {
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 && this.proc.exitCode === null) {
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 title = params.toolCall?.title ?? "Permission requested";
260
- const toolCallId = params.toolCall?.toolCallId ?? null;
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
- if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
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
- if (update.content.type === "text") {
289
- this.emit("event", {
290
- type: "message_chunk",
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
- if (update.content.type === "text") {
298
- this.emit("event", {
299
- type: "thought_chunk",
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
- this.emit("event", {
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
- this.emit("event", {
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
- this.emit("event", {
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
- this.emit("event", {
494
+ return {
333
495
  type: "config_option_update",
334
496
  sessionId,
335
- configOptions: update.configOptions ?? [],
336
- });
337
- break;
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
+ }