@lelouchhe/webagent 0.3.0 → 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 +96 -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 +123 -9
  31. package/lib/daemon.js +175 -41
  32. package/lib/event-handler.js +209 -91
  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 +1202 -144
  40. package/lib/server.js +149 -33
  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 +624 -30
  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.2562YGRO.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. package/lib/shared/constants.js +0 -17
package/lib/store.js CHANGED
@@ -8,6 +8,9 @@ export class Store {
8
8
  this.db = new Database(join(dataDir, "webagent.db"));
9
9
  this.db.pragma("journal_mode = WAL");
10
10
  this.migrate();
11
+ // Enforce foreign keys *after* migrate() so the one-time orphan cleanup
12
+ // can run without pragma interfering with legacy cleanup queries.
13
+ this.db.pragma("foreign_keys = ON");
11
14
  }
12
15
  migrate() {
13
16
  this.db.exec(`
@@ -37,7 +40,7 @@ export class Store {
37
40
  `);
38
41
  // Migrate existing tables: add columns if missing
39
42
  const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
40
- const colNames = new Set(cols.map(c => c.name));
43
+ const colNames = new Set(cols.map((c) => c.name));
41
44
  if (!colNames.has("title")) {
42
45
  this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
43
46
  }
@@ -58,8 +61,47 @@ export class Store {
58
61
  if (!colNames.has("source")) {
59
62
  this.db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'auto'");
60
63
  }
64
+ if (!colNames.has("deleted_at")) {
65
+ this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
66
+ }
67
+ // messages — pending unbound notifications. POST /api/v1/messages with
68
+ // `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`.
72
+ this.db.exec(`
73
+ CREATE TABLE IF NOT EXISTS messages (
74
+ id TEXT PRIMARY KEY,
75
+ from_ref TEXT NOT NULL,
76
+ from_label TEXT,
77
+ to_ref TEXT NOT NULL,
78
+ deliver TEXT NOT NULL DEFAULT 'push',
79
+ dedup_key TEXT,
80
+ title TEXT NOT NULL,
81
+ body TEXT NOT NULL,
82
+ cwd TEXT,
83
+ created_at INTEGER NOT NULL
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_messages_created ON messages (created_at);
86
+ CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages (to_ref, dedup_key);
87
+ `);
88
+ // client-server-split M2: idempotency for mutating REST calls. Stores
89
+ // the cached response per (session_id, client_op_id) so retries (after
90
+ // network/SSE reconnect) return the same result instead of re-executing
91
+ // side effects.
92
+ this.db.exec(`
93
+ CREATE TABLE IF NOT EXISTS client_ops (
94
+ session_id TEXT NOT NULL,
95
+ client_op_id TEXT NOT NULL,
96
+ result_json TEXT NOT NULL,
97
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
98
+ PRIMARY KEY (session_id, client_op_id)
99
+ );
100
+ `);
61
101
  // recent_paths: LRU path list for /new menu
62
- const rpExists = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='recent_paths'").get();
102
+ const rpExists = this.db
103
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='recent_paths'")
104
+ .get();
63
105
  if (!rpExists) {
64
106
  this.db.exec(`
65
107
  CREATE TABLE recent_paths (
@@ -74,56 +116,263 @@ export class Store {
74
116
  FROM sessions GROUP BY cwd;
75
117
  `);
76
118
  }
119
+ // events.from_ref — origin marker for every event row.
120
+ // Values: 'user' | 'system' | 'agent' | 'msg:<id>'. The 'msg:<id>'
121
+ // form is reserved for events authored by consuming an inbox message
122
+ // (see C7+). Bucketed backfill runs once for legacy rows.
123
+ const eventCols = this.db
124
+ .prepare("PRAGMA table_info(events)")
125
+ .all();
126
+ const eventColNames = new Set(eventCols.map((c) => c.name));
127
+ if (!eventColNames.has("from_ref")) {
128
+ this.db.exec("ALTER TABLE events ADD COLUMN from_ref TEXT");
129
+ // Buckets:
130
+ // user — user-authored input
131
+ // system — client-originated side-channel actions + host responses
132
+ // (permission responses, local bash, system messages)
133
+ // agent — everything else (assistant_message, thinking, tool_call,
134
+ // tool_call_update, plan, prompt_done, permission_request,
135
+ // etc.)
136
+ this.db.exec(`
137
+ UPDATE events SET from_ref = CASE
138
+ WHEN type = 'user_message' THEN 'user'
139
+ WHEN type IN ('permission_response', 'bash_command', 'bash_result',
140
+ 'system_message') THEN 'system'
141
+ ELSE 'agent'
142
+ END
143
+ WHERE from_ref IS NULL
144
+ `);
145
+ }
146
+ // One-time orphan cleanup: rows whose session_id no longer exists in
147
+ // `sessions`. Pre-FK writes could leave these behind (a session DELETE
148
+ // that didn't cascade because the FK pragma was off). Must run before
149
+ // enabling FK pragma.
150
+ this.db.exec("DELETE FROM events WHERE session_id NOT IN (SELECT id FROM sessions)");
151
+ // Secondary index for events queried by (session_id, type, created_at)
152
+ // -- used by upcoming inbox/message consume queries.
153
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_events_type ON events(session_id, type, created_at)");
154
+ // shares — public read-only share links (share-plan §4.1).
155
+ // State machine: preview (shared_at NULL) → active (shared_at set).
156
+ // Revocation = hard-delete the row (no audit trail kept).
157
+ // Multiple active siblings per session allowed (v4 multi-share).
158
+ // Partial unique index enforces at most one un-activated preview per
159
+ // session at any time.
160
+ this.db.exec(`
161
+ CREATE TABLE IF NOT EXISTS shares (
162
+ token TEXT PRIMARY KEY,
163
+ session_id TEXT NOT NULL REFERENCES sessions(id),
164
+ shared_at INTEGER,
165
+ share_snapshot_seq INTEGER NOT NULL,
166
+ ttl_hours INTEGER,
167
+ display_name TEXT,
168
+ owner_label TEXT,
169
+ created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000),
170
+ last_accessed_at INTEGER
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_shares_session ON shares(session_id, created_at DESC);
173
+ `);
174
+ // Migrate: drop revoked_at column from existing tables (v0.5+).
175
+ // Revocation is now hard-delete; kept rows are always live.
176
+ const shareCols = this.db
177
+ .prepare("PRAGMA table_info(shares)")
178
+ .all();
179
+ const shareColNames = new Set(shareCols.map((c) => c.name));
180
+ if (shareColNames.has("revoked_at")) {
181
+ // Hard-delete any pre-existing revoked rows so the migration
182
+ // doesn't resurrect them as "live" shares after dropping the column.
183
+ this.db.exec("DELETE FROM shares WHERE revoked_at IS NOT NULL");
184
+ // Drop the partial unique index that references revoked_at, then
185
+ // the column, then recreate the index without the revoked_at clause.
186
+ this.db.exec("DROP INDEX IF EXISTS shares_one_active_preview");
187
+ this.db.exec("ALTER TABLE shares DROP COLUMN revoked_at");
188
+ }
189
+ this.db.exec(`
190
+ CREATE UNIQUE INDEX IF NOT EXISTS shares_one_active_preview
191
+ ON shares(session_id)
192
+ WHERE shared_at IS NULL;
193
+ `);
194
+ // attachments — server-managed file uploads bound to a session.
195
+ // Lifecycle = session lifecycle: FK CASCADE removes the row when the
196
+ // session row is deleted (hard-delete path). Tombstoned (soft-deleted)
197
+ // sessions keep the row alive so the share viewer can still resolve
198
+ // file references for active shares.
199
+ //
200
+ // upload_seq = MAX(events.seq) at upload time. The share proxy uses
201
+ // `upload_seq <= shares.share_snapshot_seq` to refuse files uploaded
202
+ // after the share was published, without growing a second seq axis.
203
+ //
204
+ // realpath is stored after fs.realpath so the bridge / permission
205
+ // interceptor can compare paths without re-resolving symlinks on
206
+ // every request.
207
+ this.db.exec(`
208
+ CREATE TABLE IF NOT EXISTS attachments (
209
+ id TEXT PRIMARY KEY,
210
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
211
+ kind TEXT NOT NULL,
212
+ name TEXT NOT NULL,
213
+ mime TEXT NOT NULL,
214
+ size INTEGER NOT NULL,
215
+ realpath TEXT NOT NULL,
216
+ upload_seq INTEGER NOT NULL,
217
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
218
+ );
219
+ CREATE INDEX IF NOT EXISTS idx_attachments_session ON attachments(session_id);
220
+ `);
221
+ // owner_prefs — key-value store for owner-scoped defaults (display_name,
222
+ // last /by selection, etc). Single-user model = single owner scope.
223
+ // Stored as plain key/value so we don't grow a new table per pref.
224
+ this.db.exec(`
225
+ CREATE TABLE IF NOT EXISTS owner_prefs (
226
+ key TEXT PRIMARY KEY,
227
+ value TEXT NOT NULL,
228
+ updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000)
229
+ );
230
+ `);
77
231
  }
78
232
  createSession(id, cwd, source = "auto") {
79
- this.db.prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)").run(id, cwd, source);
80
- return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
233
+ this.db
234
+ .prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
235
+ .run(id, cwd, source);
236
+ return this.db
237
+ .prepare("SELECT * FROM sessions WHERE id = ?")
238
+ .get(id);
81
239
  }
