@lelouchhe/webagent 0.7.0 → 0.8.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.
@@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
3
3
  import { rm } from "node:fs/promises";
4
4
  import { stat } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
+ import { MessageNotFoundError } from "./store.js";
6
7
  import { SessionStateManager } from "./session-state.js";
7
8
  import { buildLabelMap } from "./attachment-labels.js";
8
9
  import { log } from "./log.js";
9
10
  const slog = log.scope("session");
10
11
  const IS_WIN = process.platform === "win32";
11
- export function interruptBashProc(proc) {
12
+ export function interruptBashProc(proc, force = false) {
12
13
  if (!proc)
13
14
  return;
14
15
  if (IS_WIN && typeof proc.pid === "number") {
@@ -18,19 +19,23 @@ export function interruptBashProc(proc) {
18
19
  }
19
20
  if (typeof proc.pid === "number") {
20
21
  try {
21
- process.kill(-proc.pid, "SIGINT");
22
+ process.kill(-proc.pid, force ? "SIGKILL" : "SIGINT");
22
23
  return;
23
24
  }
24
25
  catch {
25
26
  // Fall through to direct child kill when the process is not a group leader.
26
27
  }
27
28
  }
28
- proc.kill("SIGINT");
29
+ proc.kill(force ? "SIGKILL" : "SIGINT");
29
30
  }
30
- /** Known config option IDs that we persist per-session. */
31
- const _PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
32
31
  /** Minimum age (seconds) before an empty session is eligible for cleanup. */
33
32
  const EMPTY_SESSION_MIN_AGE_S = 60;
33
+ export class InvalidSessionDirectoryError extends Error {
34
+ constructor(cwd) {
35
+ super(`Directory does not exist: ${cwd}`);
36
+ this.name = "InvalidSessionDirectoryError";
37
+ }
38
+ }
34
39
  /**
35
40
  * Centralizes all session-related state that was previously scattered
36
41
  * across module-level variables in server.ts.
@@ -42,13 +47,20 @@ export class SessionManager {
42
47
  assistantBuffers = new Map();
43
48
  thinkingBuffers = new Map();
44
49
  activePrompts = new Set();
50
+ pendingPromptSubmissions = new Map();
51
+ cancelledPromptSubmissions = new Set();
45
52
  runningBashProcs = new Map();
53
+ interruptedBashProcs = new WeakSet();
46
54
  /** Pending permission requests keyed by requestId. */
47
55
  pendingPermissions = new Map();
48
56
  /** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
49
57
  state = new SessionStateManager();
50
58
  /** Deduplicates concurrent resume calls for the same session. */
51
59
  pendingResumes = new Map();
60
+ nextPromptNumber = 0;
61
+ nextPromptSubmissionNumber = 0;
62
+ /** Deduplicates concurrent attempts to materialize one inbox message. */
63
+ pendingMessageConsumes = new Map();
52
64
  /**
53
65
  * Per-session attachment label map (CLAUDE.md "Attachment label
54
66
  * egress rewrite"). Built lazily from the `attachments` table on
@@ -76,7 +88,7 @@ export class SessionManager {
76
88
  }
77
89
  }
78
90
  /** Create a new session in both bridge and store, inheriting the source session's config. */
79
- async createSession(bridge, cwd, inheritFromSessionId, source = "auto") {
91
+ async createSession(bridge, cwd, inheritFromSessionId, source = "auto", opts) {
80
92
  const sessionCwd = cwd ?? this.defaultCwd;
81
93
  try {
82
94
  const info = await stat(sessionCwd);
@@ -84,7 +96,7 @@ export class SessionManager {
84
96
  throw new Error("not a directory");
85
97
  }
86
98
  catch {
87
- throw new Error(`Directory does not exist: ${sessionCwd}`);
99
+ throw new InvalidSessionDirectoryError(sessionCwd);
88
100
  }
89
101
  // Clean up empty sessions (no events) older than the threshold
90
102
  const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
@@ -97,9 +109,20 @@ export class SessionManager {
97
109
  const sourceSession = inheritFromSessionId
98
110
  ? this.store.getSession(inheritFromSessionId)
99
111
  : null;
100
- const { sessionId } = await bridge.newSession(sessionCwd);
112
+ const { sessionId, configOptions: createdConfigOptions } = await bridge.newSession(sessionCwd, { silent: opts?.silent });
113
+ let configOptions = createdConfigOptions;
114
+ try {
115
+ this.store.createSession(sessionId, sessionCwd, source);
116
+ }
117
+ catch (err) {
118
+ slog.warn("ACP session created but local persistence failed", {
119
+ sessionId,
120
+ error: err,
121
+ });
122
+ throw err;
123
+ }
101
124
  this.liveSessions.add(sessionId);
102
- this.store.createSession(sessionId, sessionCwd, source);
125
+ this.recordConfigOptions(sessionId, createdConfigOptions);
103
126
  // Inherit config options from source session
104
127
  if (sourceSession) {
105
128
  const inherited = [
@@ -110,8 +133,14 @@ export class SessionManager {
110
133
  if (!value)
111
134
  continue;
112
135
  try {
113
- await bridge.setConfigOption(sessionId, configId, value);
114
- this.store.updateSessionConfig(sessionId, configId, value);
136
+ const updatedConfigOptions = await bridge.setConfigOption(sessionId, configId, value);
137
+ if (updatedConfigOptions.length > 0) {
138
+ configOptions = updatedConfigOptions;
139
+ this.recordConfigOptions(sessionId, updatedConfigOptions);
140
+ }
141
+ else {
142
+ this.store.updateSessionConfig(sessionId, configId, value);
143
+ }
115
144
  }
116
145
  catch {
117
146
  // Option may no longer be available; ignore
@@ -121,9 +150,73 @@ export class SessionManager {
121
150
  const session = this.store.getSession(sessionId);
122
151
  return {
123
152
  sessionId,
124
- configOptions: session ? this.buildConfigOptions(session) : [],
153
+ configOptions: session
154
+ ? this.applyStoredConfig(configOptions, session)
155
+ : [],
125
156
  };
126
157
  }
158
+ /** Cache the ACP config schema and persist this session's current values. */
159
+ recordConfigOptions(sessionId, configOptions) {
160
+ if (configOptions.length === 0)
161
+ return;
162
+ this.cachedConfigOptions = configOptions;
163
+ for (const option of configOptions) {
164
+ if (typeof option.currentValue === "string") {
165
+ this.store.updateSessionConfig(sessionId, option.id, option.currentValue);
166
+ }
167
+ }
168
+ }
169
+ /**
170
+ * Materialize a pending inbox message as a real ACP-backed session.
171
+ * Returns null when the message is neither pending nor previously consumed.
172
+ */
173
+ consumeMessage(bridge, messageId, inheritFromSessionId) {
174
+ const pending = this.pendingMessageConsumes.get(messageId);
175
+ if (pending)
176
+ return pending;
177
+ const existing = this.store.findConsumedMessageSession(messageId);
178
+ if (existing) {
179
+ return Promise.resolve({
180
+ sessionId: existing,
181
+ alreadyConsumed: true,
182
+ });
183
+ }
184
+ const operation = this.consumePendingMessage(bridge, messageId, inheritFromSessionId).finally(() => {
185
+ if (this.pendingMessageConsumes.get(messageId) === operation) {
186
+ this.pendingMessageConsumes.delete(messageId);
187
+ }
188
+ });
189
+ this.pendingMessageConsumes.set(messageId, operation);
190
+ return operation;
191
+ }
192
+ async consumePendingMessage(bridge, messageId, inheritFromSessionId) {
193
+ const message = this.store.getMessage(messageId);
194
+ if (!message)
195
+ return null;
196
+ const { sessionId } = await this.createSession(bridge, message.cwd ?? undefined, inheritFromSessionId, "message", { silent: true });
197
+ try {
198
+ const result = this.store.consumeMessageTx(messageId, sessionId);
199
+ if (result.alreadyConsumed) {
200
+ this.deleteSession(sessionId);
201
+ }
202
+ return result;
203
+ }
204
+ catch (err) {
205
+ this.deleteSession(sessionId);
206
+ if (err instanceof MessageNotFoundError) {
207
+ const consumedSessionId = this.store.findConsumedMessageSession(messageId);
208
+ return consumedSessionId
209
+ ? { sessionId: consumedSessionId, alreadyConsumed: true }
210
+ : null;
211
+ }
212
+ slog.warn("message consume left an unreachable ACP session", {
213
+ messageId,
214
+ sessionId,
215
+ error: err,
216
+ });
217
+ throw err;
218
+ }
219
+ }
127
220
  /** Resume a session — returns event to send to the requesting client. */
128
221
  async resumeSession(bridge, sessionId) {
129
222
  const session = this.store.getSession(sessionId);
@@ -233,7 +326,7 @@ export class SessionManager {
233
326
  /** Override currentValue in configOptions with stored session values. */
234
327
  applyStoredConfig(configOptions, session) {
235
328
  if (!configOptions.length)
236
- return this.cachedConfigOptions;
329
+ return configOptions;
237
330
  const stored = {
238
331
  model: session.model,
239
332
  mode: session.mode,
@@ -311,6 +404,11 @@ export class SessionManager {
311
404
  this.assistantBuffers.delete(sessionId);
312
405
  this.thinkingBuffers.delete(sessionId);
313
406
  this.activePrompts.delete(sessionId);
407
+ const pendingSubmission = this.pendingPromptSubmissions.get(sessionId);
408
+ this.pendingPromptSubmissions.delete(sessionId);
409
+ if (pendingSubmission !== undefined) {
410
+ this.cancelledPromptSubmissions.delete(pendingSubmission);
411
+ }
314
412
  this.runningBashProcs.delete(sessionId);
315
413
  this.attachmentLabelCache.delete(sessionId);
316
414
  this.agentCommandSnapshots.delete(sessionId);
@@ -338,17 +436,17 @@ export class SessionManager {
338
436
  /** Flush only the assistant message buffer to store. */
339
437
  flushAssistantBuffer(sessionId) {
340
438
  const assistant = this.assistantBuffers.get(sessionId);
341
- if (assistant) {
439
+ this.assistantBuffers.delete(sessionId);
440
+ if (assistant && this.store.getSession(sessionId)) {
342
441
  this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
343
- this.assistantBuffers.delete(sessionId);
344
442
  }
345
443
  }
346
444
  /** Flush only the thinking buffer to store. */
347
445
  flushThinkingBuffer(sessionId) {
348
446
  const thinking = this.thinkingBuffers.get(sessionId);
349
- if (thinking) {
447
+ this.thinkingBuffers.delete(sessionId);
448
+ if (thinking && this.store.getSession(sessionId)) {
350
449
  this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
351
- this.thinkingBuffers.delete(sessionId);
352
450
  }
353
451
  }
354
452
  /** Append to assistant message buffer. */
@@ -366,12 +464,52 @@ export class SessionManager {
366
464
  return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
367
465
  }
368
466
  getBusyKind(sessionId) {
369
- if (this.runningBashProcs.has(sessionId))
370
- return "bash";
467
+ if (this.pendingPromptSubmissions.has(sessionId))
468
+ return "agent";
371
469
  if (this.activePrompts.has(sessionId))
372
470
  return "agent";
471
+ if (this.runningBashProcs.has(sessionId))
472
+ return "bash";
373
473
  return null;
374
474
  }
475
+ /**
476
+ * True when `promptId` still names the session's live turn. A turn can
477
+ * outlive its own supersession — cancelling one and immediately starting
478
+ * another interleaves them — and its terminal work must not clear state
479
+ * that now belongs to the replacement. An absent id is treated as current
480
+ * so callers predating turn identity keep their old behaviour.
481
+ */
482
+ isCurrentPrompt(sessionId, promptId) {
483
+ if (!promptId)
484
+ return true;
485
+ const current = this.state.getState(sessionId).runtime.busy?.promptId;
486
+ return current == null || current === promptId;
487
+ }
488
+ reservePromptSubmission(sessionId) {
489
+ if (this.getBusyKind(sessionId) !== null)
490
+ return null;
491
+ const submissionId = ++this.nextPromptSubmissionNumber;
492
+ this.pendingPromptSubmissions.set(sessionId, submissionId);
493
+ return submissionId;
494
+ }
495
+ cancelPendingPromptSubmission(sessionId) {
496
+ const submissionId = this.pendingPromptSubmissions.get(sessionId);
497
+ if (submissionId === undefined)
498
+ return false;
499
+ this.cancelledPromptSubmissions.add(submissionId);
500
+ return true;
501
+ }
502
+ isPromptSubmissionCancelled(submissionId) {
503
+ return this.cancelledPromptSubmissions.has(submissionId);
504
+ }
505
+ releasePromptSubmission(sessionId, submissionId, sync = true) {
506
+ if (this.pendingPromptSubmissions.get(sessionId) === submissionId) {
507
+ this.pendingPromptSubmissions.delete(sessionId);
508
+ }
509
+ this.cancelledPromptSubmissions.delete(submissionId);
510
+ if (sync)
511
+ this.syncBusy(sessionId);
512
+ }
375
513
  /**
376
514
  * Recompute busy from active prompts/bash procs and patch the state manager.
377
515
  * Call this immediately after mutating activePrompts / runningBashProcs so
@@ -390,8 +528,14 @@ export class SessionManager {
390
528
  this.state.clearCancelSafety(sessionId);
391
529
  return;
392
530
  }
393
- const nextPromptId = kind === "agent" ? (promptId ?? current?.promptId ?? null) : null;
394
- if (current?.kind === kind && current.promptId === nextPromptId)
531
+ const nextPromptId = kind === "agent"
532
+ ? (promptId ??
533
+ (current?.kind === "agent"
534
+ ? current.promptId
535
+ : `prompt-${++this.nextPromptNumber}`))
536
+ : null;
537
+ const sameWork = current?.kind === kind && current.promptId === nextPromptId;
538
+ if (sameWork)
395
539
  return;
396
540
  this.state.patch(sessionId, {
397
541
  runtime: {
@@ -399,6 +543,7 @@ export class SessionManager {
399
543
  kind: kind,
400
544
  since: current?.kind === kind ? current.since : new Date().toISOString(),
401
545
  promptId: nextPromptId,
546
+ cancelStatus: null,
402
547
  },
403
548
  },
404
549
  });
@@ -417,13 +562,16 @@ export class SessionManager {
417
562
  });
418
563
  this.activePrompts.add(sessionId);
419
564
  this.syncBusy(sessionId);
565
+ const promptId = this.state.getState(sessionId).runtime.busy?.promptId ?? undefined;
420
566
  bridge
421
- .prompt(sessionId, "Continue your previous response — it was interrupted mid-way.")
567
+ .prompt(sessionId, "Continue your previous response — it was interrupted mid-way.", undefined, promptId)
422
568
  .catch((err) => {
423
569
  slog.error("auto-retry failed", {
424
570
  sessionId: sessionId.slice(0, 8) + "…",
425
571
  error: err,
426
572
  });
573
+ if (!this.isCurrentPrompt(sessionId, promptId))
574
+ return;
427
575
  this.activePrompts.delete(sessionId);
428
576
  this.syncBusy(sessionId);
429
577
  });
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Per-session runtime state: single source of truth for "what state is this
3
- * session in right now" (busy / streaming / pending permissions).
3
+ * session in right now" (busy / streaming / pending permissions / plan).
4
4
  *
5
5
  * The frontend fetches a full snapshot on connect / reconnect / after long
6
6
  * backgrounding, then applies incremental `state_patch` SSE events. This
7
7
  * replaces the old "replay history + reconcile" approach which repeatedly
8
8
  * grew one-off sync paths per state field.
9
9
  */
10
+ import { log } from "./log.js";
11
+ const clog = log.scope("cancel");
10
12
  function defaultState() {
11
13
  return {
12
14
  seq: 0,
@@ -14,6 +16,7 @@ function defaultState() {
14
16
  busy: null,
15
17
  pendingPermissions: [],
16
18
  streaming: { assistant: false, thinking: false },
19
+ plan: null,
17
20
  },
18
21
  };
19
22
  }
@@ -22,7 +25,10 @@ function busyEqual(a, b) {
22
25
  return true;
23
26
  if (a === null || b === null)
24
27
  return false;
25
- return a.kind === b.kind && a.since === b.since && a.promptId === b.promptId;
28
+ return (a.kind === b.kind &&
29
+ a.since === b.since &&
30
+ a.promptId === b.promptId &&
31
+ (a.cancelStatus ?? null) === (b.cancelStatus ?? null));
26
32
  }
27
33
  function permsEqual(a, b) {
28
34
  if (a.length !== b.length)
@@ -43,6 +49,13 @@ function permsEqual(a, b) {
43
49
  }
44
50
  return true;
45
51
  }
52
+ function plansEqual(a, b) {
53
+ if (a === null || b === null)
54
+ return a === b;
55
+ if (a.length !== b.length)
56
+ return false;
57
+ return a.every((entry, index) => entry.status === b[index].status && entry.content === b[index].content);
58
+ }
46
59
  /** True when the patch would change the current runtime state. */
47
60
  function hasRuntimeChanges(current, patch) {
48
61
  if (!patch)
@@ -53,6 +66,8 @@ function hasRuntimeChanges(current, patch) {
53
66
  patch.pendingPermissions &&
54
67
  !permsEqual(current.pendingPermissions, patch.pendingPermissions))
55
68
  return true;
69
+ if ("plan" in patch && !plansEqual(current.plan, patch.plan ?? null))
70
+ return true;
56
71
  if ("streaming" in patch && patch.streaming) {
57
72
  const s = patch.streaming;
58
73
  if (s.assistant !== undefined &&
@@ -76,6 +91,11 @@ export class SessionStateManager {
76
91
  }
77
92
  return s;
78
93
  }
94
+ /** Read streaming state without creating runtime state for an unseen session. */
95
+ peekStreaming(sessionId) {
96
+ const streaming = this.states.get(sessionId)?.runtime.streaming;
97
+ return streaming ? { ...streaming } : { assistant: false, thinking: false };
98
+ }
79
99
  /**
80
100
  * Merge a patch into the session's runtime state. Bumps seq and notifies
81
101
  * listeners only when the patch actually changes something (no-op patches
@@ -95,6 +115,10 @@ export class SessionStateManager {
95
115
  state.runtime.pendingPermissions =
96
116
  patch.runtime.pendingPermissions.slice();
97
117
  }
118
+ if ("plan" in patch.runtime) {
119
+ state.runtime.plan =
120
+ patch.runtime.plan?.map((entry) => ({ ...entry })) ?? null;
121
+ }
98
122
  if ("streaming" in patch.runtime && patch.runtime.streaming) {
99
123
  if (patch.runtime.streaming.assistant !== undefined) {
100
124
  state.runtime.streaming.assistant = patch.runtime.streaming.assistant;
@@ -130,9 +154,30 @@ export class SessionStateManager {
130
154
  this.cancelTimers.delete(sessionId);
131
155
  }
132
156
  }
157
+ /** Clear current plans for every known session (used on bridge reload). */
158
+ clearPlans() {
159
+ for (const [sessionId, state] of this.states) {
160
+ if (state.runtime.plan !== null) {
161
+ this.patch(sessionId, { runtime: { plan: null } });
162
+ }
163
+ }
164
+ }
165
+ /** Clear active stream markers for every known session on bridge teardown. */
166
+ clearStreaming() {
167
+ for (const [sessionId, state] of this.states) {
168
+ if (state.runtime.streaming.assistant ||
169
+ state.runtime.streaming.thinking) {
170
+ this.patch(sessionId, {
171
+ runtime: {
172
+ streaming: { assistant: false, thinking: false },
173
+ },
174
+ });
175
+ }
176
+ }
177
+ }
133
178
  /**
134
- * Backend safety net for cancel: if busy is still set after `timeoutMs`,
135
- * force-clear it. Replaces the old frontend cancel timer.
179
+ * Backend acknowledgement timer for cancel: if the same agent prompt is
180
+ * still pending after `timeoutMs`, mark the request unconfirmed.
136
181
  * A second arm on the same session replaces the existing timer.
137
182
  */
138
183
  armCancelSafety(sessionId, timeoutMs) {
@@ -143,12 +188,32 @@ export class SessionStateManager {
143
188
  clearTimeout(existing);
144
189
  const t = setTimeout(() => {
145
190
  this.cancelTimers.delete(sessionId);
146
- this.patch(sessionId, { runtime: { busy: null } });
191
+ const busy = this.getState(sessionId).runtime.busy;
192
+ if (busy?.kind === "agent" && busy.cancelStatus === "requested") {
193
+ clog.warn("agent did not acknowledge", {
194
+ sessionId: sessionId.slice(0, 8),
195
+ promptId: busy.promptId,
196
+ });
197
+ this.patch(sessionId, {
198
+ runtime: {
199
+ busy: { ...busy, cancelStatus: "unconfirmed" },
200
+ },
201
+ });
202
+ }
147
203
  }, timeoutMs);
148
204
  if (typeof t === "object" && "unref" in t)
149
205
  t.unref();
150
206
  this.cancelTimers.set(sessionId, t);
151
207
  }
208
+ /** Mark that a cancel notification was sent for the active agent prompt. */
209
+ markCancelRequested(sessionId) {
210
+ const busy = this.getState(sessionId).runtime.busy;
211
+ if (busy?.kind !== "agent")
212
+ return;
213
+ this.patch(sessionId, {
214
+ runtime: { busy: { ...busy, cancelStatus: "requested" } },
215
+ });
216
+ }
152
217
  /** Cancel the safety net timer (e.g. when prompt_done arrives naturally). */
153
218
  clearCancelSafety(sessionId) {
154
219
  const t = this.cancelTimers.get(sessionId);
@@ -1,6 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { reSignAttachmentUrlsInJson } from "./auth.js";
3
3
  import { enrichEventForDisplay } from "./attachment-labels.js";
4
+ import { log } from "./log.js";
5
+ const slog = log.scope("sse");
4
6
  /**
5
7
  * SSE heartbeat frame — a NAMED event so the frontend can hook
6
8
  * `es.addEventListener("heartbeat", ...)` and refresh its per-session
@@ -69,7 +71,7 @@ export class SseManager {
69
71
  catch {
70
72
  /* already torn down */
71
73
  }
72
- this.remove(client.id);
74
+ this.remove(client.id, "token-revoked");
73
75
  continue;
74
76
  }
75
77
  client.res.write(SSE_HEARTBEAT_FRAME);
@@ -91,8 +93,13 @@ export class SseManager {
91
93
  /** Register a new SSE client connection. */
92
94
  add(client) {
93
95
  this.clients.set(client.id, client);
96
+ slog.info("connected", {
97
+ clientId: client.id,
98
+ sessionId: client.sessionId ?? "*",
99
+ clients: this.clients.size,
100
+ });
94
101
  client.res.on("close", () => {
95
- this.remove(client.id);
102
+ this.remove(client.id, "closed");
96
103
  });
97
104
  }
98
105
  /** Write a single heartbeat frame to the given client. Used right after
@@ -109,9 +116,16 @@ export class SseManager {
109
116
  // socket already dead; res.on("close") will clean up
110
117
  }
111
118
  }
112
- /** Remove a client by ID. */
113
- remove(id) {
114
- this.clients.delete(id);
119
+ /** Remove a client by ID. `reason` is recorded so an operator can tell an
120
+ * ordinary disconnect apart from a write failure or a revoked token. */
121
+ remove(id, reason = "closed") {
122
+ if (this.clients.delete(id)) {
123
+ slog.info("disconnected", {
124
+ clientId: id,
125
+ reason,
126
+ clients: this.clients.size,
127
+ });
128
+ }
115
129
  this.onRemoveCallback?.(id);
116
130
  }
117
131
  /** Send an SSE event to a single client. */
@@ -138,10 +152,11 @@ export class SseManager {
138
152
  try {
139
153
  client.res.write(msg);
140
154
  }
141
- catch {
155
+ catch (err) {
142
156
  // Socket torn down between writableEnded check and write.
143
157
  // Drop the client so we stop writing to it on every broadcast.
144
- this.remove(client.id);
158
+ slog.warn("write failed", { clientId: client.id, err: String(err) });
159
+ this.remove(client.id, "write-failed");
145
160
  }
146
161
  }
147
162
  /**
@@ -159,6 +174,15 @@ export class SseManager {
159
174
  this.sendEvent(client, event);
160
175
  }
161
176
  }
177
+ /** Broadcast global application state regardless of a client's session filter. */
178
+ broadcastGlobal(event) {
179
+ const snapshot = [...this.clients.values()];
180
+ for (const client of snapshot) {
181
+ if (client.res.writableEnded)
182
+ continue;
183
+ this.sendEvent(client, event);
184
+ }
185
+ }
162
186
  /** Get count of connected clients. */
163
187
  get size() {
164
188
  return this.clients.size;
package/lib/store.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ export class MessageNotFoundError extends Error {
5
+ constructor(messageId) {
6
+ super(`Message not found: ${messageId}`);
7
+ this.name = "MessageNotFoundError";
8
+ }
9
+ }
4
10
  export class Store {
5
11
  db;
6
12
  constructor(dataDir) {
@@ -66,9 +72,9 @@ export class Store {
66
72
  }
67
73
  // messages — pending unbound notifications. POST /api/v1/messages with
68
74
  // `to = "user"` lands here; consumeMessageTx transactionally moves the
69
- // content into a new session's events and deletes the row. Bound
70
- // messages (to = session id) skip this table entirely and go straight
71
- // to `events`.
75
+ // content into an existing ACP-backed session's events and deletes the
76
+ // row. Bound messages (to = session id) skip this table entirely and go
77
+ // straight to `events`.
72
78
  this.db.exec(`
73
79
  CREATE TABLE IF NOT EXISTS messages (
74
80
  id TEXT PRIMARY KEY,
@@ -506,6 +512,12 @@ export class Store {
506
512
  .prepare("SELECT * FROM messages ORDER BY created_at DESC")
507
513
  .all();
508
514
  }
515
+ countUnprocessed() {
516
+ const row = this.db
517
+ .prepare("SELECT COUNT(*) AS count FROM messages")
518
+ .get();
519
+ return row.count;
520
+ }
509
521
  deleteMessage(id) {
510
522
  const info = this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
511
523
  return info.changes;
@@ -529,29 +541,21 @@ export class Store {
529
541
  .get(to_ref, dedup_key);
530
542
  }
531
543
  /**
532
- * Atomic consume: create a session, append a `message` event whose data
533
- * includes `message_id`, and delete the messages row -- all in a single
534
- * transaction. If the row is already gone, returns the prior session id
535
- * by looking up the historic `message` event; callers can treat this as
536
- * idempotent.
544
+ * Atomically move a pending message into an existing session. Session
545
+ * lifecycle belongs to SessionManager because ACP creation is asynchronous
546
+ * and cannot participate in this SQLite transaction.
537
547
  */
538
- consumeMessageTx(messageId, opts) {
539
- // Fast idempotency pre-check outside the tx to avoid the cost of
540
- // opening one for an already-resolved message.
541
- const existing = this.findMessageEventSession(messageId);
548
+ consumeMessageTx(messageId, sessionId) {
549
+ const existing = this.findConsumedMessageSession(messageId);
542
550
  if (existing) {
543
551
  return { sessionId: existing, alreadyConsumed: true };
544
552
  }
545
553
  const row = this.getMessage(messageId);
546
554
  if (!row) {
547
- throw new Error(`consumeMessageTx: message not found (id=${messageId})`);
555
+ throw new MessageNotFoundError(messageId);
548
556
  }
549
557
  const tx = this.db.transaction(() => {
550
- this.db
551
- .prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
552
- .run(opts.sessionId, opts.cwd ?? row.cwd ?? "", "message");
553
- // Append message event via saveEvent so seq logic applies.
554
- this.saveEvent(opts.sessionId, "message", {
558
+ this.saveEvent(sessionId, "message", {
555
559
  message_id: row.id,
556
560
  from_ref: row.from_ref,
557
561
  from_label: row.from_label,
@@ -563,15 +567,13 @@ export class Store {
563
567
  .prepare("DELETE FROM messages WHERE id = ?")
564
568
  .run(messageId);
565
569
  if (del.changes === 0) {
566
- // Should never happen -- we just fetched the row above. If it does,
567
- // roll back via throw.
568
- throw new Error(`consumeMessageTx: row vanished mid-tx (id=${messageId})`);
570
+ throw new MessageNotFoundError(messageId);
569
571
  }
570
572
  });
571
573
  tx();
572
- return { sessionId: opts.sessionId, alreadyConsumed: false };
574
+ return { sessionId, alreadyConsumed: false };
573
575
  }
574
- findMessageEventSession(messageId) {
576
+ findConsumedMessageSession(messageId) {
575
577
  const row = this.db
576
578
  .prepare(`SELECT session_id FROM events
577
579
  WHERE type = 'message'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lelouchhe/webagent",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A terminal-style web UI for ACP-compatible agents",
5
5
  "type": "module",
6
6
  "license": "MIT",