@lelouchhe/webagent 0.7.0 → 0.9.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.
@@ -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,8 @@ function defaultState() {
14
16
  busy: null,
15
17
  pendingPermissions: [],
16
18
  streaming: { assistant: false, thinking: false },
19
+ plan: null,
20
+ contextUsage: null,
17
21
  },
18
22
  };
19
23
  }
@@ -22,7 +26,10 @@ function busyEqual(a, b) {
22
26
  return true;
23
27
  if (a === null || b === null)
24
28
  return false;
25
- return a.kind === b.kind && a.since === b.since && a.promptId === b.promptId;
29
+ return (a.kind === b.kind &&
30
+ a.since === b.since &&
31
+ a.promptId === b.promptId &&
32
+ (a.cancelStatus ?? null) === (b.cancelStatus ?? null));
26
33
  }
27
34
  function permsEqual(a, b) {
28
35
  if (a.length !== b.length)
@@ -43,6 +50,21 @@ function permsEqual(a, b) {
43
50
  }
44
51
  return true;
45
52
  }
53
+ function plansEqual(a, b) {
54
+ if (a === null || b === null)
55
+ return a === b;
56
+ if (a.length !== b.length)
57
+ return false;
58
+ return a.every((entry, index) => entry.status === b[index].status && entry.content === b[index].content);
59
+ }
60
+ function contextUsageEqual(a, b) {
61
+ if (a === null || b === null)
62
+ return a === b;
63
+ return (a.used === b.used &&
64
+ a.size === b.size &&
65
+ (a.cost?.amount ?? null) === (b.cost?.amount ?? null) &&
66
+ (a.cost?.currency ?? null) === (b.cost?.currency ?? null));
67
+ }
46
68
  /** True when the patch would change the current runtime state. */