82
240
  listSessions(opts) {
83
241
  if (opts?.source) {
84
- return this.db.prepare("SELECT * FROM sessions WHERE source = ? ORDER BY COALESCE(last_active_at, created_at) DESC").all(opts.source);
242
+ return this.db
243
+ .prepare("SELECT * FROM sessions WHERE source = ? AND deleted_at IS NULL ORDER BY COALESCE(last_active_at, created_at) DESC")
244
+ .all(opts.source);
85
245
  }
86
- return this.db.prepare("SELECT * FROM sessions ORDER BY COALESCE(last_active_at, created_at) DESC").all();
246
+ return this.db
247
+ .prepare("SELECT * FROM sessions WHERE deleted_at IS NULL ORDER BY COALESCE(last_active_at, created_at) DESC")
248
+ .all();
87
249
  }
250
+ /** Returns live sessions only. Soft-deleted (tombstone) rows are hidden. */
88
251
  getSession(id) {
252
+ return this.db
253
+ .prepare("SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL")
254
+ .get(id);
255
+ }
256
+ /**
257
+ * Returns a session row even if soft-deleted. Used by the public share
258
+ * viewer, which must keep working after the owner deletes the source
259
+ * session (events stay alive as long as any active share references them).
260
+ */
261
+ getSessionIncludingDeleted(id) {
89
262
  return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
90
263
  }
264
+ /**
265
+ * Delete a session. If any active (published) shares reference it, the
266
+ * session row + events are kept (soft-delete via deleted_at) so the
267
+ * shared snapshot remains viewable. Otherwise everything is hard-
268
+ * deleted. Preview shares (shared_at IS NULL) are always cleared:
269
+ * unpublished drafts share the session's lifecycle.
270
+ *
271
+ * Returns "hard" if the row + events were physically removed, "soft"
272
+ * if the row was tombstoned because shares still reference it. Callers
273
+ * use this to decide whether to clean up filesystem artefacts (images).
274
+ */
91
275
  deleteSession(id) {
276
+ // Drop preview shares regardless — they are owner-only drafts and
277
+ // share the session's lifecycle by design.
278
+ this.db
279
+ .prepare("DELETE FROM shares WHERE session_id = ? AND shared_at IS NULL")
280
+ .run(id);
281
+ const activeShareCount = this.db
282
+ .prepare("SELECT COUNT(*) AS n FROM shares WHERE session_id = ? AND shared_at IS NOT NULL")
283
+ .get(id).n;
284
+ this.db.prepare("DELETE FROM client_ops WHERE session_id = ?").run(id);
285
+ if (activeShareCount > 0) {
286
+ // Soft-delete: keep events + sessions row so public share viewers
287
+ // can still resolve. revokeShare() / reapTombstoneIfOrphaned()
288
+ // finishes the job once the last share is gone.
289
+ this.db
290
+ .prepare("UPDATE sessions SET deleted_at = ? WHERE id = ?")
291
+ .run(Date.now(), id);
292
+ return "soft";
293
+ }
92
294
  this.db.prepare("DELETE FROM events WHERE session_id = ?").run(id);
93
295
  this.db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
296
+ return "hard";
297
+ }
298
+ /**
299
+ * Hard-delete events + sessions row for a session that has been soft-
300
+ * deleted and whose last share was just revoked. No-op if the session
301
+ * is still live (deleted_at IS NULL) or still has active shares.
302
+ * Returns true if a tombstone was reaped.
303
+ */
304
+ reapTombstoneIfOrphaned(sessionId) {
305
+ const sess = this.db
306
+ .prepare("SELECT id FROM sessions WHERE id = ? AND deleted_at IS NOT NULL")
307
+ .get(sessionId);
308
+ if (!sess)
309
+ return false;
310
+ const remaining = this.db
311
+ .prepare("SELECT COUNT(*) AS n FROM shares WHERE session_id = ?")
312
+ .get(sessionId).n;
313
+ if (remaining > 0)
314
+ return false;
315
+ this.db.prepare("DELETE FROM events WHERE session_id = ?").run(sessionId);
316
+ this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
317
+ return true;
94
318
  }