47
69
  function hasRuntimeChanges(current, patch) {
48
70
  if (!patch)
@@ -53,6 +75,11 @@ function hasRuntimeChanges(current, patch) {
53
75
  patch.pendingPermissions &&
54
76
  !permsEqual(current.pendingPermissions, patch.pendingPermissions))
55
77
  return true;
78
+ if ("plan" in patch && !plansEqual(current.plan, patch.plan ?? null))
79
+ return true;
80
+ if ("contextUsage" in patch &&
81
+ !contextUsageEqual(current.contextUsage, patch.contextUsage ?? null))
82
+ return true;
56
83
  if ("streaming" in patch && patch.streaming) {
57
84
  const s = patch.streaming;
58
85
  if (s.assistant !== undefined &&
@@ -76,6 +103,11 @@ export class SessionStateManager {
76
103
  }
77
104
  return s;
78
105
  }
106
+ /** Read streaming state without creating runtime state for an unseen session. */
107
+ peekStreaming(sessionId) {
108
+ const streaming = this.states.get(sessionId)?.runtime.streaming;
109
+ return streaming ? { ...streaming } : { assistant: false, thinking: false };
110
+ }
79
111
  /**
80
112
  * Merge a patch into the session's runtime state. Bumps seq and notifies
81
113
  * listeners only when the patch actually changes something (no-op patches
@@ -95,6 +127,19 @@ export class SessionStateManager {
95
127
  state.runtime.pendingPermissions =
96
128
  patch.runtime.pendingPermissions.slice();
97
129
  }
130
+ if ("plan" in patch.runtime) {
131
+ state.runtime.plan =
132
+ patch.runtime.plan?.map((entry) => ({ ...entry })) ?? null;
133
+ }
134
+ if ("contextUsage" in patch.runtime) {
135
+ const usage = patch.runtime.contextUsage;
136
+ state.runtime.contextUsage = usage
137
+ ? {
138
+ ...usage,
139
+ ...(usage.cost ? { cost: { ...usage.cost } } : {}),
140
+ }
141
+ : null;
142
+ }
98
143
  if ("streaming" in patch.runtime && patch.runtime.streaming) {
99
144
  if (patch.runtime.streaming.assistant !== undefined) {
100
145
  state.runtime.streaming.assistant = patch.runtime.streaming.assistant;
@@ -130,9 +175,38 @@ export class SessionStateManager {
130
175
  this.cancelTimers.delete(sessionId);
131
176
  }
132
177
  }
178
+ /** Clear current plans for every known session (used on bridge reload). */
179
+ clearPlans() {
180
+ for (const [sessionId, state] of this.states) {
181
+ if (state.runtime.plan !== null) {
182
+ this.patch(sessionId, { runtime: { plan: null } });
183
+ }
184
+ }
185
+ }
186
+ /** Clear context usage for every known session on bridge teardown. */
187
+ clearContextUsage() {
188
+ for (const [sessionId, state] of this.states) {
189
+ if (state.runtime.contextUsage !== null) {
190
+ this.patch(sessionId, { runtime: { contextUsage: null } });
191
+ }
192
+ }
193
+ }
194
+ /** Clear active stream markers for every known session on bridge teardown. */
195
+ clearStreaming() {
196
+ for (const [sessionId, state] of this.states) {
197
+ if (state.runtime.streaming.assistant ||
198
+ state.runtime.streaming.thinking) {
199
+ this.patch(sessionId, {
200
+ runtime: {
201
+ streaming: { assistant: false, thinking: false },
202
+ },
203
+ });
204
+ }
205
+ }
206
+ }
133
207
  /**
134
- * Backend safety net for cancel: if busy is still set after `timeoutMs`,
135
- * force-clear it. Replaces the old frontend cancel timer.
208
+ * Backend acknowledgement timer for cancel: if the same agent prompt is
209
+ * still pending after `timeoutMs`, mark the request unconfirmed.
136
210
  * A second arm on the same session replaces the existing timer.
137
211
  */
138
212
  armCancelSafety(sessionId, timeoutMs) {
@@ -143,12 +217,32 @@ export class SessionStateManager {
143
217
  clearTimeout(existing);
144
218
  const t = setTimeout(() => {
145
219
  this.cancelTimers.delete(sessionId);
146
- this.patch(sessionId, { runtime: { busy: null } });
220
+ const busy = this.getState(sessionId).runtime.busy;
221
+ if (busy?.kind === "agent" && busy.cancelStatus === "requested") {
222
+ clog.warn("agent did not acknowledge", {
223
+ sessionId: sessionId.slice(0, 8),
224
+ promptId: busy.promptId,
225
+ });
226
+ this.patch(sessionId, {
227
+ runtime: {
228
+ busy: { ...busy, cancelStatus: "unconfirmed" },
229
+ },
230
+ });
231
+ }
147
232
  }, timeoutMs);
148
233
  if (typeof t === "object" && "unref" in t)
149
234
  t.unref();
150
235
  this.cancelTimers.set(sessionId, t);
151
236
  }
237
+ /** Mark that a cancel notification was sent for the active agent prompt. */
238
+ markCancelRequested(sessionId) {
239
+ const busy = this.getState(sessionId).runtime.busy;
240
+ if (busy?.kind !== "agent")
241
+ return;
242
+ this.patch(sessionId, {
243
+ runtime: { busy: { ...busy, cancelStatus: "requested" } },
244
+ });
245
+ }
152
246
  /** Cancel the safety net timer (e.g. when prompt_done arrives naturally). */
153
247
  clearCancelSafety(sessionId) {
154
248
  const t = this.cancelTimers.get(sessionId);
@@ -444,6 +444,10 @@ async function handlePublish(req, res, deps, sessionId) {
444
444
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
445
445
  return;
446
446
  }
447
+ if (!deps.store.ownsSession(sessionId)) {
448
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
449
+ return;
450
+ }
447
451
  const row = deps.store.getShareByToken(body.token);
448
452
  if (!row) {
449
453
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
@@ -849,6 +853,10 @@ async function handleRevoke(req, res, deps, sessionId) {
849
853
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
850
854
  return;
851
855
  }
856
+ if (!deps.store.ownsSession(sessionId)) {
857
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
858
+ return;
859
+ }
852
860
  const row = deps.store.getShareByToken(body.token);
853
861
  if (!row) {
854
862
  // Idempotent DELETE: row already gone (revoked or never existed).
@@ -910,6 +918,10 @@ async function handlePatchLabel(req, res, deps, sessionId) {
910
918
  json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
911
919
  return;
912
920
  }
921
+ if (!deps.store.ownsSession(sessionId)) {
922
+ json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
923
+ return;
924
+ }
913
925
  const row = deps.store.getShareByToken(body.token);
914
926
  if (!row) {
915
927
  json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
@@ -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,9 +1,19 @@
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
- constructor(dataDir) {
12
+ agentKey;
13
+ constructor(dataDir, agentKey) {
14
+ if (!agentKey)
15
+ throw new Error("agentKey is required");
16
+ this.agentKey = agentKey;
7
17
  mkdirSync(dataDir, { recursive: true });
8
18
  this.db = new Database(join(dataDir, "webagent.db"));
9
19
  this.db.pragma("journal_mode = WAL");
@@ -21,6 +31,16 @@ export class Store {
21
31
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
22
32
  last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
23
33
  );
34
+ CREATE TABLE IF NOT EXISTS agent_sessions (
35
+ agent_key TEXT NOT NULL,
36
+ agent_session_id TEXT NOT NULL,
37
+ web_session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
38
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
39
+ PRIMARY KEY (agent_key, agent_session_id)
40
+ );
41
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sessions_web
42
+ ON agent_sessions(web_session_id)
43
+ WHERE web_session_id IS NOT NULL;
24
44
  CREATE TABLE IF NOT EXISTS events (
25
45
  id INTEGER PRIMARY KEY AUTOINCREMENT,
26
46
  session_id TEXT NOT NULL REFERENCES sessions(id),
@@ -64,11 +84,26 @@ export class Store {
64
84
  if (!colNames.has("deleted_at")) {
65
85
  this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
66
86
  }
87
+ // One-time dual-ID migration. Existing WebAgent session IDs were also the
88
+ // ACP agent's IDs, so preserve the public IDs and record that identity
89
+ // mapping under the agent command active during the upgrade.
90
+ this.db
91
+ .prepare(`
92
+ INSERT INTO agent_sessions (
93
+ agent_key, agent_session_id, web_session_id, created_at
94
+ )
95
+ SELECT ?, s.id, s.id, s.created_at
96
+ FROM sessions s
97
+ WHERE NOT EXISTS (
98
+ SELECT 1 FROM agent_sessions a WHERE a.web_session_id = s.id
99
+ )
100
+ `)
101
+ .run(this.agentKey);
67
102
  // messages — pending unbound notifications. POST /api/v1/messages with
68
103
  // `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`.
104
+ // content into an existing ACP-backed session's events and deletes the
105
+ // row. Bound messages (to = session id) skip this table entirely and go
106
+ // straight to `events`.
72
107
  this.db.exec(`
73
108
  CREATE TABLE IF NOT EXISTS messages (
74
109
  id TEXT PRIMARY KEY,
@@ -241,29 +276,72 @@ export class Store {
241
276
  );
242
277
  `);
243
278
  }
244
- createSession(id, cwd, source = "auto") {
245
- this.db
246
- .prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
247
- .run(id, cwd, source);
248
- return this.db
249
- .prepare("SELECT * FROM sessions WHERE id = ?")
250
- .get(id);
279
+ createSession(id, cwd, source = "auto", agentSessionId = id) {
280
+ return this.db.transaction(() => {
281
+ this.db
282
+ .prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
283
+ .run(id, cwd, source);
284
+ this.db
285
+ .prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, web_session_id) VALUES (?, ?, ?)")
286
+ .run(this.agentKey, agentSessionId, id);
287
+ return this.db
288
+ .prepare("SELECT * FROM sessions WHERE id = ?")
289
+ .get(id);
290
+ })();
251
291
  }
252
292
  listSessions(opts) {
253
293
  if (opts?.source) {
254
294
  return this.db
255
- .prepare("SELECT * FROM sessions WHERE source = ? AND deleted_at IS NULL ORDER BY COALESCE(last_active_at, created_at) DESC")
256
- .all(opts.source);
295
+ .prepare(`SELECT s.* FROM sessions s
296
+ JOIN agent_sessions a ON a.web_session_id = s.id
297
+ WHERE a.agent_key = ? AND s.source = ? AND s.deleted_at IS NULL
298
+ ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
299
+ .all(this.agentKey, opts.source);
257
300
  }
258
301
  return this.db
259
- .prepare("SELECT * FROM sessions WHERE deleted_at IS NULL ORDER BY COALESCE(last_active_at, created_at) DESC")
260
- .all();
302
+ .prepare(`SELECT s.* FROM sessions s
303
+ JOIN agent_sessions a ON a.web_session_id = s.id
304
+ WHERE a.agent_key = ? AND s.deleted_at IS NULL
305
+ ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
306
+ .all(this.agentKey);
261
307
  }
262
308
  /** Returns live sessions only. Soft-deleted (tombstone) rows are hidden. */
263
309
  getSession(id) {
264
310
  return this.db
265
- .prepare("SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL")
266
- .get(id);
311
+ .prepare(`SELECT s.* FROM sessions s
312
+ JOIN agent_sessions a ON a.web_session_id = s.id
313
+ WHERE s.id = ? AND a.agent_key = ? AND s.deleted_at IS NULL`)
314
+ .get(id, this.agentKey);
315
+ }
316
+ registerInternalAgentSession(agentSessionId) {
317
+ this.db
318
+ .prepare("INSERT OR IGNORE INTO agent_sessions (agent_key, agent_session_id, web_session_id) VALUES (?, ?, NULL)")
319
+ .run(this.agentKey, agentSessionId);
320
+ const row = this.db
321
+ .prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ?")
322
+ .get(this.agentKey, agentSessionId);
323
+ if (row.web_session_id) {
324
+ throw new Error("Agent reused a user-visible session ID internally");
325
+ }
326
+ return row;
327
+ }
328
+ getAgentSessionId(webSessionId) {
329
+ return this.db
330
+ .prepare("SELECT agent_session_id FROM agent_sessions WHERE agent_key = ? AND web_session_id = ?")
331
+ .get(this.agentKey, webSessionId)?.agent_session_id;
332
+ }
333
+ getWebSessionId(agentSessionId) {
334
+ return this.db
335
+ .prepare("SELECT web_session_id FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ? AND web_session_id IS NOT NULL")
336
+ .get(this.agentKey, agentSessionId)?.web_session_id;
337
+ }
338
+ getAgentSessionBinding(webSessionId) {
339
+ return this.db
340
+ .prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND web_session_id = ?")
341
+ .get(this.agentKey, webSessionId);
342
+ }
343
+ ownsSession(webSessionId) {
344
+ return this.getAgentSessionBinding(webSessionId) !== undefined;
267
345
  }
268
346
  /**
269
347
  * Returns a session row even if soft-deleted. Used by the public share
@@ -333,11 +411,14 @@ export class Store {
333
411
  const empties = this.db
334
412
  .prepare(`
335
413
  SELECT s.id FROM sessions s
414
+ JOIN agent_sessions a ON a.web_session_id = s.id
336
415
  LEFT JOIN events e ON e.session_id = s.id
337
416
  WHERE e.id IS NULL
417
+ AND a.agent_key = ?
418
+ AND s.deleted_at IS NULL
338
419
  AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
339
420
  `)
340
- .all(minAgeS);
421
+ .all(this.agentKey, minAgeS);
341
422
  if (empties.length === 0)
342
423
  return [];
343
424
  const del = this.db.prepare("DELETE FROM sessions WHERE id = ?");
@@ -361,6 +442,7 @@ export class Store {
361
442
  model: "model",
362
443
  mode: "mode",
363
444
  reasoning_effort: "reasoning_effort",
445
+ thought_level: "reasoning_effort",
364
446
  }[configId];
365
447
  if (!column)
366
448
  return;
@@ -429,14 +511,17 @@ export class Store {
429
511
  .get(sessionId);
430
512
  return row.seq;
431
513
  }
432
- /** Check if the most recent agent turn was interrupted (user_message without a following prompt_done). */
514
+ /** Check if the most recent agent turn lacks a completion or error terminal event. */
433
515
  hasInterruptedTurn(sessionId) {
434
516
  const row = this.db
435
517
  .prepare(`
436
518
  SELECT 1 FROM events
437
519
  WHERE session_id = ? AND type = 'user_message'
438
520
  AND seq > COALESCE(
439
- (SELECT MAX(seq) FROM events WHERE session_id = ? AND type = 'prompt_done'),
521
+ (
522
+ SELECT MAX(seq) FROM events
523
+ WHERE session_id = ? AND type IN ('prompt_done', 'error')
524
+ ),
440
525
  0
441
526
  )
442
527
  LIMIT 1
@@ -506,6 +591,12 @@ export class Store {
506
591
  .prepare("SELECT * FROM messages ORDER BY created_at DESC")
507
592
  .all();
508
593
  }
594
+ countUnprocessed() {
595
+ const row = this.db
596
+ .prepare("SELECT COUNT(*) AS count FROM messages")
597
+ .get();
598
+ return row.count;
599
+ }
509
600
  deleteMessage(id) {
510
601
  const info = this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
511
602
  return info.changes;
@@ -529,29 +620,21 @@ export class Store {
529
620
  .get(to_ref, dedup_key);
530
621
  }
531
622
  /**
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.
623
+ * Atomically move a pending message into an existing session. Session
624
+ * lifecycle belongs to SessionManager because ACP creation is asynchronous
625
+ * and cannot participate in this SQLite transaction.
537
626
  */
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);
627
+ consumeMessageTx(messageId, sessionId) {
628
+ const existing = this.findConsumedMessageSession(messageId);
542
629
  if (existing) {
543
630
  return { sessionId: existing, alreadyConsumed: true };
544
631
  }
545
632
  const row = this.getMessage(messageId);
546
633
  if (!row) {
547
- throw new Error(`consumeMessageTx: message not found (id=${messageId})`);
634
+ throw new MessageNotFoundError(messageId);
548
635
  }
549
636
  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", {
637
+ this.saveEvent(sessionId, "message", {
555
638
  message_id: row.id,
556
639
  from_ref: row.from_ref,
557
640
  from_label: row.from_label,
@@ -563,15 +646,13 @@ export class Store {
563
646
  .prepare("DELETE FROM messages WHERE id = ?")
564
647
  .run(messageId);
565
648
  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})`);
649
+ throw new MessageNotFoundError(messageId);
569
650
  }
570
651
  });
571
652
  tx();
572
- return { sessionId: opts.sessionId, alreadyConsumed: false };
653
+ return { sessionId, alreadyConsumed: false };
573
654
  }
574
- findMessageEventSession(messageId) {
655
+ findConsumedMessageSession(messageId) {
575
656
  const row = this.db
576
657
  .prepare(`SELECT session_id FROM events
577
658
  WHERE type = 'message'
@@ -769,9 +850,11 @@ export class Store {
769
850
  s.ttl_hours AS ttl_hours,
770
851
  s.last_accessed_at AS last_accessed_at
771
852
  FROM shares s
853
+ JOIN agent_sessions a ON a.web_session_id = s.session_id
772
854
  LEFT JOIN sessions sess ON sess.id = s.session_id
855
+ WHERE a.agent_key = ?
773
856
  ORDER BY s.created_at DESC`)
774
- .all();
857
+ .all(this.agentKey);
775
858
  }
776
859
  /**
777
860
  * One-time write of last_accessed_at (share-plan §4.1 R2 ENG-6a +
@@ -64,7 +64,7 @@ export class TitleService {
64
64
  this.cancelledSourceSessions.add(sessionId);
65
65
  if (!this.titleSessionId || !this.activeSourceSessions.has(sessionId))
66
66
  return;
67
- await bridge.cancel(this.titleSessionId);
67
+ await bridge.cancelAgentSession(this.titleSessionId);
68
68
  }
69
69
  /** Clear the cached title session ID (e.g. after agent reload). */
70
70
  invalidate() {
@@ -76,14 +76,14 @@ export class TitleService {
76
76
  return this.titleSessionId;
77
77
  try {
78
78
  const { sessionId: id, configOptions } = await bridge.newSession(this.defaultCwd, { silent: true });
79
- this.sessions.liveSessions.add(id);
79
+ this.store.registerInternalAgentSession(id);
80
80
  // Pick the cheapest available model by matching id substrings against
81
81
  // the agent's reported availableModels (`configOptions[id=model].options`).
82
82
  // Empty pattern list, no model option, or no match → skip the call and
83
83
  // inherit the agent's default model (`currentModelId`).
84
84
  const picked = pickModelByPatterns(configOptions, this.modelPatterns);
85
85
  if (picked) {
86
- await bridge.setConfigOption(id, "model", picked).catch(() => []);
86
+ await bridge.setAgentConfigOption(id, "model", picked).catch(() => []);
87
87
  }
88
88
  this.titleSessionId = id;
89
89
  return id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lelouchhe/webagent",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A terminal-style web UI for ACP-compatible agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,6 +64,7 @@
64
64
  "@types/web-push": "^3.6.4",
65
65
  "better-sqlite3": "^12.6.2",
66
66
  "busboy": "^1.6.0",
67
+ "diff": "^9.0.0",
67
68
  "dompurify": "^3.4.1",
68
69
  "file-type": "^22.0.1",
69
70
  "highlight.js": "^11.11.1",