95
319
  /** Delete sessions that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
96
320
  deleteEmptySessions(minAgeS) {
97
- const empties = this.db.prepare(`
321
+ const empties = this.db
322
+ .prepare(`
98
323
  SELECT s.id FROM sessions s
99
324
  LEFT JOIN events e ON e.session_id = s.id
100
325
  WHERE e.id IS NULL
101
326
  AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
102
- `).all(minAgeS);
327
+ `)
328
+ .all(minAgeS);
103
329
  if (empties.length === 0)
104
330
  return [];
105
331
  const del = this.db.prepare("DELETE FROM sessions WHERE id = ?");
106
332
  for (const r of empties)
107
333
  del.run(r.id);
108
- return empties.map(r => r.id);
334
+ return empties.map((r) => r.id);
109
335
  }
110
336
  updateSessionTitle(id, title) {
111
- this.db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run(title, id);
337
+ this.db
338
+ .prepare("UPDATE sessions SET title = ? WHERE id = ?")
339
+ .run(title, id);
112
340
  }
113
341
  updateSessionLastActive(id) {
114
- this.db.prepare("UPDATE sessions SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?").run(id);
342
+ this.db
343
+ .prepare("UPDATE sessions SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?")
344
+ .run(id);
115
345
  }
116
346
  /** Update a config option value (model, mode, reasoning_effort) for a session. */
117
347
  updateSessionConfig(id, configId, value) {
118
- const column = { model: "model", mode: "mode", reasoning_effort: "reasoning_effort" }[configId];
348
+ const column = {
349
+ model: "model",
350
+ mode: "mode",
351
+ reasoning_effort: "reasoning_effort",
352
+ }[configId];
119
353
  if (!column)
120
354
  return;
121
- this.db.prepare(`UPDATE sessions SET ${column} = ? WHERE id = ?`).run(value, id);
355
+ this.db
356
+ .prepare(`UPDATE sessions SET ${column} = ? WHERE id = ?`)
357
+ .run(value, id);
122
358
  }
123
- saveEvent(sessionId, type, data = {}) {
124
- const seq = this.db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE session_id = ?").get(sessionId).next;
125
- this.db.prepare("INSERT INTO events (session_id, seq, type, data) VALUES (?, ?, ?, ?)").run(sessionId, seq, type, JSON.stringify(data));
126
- return this.db.prepare("SELECT * FROM events WHERE session_id = ? AND seq = ?")
359
+ saveEvent(sessionId, type, data = {}, opts) {
360
+ const seq = this.db
361
+ .prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE session_id = ?")
362
+ .get(sessionId).next;
363
+ // Origin marker is required. Every writer must pass an explicit value;
364
+ // missing/empty fails loudly so a forgotten retrofit can't silently
365
+ // mis-bucket a row in production. Valid values:
366
+ // 'user' | 'system' | 'agent' | 'msg:<id>'.
367
+ const fromRef = opts?.from_ref;
368
+ if (!fromRef) {
369
+ throw new Error(`saveEvent: from_ref is required (type=${type} session=${sessionId.slice(0, 8)}) — pass { from_ref: 'user' | 'system' | 'agent' | 'msg:<id>' }`);
370
+ }
371
+ this.db
372
+ .prepare("INSERT INTO events (session_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
373
+ .run(sessionId, seq, type, JSON.stringify(data), fromRef);
374
+ return this.db
375
+ .prepare("SELECT * FROM events WHERE session_id = ? AND seq = ?")
127
376
  .get(sessionId, seq);
128
377
  }
129
378
  getEvents(sessionId, opts) {
@@ -149,7 +398,9 @@ export class Store {
149
398
  params.push(opts.limit);
150
399
  return this.db.prepare(sql).all(...params);
151
400
  }
152
- return this.db.prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq`).all(...params);
401
+ return this.db
402
+ .prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq`)
403
+ .all(...params);
153
404
  }
154
405
  getEventCount(sessionId, opts) {
155
406
  let query = "SELECT COUNT(*) as count FROM events WHERE session_id = ?";
@@ -159,9 +410,17 @@ export class Store {
159
410
  }
160
411
  return this.db.prepare(query).get(...params).count;
161
412
  }
413
+ /** Highest seq of any stored event for this session (0 when empty). */
414
+ getLastEventSeq(sessionId) {
415
+ const row = this.db
416
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE session_id = ?")
417
+ .get(sessionId);
418
+ return row.seq;
419
+ }
162
420
  /** Check if the most recent agent turn was interrupted (user_message without a following prompt_done). */
163
421
  hasInterruptedTurn(sessionId) {
164
- const row = this.db.prepare(`
422
+ const row = this.db
423
+ .prepare(`
165
424
  SELECT 1 FROM events
166
425
  WHERE session_id = ? AND type = 'user_message'
167
426
  AND seq > COALESCE(
@@ -169,42 +428,377 @@ export class Store {
169
428
  0
170
429
  )
171
430
  LIMIT 1
172
- `).get(sessionId, sessionId);
173
- return !!row;
431
+ `)
432
+ .get(sessionId, sessionId);
433
+ return Boolean(row);
174
434
  }
175
435
  // --- Push subscriptions ---
176
436
  saveSubscription(endpoint, auth, p256dh) {
177
- this.db.prepare(`INSERT INTO push_subscriptions (endpoint, auth, p256dh)
437
+ this.db
438
+ .prepare(`INSERT INTO push_subscriptions (endpoint, auth, p256dh)
178
439
  VALUES (?, ?, ?)
179
- ON CONFLICT(endpoint) DO UPDATE SET auth = excluded.auth, p256dh = excluded.p256dh`).run(endpoint, auth, p256dh);
440
+ ON CONFLICT(endpoint) DO UPDATE SET auth = excluded.auth, p256dh = excluded.p256dh`)
441
+ .run(endpoint, auth, p256dh);
180
442
  }
181
443
  removeSubscription(endpoint) {
182
- this.db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
444
+ this.db
445
+ .prepare("DELETE FROM push_subscriptions WHERE endpoint = ?")
446
+ .run(endpoint);
183
447
  }
184
448
  getAllSubscriptions() {
185
- return this.db.prepare("SELECT * FROM push_subscriptions").all();
449
+ return this.db
450
+ .prepare("SELECT * FROM push_subscriptions")
451
+ .all();
186
452
  }
187
453
  // --- Recent paths ---
188
454
  touchRecentPath(cwd) {
189
- this.db.prepare(`INSERT INTO recent_paths (cwd, last_used_at)
455
+ this.db
456
+ .prepare(`INSERT INTO recent_paths (cwd, last_used_at)
190
457
  VALUES (?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
191
- ON CONFLICT(cwd) DO UPDATE SET last_used_at = strftime('%Y-%m-%d %H:%M:%f', 'now')`).run(cwd);
458
+ ON CONFLICT(cwd) DO UPDATE SET last_used_at = strftime('%Y-%m-%d %H:%M:%f', 'now')`)
459
+ .run(cwd);
192
460
  }
193
461
  listRecentPaths(opts) {
194
462
  const ttl = opts?.ttlDays ?? 0;
195
463
  if (ttl > 0) {
196
- this.db.prepare("DELETE FROM recent_paths WHERE last_used_at < strftime('%Y-%m-%d %H:%M:%f', 'now', ?)").run(`-${ttl} days`);
464
+ this.db
465
+ .prepare("DELETE FROM recent_paths WHERE last_used_at < strftime('%Y-%m-%d %H:%M:%f', 'now', ?)")
466
+ .run(`-${ttl} days`);
197
467
  }
198
468
  const limit = opts?.limit;
199
469
  if (limit && limit > 0) {
200
- return this.db.prepare("SELECT cwd, last_used_at FROM recent_paths ORDER BY last_used_at DESC LIMIT ?").all(limit);
470
+ return this.db
471
+ .prepare("SELECT cwd, last_used_at FROM recent_paths ORDER BY last_used_at DESC LIMIT ?")
472
+ .all(limit);
201
473
  }
202
- return this.db.prepare("SELECT cwd, last_used_at FROM recent_paths ORDER BY last_used_at DESC").all();
474
+ return this.db
475
+ .prepare("SELECT cwd, last_used_at FROM recent_paths ORDER BY last_used_at DESC")
476
+ .all();
203
477
  }
204
478
  deleteRecentPath(cwd) {
205
479
  this.db.prepare("DELETE FROM recent_paths WHERE cwd = ?").run(cwd);
206
480
  }
481
+ // ===== messages (pending unbound notifications) =====
482
+ createMessage(input) {
483
+ this.db
484
+ .prepare(`INSERT INTO messages
485
+ (id, from_ref, from_label, to_ref, deliver, dedup_key, title, body, cwd, created_at)
486
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
487
+ .run(input.id, input.from_ref, input.from_label, input.to_ref, input.deliver, input.dedup_key, input.title, input.body, input.cwd, input.created_at);
488
+ }
489
+ getMessage(id) {
490
+ return this.db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
491
+ }
492
+ listUnprocessed() {
493
+ return this.db
494
+ .prepare("SELECT * FROM messages ORDER BY created_at DESC")
495
+ .all();
496
+ }
497
+ deleteMessage(id) {
498
+ const info = this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
499
+ return info.changes;
500
+ }
501
+ /**
502
+ * Delete unprocessed messages whose created_at is older than the given
503
+ * epoch-ms threshold. Returns the number of rows removed.
504
+ */
505
+ deleteOlderThan(thresholdMs) {
506
+ const info = this.db
507
+ .prepare("DELETE FROM messages WHERE created_at < ?")
508
+ .run(thresholdMs);
509
+ return info.changes;
510
+ }
511
+ /** Find an existing unprocessed message matching (to_ref, dedup_key) for server-side supersede. */
512
+ findBySupersede(to_ref, dedup_key) {
513
+ if (!dedup_key)
514
+ return undefined;
515
+ return this.db
516
+ .prepare("SELECT * FROM messages WHERE to_ref = ? AND dedup_key = ? LIMIT 1")
517
+ .get(to_ref, dedup_key);
518
+ }
519
+ /**
520
+ * Atomic consume: create a session, append a `message` event whose data
521
+ * includes `message_id`, and delete the messages row -- all in a single
522
+ * transaction. If the row is already gone, returns the prior session id
523
+ * by looking up the historic `message` event; callers can treat this as
524
+ * idempotent.
525
+ */
526
+ consumeMessageTx(messageId, opts) {
527
+ // Fast idempotency pre-check outside the tx to avoid the cost of
528
+ // opening one for an already-resolved message.
529
+ const existing = this.findMessageEventSession(messageId);
530
+ if (existing) {
531
+ return { sessionId: existing, alreadyConsumed: true };
532
+ }
533
+ const row = this.getMessage(messageId);
534
+ if (!row) {
535
+ throw new Error(`consumeMessageTx: message not found (id=${messageId})`);
536
+ }
537
+ const tx = this.db.transaction(() => {
538
+ this.db
539
+ .prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
540
+ .run(opts.sessionId, opts.cwd ?? row.cwd ?? "", "message");
541
+ // Append message event via saveEvent so seq logic applies.
542
+ this.saveEvent(opts.sessionId, "message", {
543
+ message_id: row.id,
544
+ from_ref: row.from_ref,
545
+ from_label: row.from_label,
546
+ title: row.title,
547
+ body: row.body,
548
+ cwd: row.cwd,
549
+ }, { from_ref: row.from_ref });
550
+ const del = this.db
551
+ .prepare("DELETE FROM messages WHERE id = ?")
552
+ .run(messageId);
553
+ if (del.changes === 0) {
554
+ // Should never happen -- we just fetched the row above. If it does,
555
+ // roll back via throw.
556
+ throw new Error(`consumeMessageTx: row vanished mid-tx (id=${messageId})`);
557
+ }
558
+ });
559
+ tx();
560
+ return { sessionId: opts.sessionId, alreadyConsumed: false };
561
+ }
562
+ findMessageEventSession(messageId) {
563
+ const row = this.db
564
+ .prepare(`SELECT session_id FROM events
565
+ WHERE type = 'message'
566
+ AND json_extract(data, '$.message_id') = ?
567
+ LIMIT 1`)
568
+ .get(messageId);
569
+ return row?.session_id;
570
+ }
571
+ // --- client-server-split M2: client_ops idempotency ---
572
+ /**
573
+ * Look up a previously-cached response for (sessionId, clientOpId).
574
+ * Returns the parsed result or null if no cached entry exists.
575
+ */
576
+ getClientOp(sessionId, clientOpId) {
577
+ const row = this.db
578
+ .prepare("SELECT result_json FROM client_ops WHERE session_id = ? AND client_op_id = ?")
579
+ .get(sessionId, clientOpId);
580
+ if (!row)
581
+ return null;
582
+ try {
583
+ return JSON.parse(row.result_json);
584
+ }
585
+ catch {
586
+ return null;
587
+ }
588
+ }
589
+ /**
590
+ * Cache a successful response for (sessionId, clientOpId). Uses
591
+ * INSERT OR IGNORE so a concurrent winner is preserved.
592
+ */
593
+ saveClientOp(sessionId, clientOpId, result) {
594
+ this.db
595
+ .prepare("INSERT OR IGNORE INTO client_ops (session_id, client_op_id, result_json) VALUES (?, ?, ?)")
596
+ .run(sessionId, clientOpId, JSON.stringify(result));
597
+ }
598
+ /** Prune client_ops rows older than `maxAgeMs` (milliseconds). Returns rows deleted. */
599
+ pruneClientOps(maxAgeMs) {
600
+ const seconds = Math.floor(maxAgeMs / 1000);
601
+ const info = this.db
602
+ .prepare("DELETE FROM client_ops WHERE strftime('%s','now') - strftime('%s', created_at) >= ?")
603
+ .run(seconds);
604
+ return info.changes;
605
+ }
606
+ // ===== attachments (uploads-plan v2.6 §1.2) =====
607
+ /**
608
+ * Insert a new attachment row. upload_seq is computed as
609
+ * `COALESCE(MAX(events.seq), 0)` for the session at insert time. Callers
610
+ * must have already written the file under
611
+ * <data_dir>/sessions/<sid>/attachments/<id>.<ext> and resolved its
612
+ * realpath. The row is bound by FK CASCADE to its session.
613
+ */
614
+ insertAttachment(input) {
615
+ const seqRow = this.db
616
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE session_id = ?")
617
+ .get(input.sessionId);
618
+ const uploadSeq = seqRow.s;
619
+ this.db
620
+ .prepare(`INSERT INTO attachments
621
+ (id, session_id, kind, name, mime, size, realpath, upload_seq)
622
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
623
+ .run(input.id, input.sessionId, input.kind, input.name, input.mime, input.size, input.realpath, uploadSeq);
624
+ return this.db
625
+ .prepare("SELECT * FROM attachments WHERE id = ?")
626
+ .get(input.id);
627
+ }
628
+ /** Look up an attachment row by (session_id, id). */
629
+ getAttachment(sessionId, id) {
630
+ return this.db
631
+ .prepare("SELECT * FROM attachments WHERE session_id = ? AND id = ?")
632
+ .get(sessionId, id);
633
+ }
634
+ /**
635
+ * For the permission interceptor: list all attachment realpaths for a
636
+ * session so we can compare against `toolCall.locations[].path` after
637
+ * realpath-ing each side. The set is small (≤ a few hundred per
638
+ * session) so we hand back an in-memory array.
639
+ */
640
+ listAttachmentRealpaths(sessionId) {
641
+ const rows = this.db
642
+ .prepare("SELECT realpath FROM attachments WHERE session_id = ?")
643
+ .all(sessionId);
644
+ return rows.map((r) => r.realpath);
645
+ }
646
+ /**
647
+ * For the egress label-rewrite (CLAUDE.md "Attachment label egress
648
+ * rewrite"): list each attachment's id, user-supplied name, and
649
+ * realpath for a session. Caller (session-manager label cache)
650
+ * derives the label string `<name> [#<id4>]`. Pure DB read; no
651
+ * realpath syscalls (the stored realpath is already resolved at
652
+ * upload time).
653
+ */
654
+ listAttachmentLabels(sessionId) {
655
+ const rows = this.db
656
+ .prepare("SELECT id, name, realpath FROM attachments WHERE session_id = ?")
657
+ .all(sessionId);
658
+ return rows;
659
+ }
660
+ /**
661
+ * For the share viewer / GET serve path: look up an attachment by the
662
+ * filename portion of its URL (`<id>.<ext>`). The id is the uuid prefix
663
+ * of the file segment.
664
+ */
665
+ getAttachmentByFile(sessionId, file) {
666
+ const dot = file.indexOf(".");
667
+ const id = dot === -1 ? file : file.slice(0, dot);
668
+ return this.getAttachment(sessionId, id);
669
+ }
207
670
  close() {
208
671
  this.db.close();
209
672
  }
673
+ // ===== shares (share-plan §4.1) =====
674
+ /**
675
+ * Insert a new preview row. Caller must have flushed buffered chunks
676
+ * and computed snapshotSeq in the same synchronous tick (share-plan
677
+ * §4.3 R1-c2). Returns the inserted row.
678
+ *
679
+ * May throw SQLITE_CONSTRAINT_UNIQUE on shares_one_active_preview;
680
+ * callers handle via findActivePreviewBySession fallback (§4.3 R2-c2).
681
+ */
682
+ insertSharePreview(input) {
683
+ this.db
684
+ .prepare(`INSERT INTO shares (token, session_id, share_snapshot_seq, ttl_hours, display_name, owner_label)
685
+ VALUES (?, ?, ?, ?, ?, ?)`)
686
+ .run(input.token, input.sessionId, input.snapshotSeq, input.ttlHours ?? null, input.displayName ?? null, input.ownerLabel ?? null);
687
+ return this.getShareByToken(input.token);
688
+ }
689
+ /** SELECT the single un-activated preview for this session (partial unique). */
690
+ findActivePreviewBySession(sessionId) {
691
+ return this.db
692
+ .prepare(`SELECT * FROM shares
693
+ WHERE session_id = ? AND shared_at IS NULL
694
+ ORDER BY created_at DESC LIMIT 1`)
695
+ .get(sessionId);
696
+ }
697
+ getShareByToken(token) {
698
+ return this.db
699
+ .prepare("SELECT * FROM shares WHERE token = ?")
700
+ .get(token);
701
+ }
702
+ /**
703
+ * Activate preview: shared_at NULL → now(). Returns true if row moved
704
+ * (0 → 1 rows affected). False if preview already activated, revoked,
705
+ * or token doesn't exist.
706
+ */
707
+ activateShare(token, opts) {
708
+ const now = Date.now();
709
+ let sql = "UPDATE shares SET shared_at = ?";
710
+ const params = [now];
711
+ if (opts && "displayName" in opts) {
712
+ sql += ", display_name = ?";
713
+ params.push(opts.displayName ?? null);
714
+ }
715
+ if (opts && "ownerLabel" in opts) {
716
+ sql += ", owner_label = ?";
717
+ params.push(opts.ownerLabel ?? null);
718
+ }
719
+ sql += " WHERE token = ? AND shared_at IS NULL";
720
+ params.push(token);
721
+ const info = this.db.prepare(sql).run(...params);
722
+ return info.changes > 0;
723
+ }
724
+ /** Hard-delete a share row. Returns true if the row existed. */
725
+ revokeShare(token) {
726
+ const info = this.db
727
+ .prepare("DELETE FROM shares WHERE token = ?")
728
+ .run(token);
729
+ return info.changes > 0;
730
+ }
731
+ /** Update only owner_label (PATCH route). Caller validates the value first. */
732
+ updateShareOwnerLabel(token, label) {
733
+ const info = this.db
734
+ .prepare("UPDATE shares SET owner_label = ? WHERE token = ?")
735
+ .run(label, token);
736
+ return info.changes > 0;
737
+ }
738
+ /** Update only display_name (PATCH route). Caller validates the value first. */
739
+ updateShareDisplayName(token, name) {
740
+ const info = this.db
741
+ .prepare("UPDATE shares SET display_name = ? WHERE token = ?")
742
+ .run(name, token);
743
+ return info.changes > 0;
744
+ }
745
+ /** Owner list — every share row (preview + active). */
746
+ listOwnerShares() {
747
+ return this.db
748
+ .prepare(`SELECT
749
+ s.token AS token,
750
+ s.session_id AS session_id,
751
+ sess.title AS session_title,
752
+ s.shared_at AS shared_at,
753
+ s.created_at AS created_at,
754
+ s.display_name AS display_name,
755
+ s.owner_label AS owner_label,
756
+ s.share_snapshot_seq AS share_snapshot_seq,
757
+ s.ttl_hours AS ttl_hours,
758
+ s.last_accessed_at AS last_accessed_at
759
+ FROM shares s
760
+ LEFT JOIN sessions sess ON sess.id = s.session_id
761
+ ORDER BY s.created_at DESC`)
762
+ .all();
763
+ }
764
+ /**
765
+ * One-time write of last_accessed_at (share-plan §4.1 R2 ENG-6a +
766
+ * OPS-R2-1): only fire when currently NULL to avoid write amplification.
767
+ * Returns true if the field was set by this call.
768
+ */
769
+ touchShareAccessed(token) {
770
+ const info = this.db
771
+ .prepare("UPDATE shares SET last_accessed_at = ? WHERE token = ? AND last_accessed_at IS NULL")
772
+ .run(Date.now(), token);
773
+ return info.changes > 0;
774
+ }
775
+ /**
776
+ * Lazy prune of preview rows older than 24h (share-plan §4.1).
777
+ * `now` is injectable for tests. Activated rows (shared_at set) are
778
+ * NEVER touched — only orphaned previews are GC'd.
779
+ */
780
+ pruneStalePreviews(now = Date.now()) {
781
+ const cutoff = now - 24 * 60 * 60 * 1000;
782
+ const info = this.db
783
+ .prepare("DELETE FROM shares WHERE shared_at IS NULL AND created_at < ?")
784
+ .run(cutoff);
785
+ return info.changes;
786
+ }
787
+ // ===== owner_prefs =====
788
+ getOwnerPref(key) {
789
+ const row = this.db
790
+ .prepare("SELECT value FROM owner_prefs WHERE key = ?")
791
+ .get(key);
792
+ return row?.value;
793
+ }
794
+ setOwnerPref(key, value) {
795
+ this.db
796
+ .prepare(`INSERT INTO owner_prefs (key, value, updated_at)
797
+ VALUES (?, ?, ?)
798
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
799
+ .run(key, value, Date.now());
800
+ }
801
+ clearOwnerPref(key) {
802
+ this.db.prepare("DELETE FROM owner_prefs WHERE key = ?").run(key);
803
+ }
210
804
  }