@lelouchhe/webagent 0.8.0 → 0.10.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 (56) hide show
  1. package/README.md +43 -15
  2. package/config.toml +7 -27
  3. package/dist/index.html +21 -5
  4. package/dist/js/app.INIQQEGD.js +5 -0
  5. package/dist/js/chunk.3CLGCUHW.js +1 -0
  6. package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
  7. package/dist/js/chunk.AOTG3PL7.js +20 -0
  8. package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
  9. package/dist/js/viewer.RHZMFYWJ.js +1 -0
  10. package/dist/login.html +2 -2
  11. package/dist/share-viewer.html +6 -6
  12. package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
  13. package/dist/sw.js +6 -6
  14. package/lib/agent-key.js +6 -0
  15. package/lib/attachment-dispatch.js +60 -31
  16. package/lib/attachment-interceptor.js +7 -7
  17. package/lib/attachment-labels.js +1 -1
  18. package/lib/attachments.js +69 -7
  19. package/lib/auth-middleware.js +11 -4
  20. package/lib/auth.js +2 -2
  21. package/lib/bridge.js +209 -90
  22. package/lib/client-registry.js +12 -12
  23. package/lib/config.js +2 -31
  24. package/lib/event-handler.js +166 -85
  25. package/lib/files/limits.js +15 -0
  26. package/lib/files/paths.js +155 -0
  27. package/lib/files/routes.js +232 -0
  28. package/lib/home-path.js +35 -0
  29. package/lib/http-status.js +1 -0
  30. package/lib/mcp/capability.js +74 -0
  31. package/lib/mcp/server.js +148 -0
  32. package/lib/mcp/task-history.js +245 -0
  33. package/lib/mcp/task-host.js +253 -0
  34. package/lib/mcp/tools.js +168 -0
  35. package/lib/mode-bucket.js +1 -1
  36. package/lib/push-service.js +33 -35
  37. package/lib/routes.js +1022 -475
  38. package/lib/server.js +84 -34
  39. package/lib/share/routes.js +97 -85
  40. package/lib/shared/task-reference.js +20 -0
  41. package/lib/sse-manager.js +8 -8
  42. package/lib/store.js +992 -284
  43. package/lib/task-collaboration.js +15 -0
  44. package/lib/task-manager.js +1409 -0
  45. package/lib/task-path.js +131 -0
  46. package/lib/{session-state.js → task-state.js} +90 -38
  47. package/lib/task-tree-lock.js +74 -0
  48. package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
  49. package/lib/tokens.js +1 -1
  50. package/lib/types.js +2 -2
  51. package/package.json +8 -1
  52. package/dist/js/app.XBFXH37R.js +0 -2
  53. package/dist/js/chunk.UMQMOGWO.js +0 -1
  54. package/dist/js/viewer.CVWXSKJM.js +0 -1
  55. package/lib/session-manager.js +0 -613
  56. package/lib/title-service.js +0 -95
package/lib/store.js CHANGED
@@ -1,41 +1,231 @@
1
1
  import Database from "better-sqlite3";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { mkdirSync } from "node:fs";
3
4
  import { join } from "node:path";
5
+ import { formatTaskPath, formatTaskReference, } from "./shared/task-reference.js";
6
+ export const ROOT_TASK_ID = "root";
4
7
  export class MessageNotFoundError extends Error {
5
8
  constructor(messageId) {
6
9
  super(`Message not found: ${messageId}`);
7
10
  this.name = "MessageNotFoundError";
8
11
  }
9
12
  }
13
+ function stringField(data, key) {
14
+ return typeof data[key] === "string" ? data[key] : undefined;
15
+ }
16
+ /** Normalize pre-title system events before any replay or egress path sees them. */
17
+ function migrateSystemMessageData(raw) {
18
+ const data = JSON.parse(raw);
19
+ const existingTitle = stringField(data, "title");
20
+ const body = stringField(data, "body");
21
+ // Older task-created rows used `title` for the child task title, while the
22
+ // new payload uses it for the visible system-message title. Their `body`
23
+ // already contains the old visible text, so preserve it as title-only.
24
+ if (data.kind === "task_created" &&
25
+ data.taskTitle === undefined &&
26
+ existingTitle !== undefined &&
27
+ body !== undefined) {
28
+ const migrated = { ...data, title: body };
29
+ delete migrated.body;
30
+ return JSON.stringify(migrated);
31
+ }
32
+ if (existingTitle?.trim())
33
+ return null;
34
+ const messageBody = stringField(data, "messageBody");
35
+ const migrated = { ...data };
36
+ if (data.kind === "collaboration" && messageBody !== undefined) {
37
+ const source = stringField(data, "sourceLabel") ??
38
+ stringField(data, "sourceTaskId") ??
39
+ "?";
40
+ const target = stringField(data, "targetLabel") ??
41
+ stringField(data, "targetTaskId") ??
42
+ "?";
43
+ migrated.title = `${formatTaskReference(source)} sent ${formatTaskReference(target)}`;
44
+ migrated.body = messageBody;
45
+ }
46
+ else {
47
+ migrated.title = body?.trim() ? body : "System message";
48
+ delete migrated.body;
49
+ }
50
+ delete migrated.messageBody;
51
+ return JSON.stringify(migrated);
52
+ }
10
53
  export class Store {
11
54
  db;
12
- constructor(dataDir) {
55
+ dataDir;
56
+ agentKey;
57
+ constructor(dataDir, agentKey) {
58
+ if (!agentKey)
59
+ throw new Error("agentKey is required");
60
+ this.agentKey = agentKey;
61
+ this.dataDir = dataDir;
13
62
  mkdirSync(dataDir, { recursive: true });
14
63
  this.db = new Database(join(dataDir, "webagent.db"));
15
- this.db.pragma("journal_mode = WAL");
16
- this.migrate();
17
- // Enforce foreign keys *after* migrate() so the one-time orphan cleanup
18
- // can run without pragma interfering with legacy cleanup queries.
19
- this.db.pragma("foreign_keys = ON");
64
+ try {
65
+ this.db.pragma("journal_mode = WAL");
66
+ this.db.pragma("foreign_keys = ON");
67
+ this.assertSupportedSchema();
68
+ this.initializeSchema();
69
+ this.migrateSystemMessagePayloads();
70
+ }
71
+ catch (error) {
72
+ this.db.close();
73
+ throw error;
74
+ }
75
+ }
76
+ assertSupportedSchema() {
77
+ const legacy = this.db
78
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions'")
79
+ .get();
80
+ const requiredColumns = {
81
+ tasks: [
82
+ "id",
83
+ "cwd",
84
+ "title",
85
+ "brief",
86
+ "workflow_status",
87
+ "parent_id",
88
+ "pending_compact_summary",
89
+ "model",
90
+ "mode",
91
+ "reasoning_effort",
92
+ "source",
93
+ "deleted_at",
94
+ "created_at",
95
+ "last_active_at",
96
+ ],
97
+ agent_sessions: [
98
+ "agent_key",
99
+ "agent_session_id",
100
+ "task_id",
101
+ "created_at",
102
+ ],
103
+ events: [
104
+ "id",
105
+ "task_id",
106
+ "seq",
107
+ "type",
108
+ "data",
109
+ "created_at",
110
+ "from_ref",
111
+ ],
112
+ shares: [
113
+ "token",
114
+ "task_id",
115
+ "shared_at",
116
+ "share_snapshot_seq",
117
+ "ttl_hours",
118
+ "display_name",
119
+ "owner_label",
120
+ "created_at",
121
+ "last_accessed_at",
122
+ ],
123
+ attachments: [
124
+ "id",
125
+ "task_id",
126
+ "kind",
127
+ "name",
128
+ "mime",
129
+ "size",
130
+ "realpath",
131
+ "upload_seq",
132
+ "width",
133
+ "height",
134
+ "created_at",
135
+ ],
136
+ inbox_messages: [
137
+ "id",
138
+ "from_ref",
139
+ "from_label",
140
+ "to_ref",
141
+ "deliver",
142
+ "dedup_key",
143
+ "title",
144
+ "body",
145
+ "cwd",
146
+ "created_at",
147
+ ],
148
+ messages: [
149
+ "id",
150
+ "source_task_id",
151
+ "direct_target_task_id",
152
+ "source_actor",
153
+ "body",
154
+ "created_at",
155
+ ],
156
+ message_projections: ["message_id", "task_id", "role", "created_at"],
157
+ deliveries: [
158
+ "id",
159
+ "message_id",
160
+ "recipient_task_id",
161
+ "idempotency_key",
162
+ "status",
163
+ "queued_at",
164
+ "claimed_at",
165
+ "delivered_at",
166
+ "failed_at",
167
+ "failure_reason",
168
+ ],
169
+ };
170
+ const incompatible = legacy
171
+ ? "legacy sessions table"
172
+ : Object.entries(requiredColumns).find(([table, columns]) => {
173
+ const exists = this.db
174
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
175
+ .get(table);
176
+ if (!exists)
177
+ return false;
178
+ const actual = new Set(this.db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
179
+ return (columns.some((column) => !actual.has(column)) ||
180
+ [...actual].some((column) => !columns.includes(column)) ||
181
+ (table === "events" &&
182
+ this.db
183
+ .prepare("SELECT 1 AS present FROM events WHERE from_ref IS NULL LIMIT 1")
184
+ .get() !== undefined));
185
+ })?.[0];
186
+ if (incompatible) {
187
+ throw new Error(`Pre-1.0 data directory detected (${incompatible}). Back up and delete the data directory before restarting: ${this.dataDir}`);
188
+ }
20
189
  }
21
- migrate() {
190
+ initializeSchema() {
22
191
  this.db.exec(`
23
- CREATE TABLE IF NOT EXISTS sessions (
192
+ CREATE TABLE IF NOT EXISTS tasks (
24
193
  id TEXT PRIMARY KEY,
25
194
  cwd TEXT NOT NULL,
26
195
  title TEXT,
196
+ brief TEXT NOT NULL DEFAULT '',
197
+ workflow_status TEXT NOT NULL DEFAULT 'idle'
198
+ CHECK (workflow_status IN ('running', 'idle', 'blocked', 'done')),
199
+ parent_id TEXT REFERENCES tasks(id),
200
+ pending_compact_summary TEXT,
201
+ model TEXT,
202
+ mode TEXT,
203
+ reasoning_effort TEXT,
204
+ source TEXT NOT NULL DEFAULT 'auto',
205
+ deleted_at INTEGER,
27
206
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
28
207
  last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
29
208
  );
209
+ CREATE TABLE IF NOT EXISTS agent_sessions (
210
+ agent_key TEXT NOT NULL,
211
+ agent_session_id TEXT NOT NULL,
212
+ task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
213
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
214
+ PRIMARY KEY (agent_key, agent_session_id)
215
+ );
216
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sessions_task
217
+ ON agent_sessions(task_id)
218
+ WHERE task_id IS NOT NULL;
30
219
  CREATE TABLE IF NOT EXISTS events (
31
220
  id INTEGER PRIMARY KEY AUTOINCREMENT,
32
- session_id TEXT NOT NULL REFERENCES sessions(id),
221
+ task_id TEXT NOT NULL REFERENCES tasks(id),
33
222
  seq INTEGER NOT NULL,
34
223
  type TEXT NOT NULL,
35
224
  data TEXT NOT NULL DEFAULT '{}',
36
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
225
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
226
+ from_ref TEXT NOT NULL
37
227
  );
38
- CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, seq);
228
+ CREATE INDEX IF NOT EXISTS idx_events_task ON events(task_id, seq);
39
229
  CREATE TABLE IF NOT EXISTS push_subscriptions (
40
230
  id INTEGER PRIMARY KEY AUTOINCREMENT,
41
231
  endpoint TEXT NOT NULL UNIQUE,
@@ -44,39 +234,13 @@ export class Store {
44
234
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
45
235
  );
46
236
  `);
47
- // Migrate existing tables: add columns if missing
48
- const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
49
- const colNames = new Set(cols.map((c) => c.name));
50
- if (!colNames.has("title")) {
51
- this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
52
- }
53
- if (!colNames.has("last_active_at")) {
54
- this.db.exec("ALTER TABLE sessions ADD COLUMN last_active_at TEXT");
55
- // Backfill from created_at
56
- this.db.exec("UPDATE sessions SET last_active_at = created_at WHERE last_active_at IS NULL");
57
- }
58
- if (!colNames.has("model")) {
59
- this.db.exec("ALTER TABLE sessions ADD COLUMN model TEXT");
60
- }
61
- if (!colNames.has("mode")) {
62
- this.db.exec("ALTER TABLE sessions ADD COLUMN mode TEXT");
63
- }
64
- if (!colNames.has("reasoning_effort")) {
65
- this.db.exec("ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT");
66
- }
67
- if (!colNames.has("source")) {
68
- this.db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'auto'");
69
- }
70
- if (!colNames.has("deleted_at")) {
71
- this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
72
- }
73
- // messages — pending unbound notifications. POST /api/v1/messages with
237
+ // inbox_messages pending unbound notifications. POST /api/v1/messages with
74
238
  // `to = "user"` lands here; consumeMessageTx transactionally moves the
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
239
+ // content into an existing ACP-backed task's events and deletes the
240
+ // row. Bound messages (to = task id) skip this table entirely and go
77
241
  // straight to `events`.
78
242
  this.db.exec(`
79
- CREATE TABLE IF NOT EXISTS messages (
243
+ CREATE TABLE IF NOT EXISTS inbox_messages (
80
244
  id TEXT PRIMARY KEY,
81
245
  from_ref TEXT NOT NULL,
82
246
  from_label TEXT,
@@ -88,85 +252,85 @@ export class Store {
88
252
  cwd TEXT,
89
253
  created_at INTEGER NOT NULL
90
254
  );
91
- CREATE INDEX IF NOT EXISTS idx_messages_created ON messages (created_at);
92
- CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages (to_ref, dedup_key);
255
+ CREATE INDEX IF NOT EXISTS idx_inbox_messages_created ON inbox_messages (created_at);
256
+ CREATE INDEX IF NOT EXISTS idx_inbox_messages_dedup ON inbox_messages (to_ref, dedup_key);
257
+
258
+ CREATE TABLE IF NOT EXISTS messages (
259
+ id TEXT PRIMARY KEY,
260
+ source_task_id TEXT NOT NULL,
261
+ direct_target_task_id TEXT NOT NULL,
262
+ source_actor TEXT NOT NULL CHECK (source_actor IN ('user', 'agent', 'system')),
263
+ body TEXT NOT NULL,
264
+ created_at INTEGER NOT NULL
265
+ );
266
+ CREATE INDEX IF NOT EXISTS idx_messages_source_created
267
+ ON messages (source_task_id, created_at);
268
+ CREATE INDEX IF NOT EXISTS idx_messages_target_created
269
+ ON messages (direct_target_task_id, created_at);
270
+
271
+ CREATE TABLE IF NOT EXISTS message_projections (
272
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
273
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
274
+ role TEXT NOT NULL CHECK (role IN ('source', 'target', 'supervisor')),
275
+ created_at INTEGER NOT NULL,
276
+ PRIMARY KEY (message_id, task_id)
277
+ );
278
+ CREATE INDEX IF NOT EXISTS idx_message_projections_task_created
279
+ ON message_projections (task_id, created_at);
280
+
281
+ CREATE TABLE IF NOT EXISTS deliveries (
282
+ id TEXT PRIMARY KEY,
283
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
284
+ recipient_task_id TEXT NOT NULL,
285
+ idempotency_key TEXT NOT NULL UNIQUE,
286
+ status TEXT NOT NULL CHECK (status IN ('queued', 'draining', 'delivered', 'failed')),
287
+ queued_at INTEGER NOT NULL,
288
+ claimed_at INTEGER,
289
+ delivered_at INTEGER,
290
+ failed_at INTEGER,
291
+ failure_reason TEXT
292
+ );
293
+ CREATE INDEX IF NOT EXISTS idx_deliveries_recipient_status_queued
294
+ ON deliveries (recipient_task_id, status, queued_at);
93
295
  `);
94
296
  // client-server-split M2: idempotency for mutating REST calls. Stores
95
- // the cached response per (session_id, client_op_id) so retries (after
297
+ // the cached response per (task_id, client_op_id) so retries (after
96
298
  // network/SSE reconnect) return the same result instead of re-executing
97
299
  // side effects.
98
300
  this.db.exec(`
99
301
  CREATE TABLE IF NOT EXISTS client_ops (
100
- session_id TEXT NOT NULL,
302
+ task_id TEXT NOT NULL,
101
303
  client_op_id TEXT NOT NULL,
102
304
  result_json TEXT NOT NULL,
103
305
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
104
- PRIMARY KEY (session_id, client_op_id)
306
+ PRIMARY KEY (task_id, client_op_id)
105
307
  );
106
308
  `);
107
309
  // recent_paths: LRU path list for /new menu
108
- const rpExists = this.db
109
- .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='recent_paths'")
110
- .get();
111
- if (!rpExists) {
112
- this.db.exec(`
113
- CREATE TABLE recent_paths (
114
- cwd TEXT PRIMARY KEY,
115
- last_used_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
116
- );
117
- `);
118
- // Backfill from existing sessions
119
- this.db.exec(`
120
- INSERT OR IGNORE INTO recent_paths (cwd, last_used_at)
121
- SELECT cwd, MAX(COALESCE(last_active_at, created_at))
122
- FROM sessions GROUP BY cwd;
123
- `);
124
- }
125
- // events.from_ref — origin marker for every event row.
126
- // Values: 'user' | 'system' | 'agent' | 'msg:<id>'. The 'msg:<id>'
127
- // form is reserved for events authored by consuming an inbox message
128
- // (see C7+). Bucketed backfill runs once for legacy rows.
129
- const eventCols = this.db
130
- .prepare("PRAGMA table_info(events)")
131
- .all();
132
- const eventColNames = new Set(eventCols.map((c) => c.name));
133
- if (!eventColNames.has("from_ref")) {
134
- this.db.exec("ALTER TABLE events ADD COLUMN from_ref TEXT");
135
- // Buckets:
136
- // user — user-authored input
137
- // system — client-originated side-channel actions + host responses
138
- // (permission responses, local bash, system messages)
139
- // agent — everything else (assistant_message, thinking, tool_call,
140
- // tool_call_update, plan, prompt_done, permission_request,
141
- // etc.)
142
- this.db.exec(`
143
- UPDATE events SET from_ref = CASE
144
- WHEN type = 'user_message' THEN 'user'
145
- WHEN type IN ('permission_response', 'bash_command', 'bash_result',
146
- 'system_message') THEN 'system'
147
- ELSE 'agent'
148
- END
149
- WHERE from_ref IS NULL
150
- `);
151
- }
152
- // One-time orphan cleanup: rows whose session_id no longer exists in
153
- // `sessions`. Pre-FK writes could leave these behind (a session DELETE
154
- // that didn't cascade because the FK pragma was off). Must run before
155
- // enabling FK pragma.
156
- this.db.exec("DELETE FROM events WHERE session_id NOT IN (SELECT id FROM sessions)");
157
- // Secondary index for events queried by (session_id, type, created_at)
158
- // -- used by upcoming inbox/message consume queries.
159
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_events_type ON events(session_id, type, created_at)");
310
+ this.db.exec(`
311
+ CREATE TABLE IF NOT EXISTS recent_paths (
312
+ cwd TEXT PRIMARY KEY,
313
+ last_used_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
314
+ );
315
+ `);
316
+ this.db.exec(`
317
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_parent_title_live
318
+ ON tasks (parent_id, title)
319
+ WHERE deleted_at IS NULL AND title IS NOT NULL;
320
+ `);
321
+ // Secondary index for events queried by (task_id, type, created_at)
322
+ // -- used by inbox/message consume queries.
323
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_events_type ON events(task_id, type, created_at)");
160
324
  // shares — public read-only share links (share-plan §4.1).
161
325
  // State machine: preview (shared_at NULL) → active (shared_at set).
162
326
  // Revocation = hard-delete the row (no audit trail kept).
163
- // Multiple active siblings per session allowed (v4 multi-share).
327
+ // Multiple active siblings per task allowed (v4 multi-share).
164
328
  // Partial unique index enforces at most one un-activated preview per
165
- // session at any time.
329
+ // task at any time.
166
330
  this.db.exec(`
167
331
  CREATE TABLE IF NOT EXISTS shares (
168
332
  token TEXT PRIMARY KEY,
169
- session_id TEXT NOT NULL REFERENCES sessions(id),
333
+ task_id TEXT NOT NULL REFERENCES tasks(id),
170
334
  shared_at INTEGER,
171
335
  share_snapshot_seq INTEGER NOT NULL,
172
336
  ttl_hours INTEGER,
@@ -175,32 +339,17 @@ export class Store {
175
339
  created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000),
176
340
  last_accessed_at INTEGER
177
341
  );
178
- CREATE INDEX IF NOT EXISTS idx_shares_session ON shares(session_id, created_at DESC);
342
+ CREATE INDEX IF NOT EXISTS idx_shares_task ON shares(task_id, created_at DESC);
179
343
  `);
180
- // Migrate: drop revoked_at column from existing tables (v0.5+).
181
- // Revocation is now hard-delete; kept rows are always live.
182
- const shareCols = this.db
183
- .prepare("PRAGMA table_info(shares)")
184
- .all();
185
- const shareColNames = new Set(shareCols.map((c) => c.name));
186
- if (shareColNames.has("revoked_at")) {
187
- // Hard-delete any pre-existing revoked rows so the migration
188
- // doesn't resurrect them as "live" shares after dropping the column.
189
- this.db.exec("DELETE FROM shares WHERE revoked_at IS NOT NULL");
190
- // Drop the partial unique index that references revoked_at, then
191
- // the column, then recreate the index without the revoked_at clause.
192
- this.db.exec("DROP INDEX IF EXISTS shares_one_active_preview");
193
- this.db.exec("ALTER TABLE shares DROP COLUMN revoked_at");
194
- }
195
344
  this.db.exec(`
196
345
  CREATE UNIQUE INDEX IF NOT EXISTS shares_one_active_preview
197
- ON shares(session_id)
346
+ ON shares(task_id)
198
347
  WHERE shared_at IS NULL;
199
348
  `);
200
- // attachments — server-managed file uploads bound to a session.
201
- // Lifecycle = session lifecycle: FK CASCADE removes the row when the
202
- // session row is deleted (hard-delete path). Tombstoned (soft-deleted)
203
- // sessions keep the row alive so the share viewer can still resolve
349
+ // attachments — server-managed file uploads bound to a task.
350
+ // Lifecycle = task lifecycle: FK CASCADE removes the row when the
351
+ // task row is deleted (hard-delete path). Tombstoned (soft-deleted)
352
+ // tasks keep the row alive so the share viewer can still resolve
204
353
  // file references for active shares.
205
354
  //
206
355
  // upload_seq = MAX(events.seq) at upload time. The share proxy uses
@@ -213,7 +362,7 @@ export class Store {
213
362
  this.db.exec(`
214
363
  CREATE TABLE IF NOT EXISTS attachments (
215
364
  id TEXT PRIMARY KEY,
216
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
365
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
217
366
  kind TEXT NOT NULL,
218
367
  name TEXT NOT NULL,
219
368
  mime TEXT NOT NULL,
@@ -224,18 +373,8 @@ export class Store {
224
373
  height INTEGER,
225
374
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
226
375
  );
227
- CREATE INDEX IF NOT EXISTS idx_attachments_session ON attachments(session_id);
376
+ CREATE INDEX IF NOT EXISTS idx_attachments_task ON attachments(task_id);
228
377
  `);
229
- const attachmentCols = this.db
230
- .prepare("PRAGMA table_info(attachments)")
231
- .all();
232
- const attachmentColNames = new Set(attachmentCols.map((c) => c.name));
233
- if (!attachmentColNames.has("width")) {
234
- this.db.exec("ALTER TABLE attachments ADD COLUMN width INTEGER");
235
- }
236
- if (!attachmentColNames.has("height")) {
237
- this.db.exec("ALTER TABLE attachments ADD COLUMN height INTEGER");
238
- }
239
378
  // owner_prefs — key-value store for owner-scoped defaults (display_name,
240
379
  // last /by selection, etc). Single-user model = single owner scope.
241
380
  // Stored as plain key/value so we don't grow a new table per pref.
@@ -247,156 +386,519 @@ export class Store {
247
386
  );
248
387
  `);
249
388
  }
250
- createSession(id, cwd, source = "auto") {
251
- this.db
252
- .prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
253
- .run(id, cwd, source);
254
- return this.db
255
- .prepare("SELECT * FROM sessions WHERE id = ?")
256
- .get(id);
389
+ /** Normalize the pre-title system_message payload in place. This is
390
+ * idempotent and runs before any event can be replayed or returned. */
391
+ migrateSystemMessagePayloads() {
392
+ const rows = this.db
393
+ .prepare("SELECT id, data FROM events WHERE type = 'system_message'")
394
+ .all();
395
+ const update = this.db.prepare("UPDATE events SET data = ? WHERE id = ?");
396
+ this.db.transaction(() => {
397
+ for (const row of rows) {
398
+ const migrated = migrateSystemMessageData(row.data);
399
+ if (migrated !== null)
400
+ update.run(migrated, row.id);
401
+ }
402
+ })();
403
+ }
404
+ /**
405
+ * Ensure the reserved Root record exists and attach existing live tasks
406
+ * that do not have a parent. This is additive and keeps every old task,
407
+ * event, attachment, and share intact; the Root's ACP binding is created by
408
+ * SessionManager after the bridge is ready.
409
+ *
410
+ * The default title is the literal "root": it is only applied while the
411
+ * title is still NULL, so a user rename survives restarts. The non-null
412
+ * title also keeps title generation from ever overwriting Root.
413
+ */
414
+ ensureRootTask(cwd) {
415
+ return this.db.transaction(() => {
416
+ this.db
417
+ .prepare("INSERT OR IGNORE INTO tasks (id, cwd, source, parent_id, title) VALUES (?, ?, 'root', NULL, 'root')")
418
+ .run("root", cwd);
419
+ this.db
420
+ .prepare("UPDATE tasks SET parent_id = NULL WHERE id = ?")
421
+ .run("root");
422
+ // Default title only while NULL so a user rename survives restarts.
423
+ this.db
424
+ .prepare("UPDATE tasks SET title = 'root' WHERE id = ? AND title IS NULL")
425
+ .run("root");
426
+ this.db
427
+ .prepare(`UPDATE tasks
428
+ SET parent_id = ?
429
+ WHERE id != ? AND parent_id IS NULL AND deleted_at IS NULL`)
430
+ .run("root", "root");
431
+ return this.db
432
+ .prepare("SELECT * FROM tasks WHERE id = ?")
433
+ .get("root");
434
+ })();
257
435
  }
258
- listSessions(opts) {
436
+ createTask(id, cwd, source = "auto", agentSessionId = id, parentId = null, opts = {}) {
437
+ return this.db.transaction(() => {
438
+ this.db
439
+ .prepare(`INSERT INTO tasks
440
+ (id, cwd, source, parent_id, title, brief, workflow_status)
441
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
442
+ .run(id, cwd, source, parentId, opts.title ?? id, opts.brief ?? "", opts.workflowStatus ?? "idle");
443
+ this.db
444
+ .prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, ?)")
445
+ .run(this.agentKey, agentSessionId, id);
446
+ if (opts.initialMessage) {
447
+ this.createCollaborationMessage({
448
+ ...opts.initialMessage,
449
+ directTargetTaskId: id,
450
+ });
451
+ }
452
+ return this.db
453
+ .prepare("SELECT * FROM tasks WHERE id = ?")
454
+ .get(id);
455
+ })();
456
+ }
457
+ listTasks(opts) {
259
458
  if (opts?.source) {
260
459
  return this.db
261
- .prepare("SELECT * FROM sessions WHERE source = ? AND deleted_at IS NULL ORDER BY COALESCE(last_active_at, created_at) DESC")
262
- .all(opts.source);
460
+ .prepare(`SELECT s.*,
461
+ EXISTS (
462
+ SELECT 1 FROM events e
463
+ WHERE e.task_id = s.id AND e.type = 'user_message'
464
+ ) OR EXISTS (
465
+ SELECT 1 FROM messages m
466
+ WHERE m.source_actor = 'user'
467
+ AND (m.source_task_id = s.id OR m.direct_target_task_id = s.id)
468
+ ) AS has_user_input
469
+ FROM tasks s
470
+ JOIN agent_sessions a ON a.task_id = s.id
471
+ WHERE a.agent_key = ? AND s.source = ? AND s.deleted_at IS NULL
472
+ ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
473
+ .all(this.agentKey, opts.source);
263
474
  }
264
475
  return this.db
265
- .prepare("SELECT * FROM sessions WHERE deleted_at IS NULL ORDER BY COALESCE(last_active_at, created_at) DESC")
266
- .all();
476
+ .prepare(`SELECT s.*,
477
+ EXISTS (
478
+ SELECT 1 FROM events e
479
+ WHERE e.task_id = s.id AND e.type = 'user_message'
480
+ ) OR EXISTS (
481
+ SELECT 1 FROM messages m
482
+ WHERE m.source_actor = 'user'
483
+ AND (m.source_task_id = s.id OR m.direct_target_task_id = s.id)
484
+ ) AS has_user_input
485
+ FROM tasks s
486
+ JOIN agent_sessions a ON a.task_id = s.id
487
+ WHERE a.agent_key = ? AND s.deleted_at IS NULL
488
+ ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
489
+ .all(this.agentKey);
267
490
  }
268
- /** Returns live sessions only. Soft-deleted (tombstone) rows are hidden. */
269
- getSession(id) {
491
+ /** Returns live tasks only. Soft-deleted (tombstone) rows are hidden. */
492
+ getTask(id) {
270
493
  return this.db
271
- .prepare("SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL")
272
- .get(id);
494
+ .prepare(`SELECT s.* FROM tasks s
495
+ JOIN agent_sessions a ON a.task_id = s.id
496
+ WHERE s.id = ? AND a.agent_key = ? AND s.deleted_at IS NULL`)
497
+ .get(id, this.agentKey);
498
+ }
499
+ registerInternalAgentSession(agentSessionId) {
500
+ this.db
501
+ .prepare("INSERT OR IGNORE INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, NULL)")
502
+ .run(this.agentKey, agentSessionId);
503
+ const row = this.db
504
+ .prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ?")
505
+ .get(this.agentKey, agentSessionId);
506
+ if (row.task_id) {
507
+ throw new Error("Agent reused a user-visible task ID internally");
508
+ }
509
+ return row;
510
+ }
511
+ getAgentSessionId(taskId) {
512
+ return this.db
513
+ .prepare("SELECT agent_session_id FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
514
+ .get(this.agentKey, taskId)?.agent_session_id;
515
+ }
516
+ getTaskId(agentSessionId) {
517
+ return this.db
518
+ .prepare("SELECT task_id FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ? AND task_id IS NOT NULL")
519
+ .get(this.agentKey, agentSessionId)?.task_id;
520
+ }
521
+ getAgentSessionBinding(taskId) {
522
+ return this.db
523
+ .prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
524
+ .get(this.agentKey, taskId);
525
+ }
526
+ /**
527
+ * Replace the current ACP execution for a stable WebAgent task.
528
+ * The previous binding row is removed: its execution is explicitly
529
+ * retired by the caller and must never accept WebAgent events again.
530
+ */
531
+ rotateAgentSession(taskId, agentSessionId, cwd) {
532
+ return this.db.transaction(() => {
533
+ const current = this.getAgentSessionBinding(taskId);
534
+ if (!current)
535
+ throw new Error(`Task not found: ${taskId}`);
536
+ // Persist a requested cwd even when the agent returns the same
537
+ // execution id (no-op rotation); the caller decides whether to retire
538
+ // based on whether the binding actually moved.
539
+ if (cwd !== undefined) {
540
+ this.db
541
+ .prepare("UPDATE tasks SET cwd = ? WHERE id = ?")
542
+ .run(cwd, taskId);
543
+ }
544
+ if (current.agent_session_id === agentSessionId)
545
+ return current;
546
+ this.db
547
+ .prepare("DELETE FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
548
+ .run(this.agentKey, taskId);
549
+ this.db
550
+ .prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, ?)")
551
+ .run(this.agentKey, agentSessionId, taskId);
552
+ return this.getAgentSessionBinding(taskId);
553
+ })();
554
+ }
555
+ /** Bind an ACP execution to an existing WebAgent task without creating a row. */
556
+ bindAgentSession(taskId, agentSessionId) {
557
+ return this.db.transaction(() => {
558
+ if (!this.getTaskIncludingDeleted(taskId)) {
559
+ throw new Error(`Task not found: ${taskId}`);
560
+ }
561
+ const current = this.getAgentSessionBinding(taskId);
562
+ if (current) {
563
+ if (current.agent_session_id === agentSessionId)
564
+ return current;
565
+ throw new Error(`Task already has an ACP binding: ${taskId}`);
566
+ }
567
+ this.db
568
+ .prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, ?)")
569
+ .run(this.agentKey, agentSessionId, taskId);
570
+ return this.getAgentSessionBinding(taskId);
571
+ })();
572
+ }
573
+ ownsTask(taskId) {
574
+ return this.getAgentSessionBinding(taskId) !== undefined;
273
575
  }
274
576
  /**
275
- * Returns a session row even if soft-deleted. Used by the public share
577
+ * Returns a task row even if soft-deleted. Used by the public share
276
578
  * viewer, which must keep working after the owner deletes the source
277
- * session (events stay alive as long as any active share references them).
579
+ * task (events stay alive as long as any active share references them).
580
+ */
581
+ getTaskIncludingDeleted(id) {
582
+ return this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
583
+ }
584
+ /**
585
+ * Return the current lineage from Root (or the highest surviving ancestor)
586
+ * to a task. Includes soft-deleted ancestors so callers can detect a
587
+ * broken/hidden parent chain and revalidate it after acquiring a lock.
588
+ * Returns undefined for a missing row or a cycle.
589
+ */
590
+ getTaskLineage(id) {
591
+ const reversed = [];
592
+ const seen = new Set();
593
+ let current = this.getTaskIncludingDeleted(id);
594
+ while (current) {
595
+ if (seen.has(current.id))
596
+ return undefined;
597
+ seen.add(current.id);
598
+ reversed.push(current.id);
599
+ if (current.parent_id === null)
600
+ return reversed.reverse();
601
+ current = this.getTaskIncludingDeleted(current.parent_id);
602
+ }
603
+ return undefined;
604
+ }
605
+ /**
606
+ * The task's tree path (`@/parent/child`) for user-facing messages, or
607
+ * undefined when the row is missing or the lineage is broken. Users read this
608
+ * as the name of a task they can act on, unlike a raw id. Root is the path's
609
+ * origin and is never a segment, matching what the input grammar resolves.
278
610
  */
279
- getSessionIncludingDeleted(id) {
280
- return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
611
+ getTaskPath(id) {
612
+ const lineage = this.getTaskLineage(id);
613
+ if (!lineage)
614
+ return undefined;
615
+ const segments = lineage[0] === ROOT_TASK_ID ? lineage.slice(1) : lineage;
616
+ return formatTaskPath(segments.map((taskId) => this.getTaskIncludingDeleted(taskId)?.title ?? taskId.slice(0, 8)));
617
+ }
618
+ /** Re-parent surviving children of a hard-deleted task under Root so the
619
+ * FK on parent_id stays valid. Root is guaranteed to exist post-boot
620
+ * (ensureRootTask runs before listen). No-op when there are no children. */
621
+ reparentChildrenToRoot(parentId) {
622
+ this.db
623
+ .prepare("UPDATE tasks SET parent_id = ? WHERE parent_id = ? AND id != ? AND id != ?")
624
+ .run(ROOT_TASK_ID, parentId, parentId, ROOT_TASK_ID);
625
+ }
626
+ /** Direct child ids of a task (excluding itself and Root), in any order. */
627
+ listChildren(parentId) {
628
+ return this.db
629
+ .prepare("SELECT id FROM tasks WHERE parent_id = ? AND id != ? AND id != ?")
630
+ .all(parentId, parentId, ROOT_TASK_ID).map((row) => row.id);
281
631
  }
282
632
  /**
283
- * Delete a session. If any active (published) shares reference it, the
284
- * session row + events are kept (soft-delete via deleted_at) so the
285
- * shared snapshot remains viewable. Otherwise everything is hard-
286
- * deleted. Preview shares (shared_at IS NULL) are always cleared:
287
- * unpublished drafts share the session's lifecycle.
633
+ * Every descendant of a task, transitively (used to gate destructive
634
+ * operations such as the DELETE busy check against in-flight children).
635
+ */
636
+ getDescendantTaskIds(rootId) {
637
+ const out = [];
638
+ const queue = [rootId];
639
+ while (queue.length > 0) {
640
+ for (const child of this.listChildren(queue.pop())) {
641
+ queue.push(child);
642
+ out.push(child);
643
+ }
644
+ }
645
+ return out;
646
+ }
647
+ hasActiveShare(taskId) {
648
+ const row = this.db
649
+ .prepare("SELECT 1 AS present FROM shares WHERE task_id = ? AND shared_at IS NOT NULL LIMIT 1")
650
+ .get(taskId);
651
+ return row !== undefined;
652
+ }
653
+ /**
654
+ * Reset Root while preserving its stable anchor row. Every descendant is
655
+ * deleted using the ordinary task/share lifecycle, then Root's own event
656
+ * history and attachments are cleared. The caller rotates Root's ACP
657
+ * execution separately so this method remains a synchronous DB operation.
658
+ */
659
+ resetRootTask() {
660
+ const root = this.getTaskIncludingDeleted(ROOT_TASK_ID);
661
+ if (!root)
662
+ throw new Error("Root task not found");
663
+ if (this.hasActiveShare(ROOT_TASK_ID)) {
664
+ throw new Error("Root task has an active share");
665
+ }
666
+ const reset = this.db.transaction(() => {
667
+ const affected = [];
668
+ for (const childId of this.listChildren(ROOT_TASK_ID)) {
669
+ affected.push(...this.deleteTask(childId).affected);
670
+ }
671
+ this.db
672
+ .prepare("DELETE FROM shares WHERE task_id = ? AND shared_at IS NULL")
673
+ .run(ROOT_TASK_ID);
674
+ this.db.prepare("DELETE FROM events WHERE task_id = ?").run(ROOT_TASK_ID);
675
+ this.db
676
+ .prepare("DELETE FROM client_ops WHERE task_id = ?")
677
+ .run(ROOT_TASK_ID);
678
+ this.db
679
+ .prepare("DELETE FROM attachments WHERE task_id = ?")
680
+ .run(ROOT_TASK_ID);
681
+ this.db
682
+ .prepare("UPDATE tasks SET pending_compact_summary = NULL WHERE id = ?")
683
+ .run(ROOT_TASK_ID);
684
+ return { affected };
685
+ })();
686
+ return reset;
687
+ }
688
+ /**
689
+ * Delete a task and every descendant task recursively. The
690
+ * parent/child hierarchy is a hard ownership link, so the deletion is
691
+ * immediate and needs no confirmation (a confirmation step is deferred
692
+ * until a tree UI exists). Each affected task follows its own share
693
+ * rules: a task with active shares is tombstoned (kept so share
694
+ * viewers still resolve) and its binding removed; a share-tombstoned
695
+ * descendant of a hard-deleted parent is re-parented under Root.
288
696
  *
289
- * Returns "hard" if the row + events were physically removed, "soft"
290
- * if the row was tombstoned because shares still reference it. Callers
291
- * use this to decide whether to clean up filesystem artefacts (images).
697
+ * Returns every affected task with its deletion mode and the ACP
698
+ * execution bound at deletion time, so callers can retire executions,
699
+ * clean up in-memory state, and broadcast `task_deleted` per id.
292
700
  */
293
- deleteSession(id) {
701
+ deleteTask(id) {
702
+ if (id === ROOT_TASK_ID) {
703
+ throw new Error("Root task cannot be deleted");
704
+ }
705
+ const affected = [];
706
+ // Delete descendants first (children before parents) so the FK on
707
+ // parent_id can never block the parent's own row removal.
708
+ const children = this.listChildren(id);
709
+ for (const childId of children) {
710
+ affected.push(...this.deleteTask(childId).affected);
711
+ }
712
+ const binding = this.getAgentSessionBinding(id);
294
713
  // Drop preview shares regardless — they are owner-only drafts and
295
- // share the session's lifecycle by design.
714
+ // share the task's lifecycle by design.
296
715
  this.db
297
- .prepare("DELETE FROM shares WHERE session_id = ? AND shared_at IS NULL")
716
+ .prepare("DELETE FROM shares WHERE task_id = ? AND shared_at IS NULL")
298
717
  .run(id);
299
718
  const activeShareCount = this.db
300
- .prepare("SELECT COUNT(*) AS n FROM shares WHERE session_id = ? AND shared_at IS NOT NULL")
719
+ .prepare("SELECT COUNT(*) AS n FROM shares WHERE task_id = ? AND shared_at IS NOT NULL")
301
720
  .get(id).n;
302
- this.db.prepare("DELETE FROM client_ops WHERE session_id = ?").run(id);
721
+ this.db.prepare("DELETE FROM client_ops WHERE task_id = ?").run(id);
303
722
  if (activeShareCount > 0) {
304
- // Soft-delete: keep events + sessions row so public share viewers
305
- // can still resolve. revokeShare() / reapTombstoneIfOrphaned()
306
- // finishes the job once the last share is gone.
723
+ // Soft-delete: keep events + tasks row so public share viewers
724
+ // can still resolve. The owner-side binding is retired like a hard
725
+ // delete; revokeShare() / reapTombstoneIfOrphaned() finishes the job
726
+ // once the last share is gone.
307
727
  this.db
308
- .prepare("UPDATE sessions SET deleted_at = ? WHERE id = ?")
728
+ .prepare("UPDATE tasks SET deleted_at = ? WHERE id = ?")
309
729
  .run(Date.now(), id);
310
- return "soft";
730
+ if (binding) {
731
+ this.db
732
+ .prepare("DELETE FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ?")
733
+ .run(this.agentKey, binding.agent_session_id);
734
+ }
735
+ affected.unshift({
736
+ id,
737
+ mode: "soft",
738
+ agentSessionId: binding?.agent_session_id ?? null,
739
+ });
740
+ return { mode: "soft", affected };
311
741
  }
312
- this.db.prepare("DELETE FROM events WHERE session_id = ?").run(id);
313
- this.db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
314
- return "hard";
742
+ // Hard delete: re-parent any survivor (share-tombstoned descendants)
743
+ // under Root, then drop events and the row (agent_sessions cascades).
744
+ this.reparentChildrenToRoot(id);
745
+ this.failOutstandingDeliveriesForDeletedTask(id);
746
+ this.db.prepare("DELETE FROM events WHERE task_id = ?").run(id);
747
+ this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
748
+ affected.unshift({
749
+ id,
750
+ mode: "hard",
751
+ agentSessionId: binding?.agent_session_id ?? null,
752
+ });
753
+ return { mode: "hard", affected };
315
754
  }
316
755
  /**
317
- * Hard-delete events + sessions row for a session that has been soft-
318
- * deleted and whose last share was just revoked. No-op if the session
756
+ * Hard-delete events + tasks row for a task that has been soft-
757
+ * deleted and whose last share was just revoked. No-op if the task
319
758
  * is still live (deleted_at IS NULL) or still has active shares.
320
759
  * Returns true if a tombstone was reaped.
321
760
  */
322
- reapTombstoneIfOrphaned(sessionId) {
323
- const sess = this.db
324
- .prepare("SELECT id FROM sessions WHERE id = ? AND deleted_at IS NOT NULL")
325
- .get(sessionId);
326
- if (!sess)
761
+ reapTombstoneIfOrphaned(taskId) {
762
+ const row = this.db
763
+ .prepare("SELECT id FROM tasks WHERE id = ? AND deleted_at IS NOT NULL")
764
+ .get(taskId);
765
+ if (!row)
327
766
  return false;
328
767
  const remaining = this.db
329
- .prepare("SELECT COUNT(*) AS n FROM shares WHERE session_id = ?")
330
- .get(sessionId).n;
768
+ .prepare("SELECT COUNT(*) AS n FROM shares WHERE task_id = ?")
769
+ .get(taskId).n;
331
770
  if (remaining > 0)
332
771
  return false;
333
- this.db.prepare("DELETE FROM events WHERE session_id = ?").run(sessionId);
334
- this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
772
+ // Its live children were deleted when it was tombstoned; any survivor
773
+ // (a share-tombstoned descendant of this tombstone) still references it
774
+ // and must be re-parented under Root before the row goes away.
775
+ this.reparentChildrenToRoot(taskId);
776
+ this.failOutstandingDeliveriesForDeletedTask(taskId);
777
+ this.db.prepare("DELETE FROM events WHERE task_id = ?").run(taskId);
778
+ this.db.prepare("DELETE FROM tasks WHERE id = ?").run(taskId);
335
779
  return true;
336
780
  }
337
- /** Delete sessions that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
338
- deleteEmptySessions(minAgeS) {
781
+ /** Delete tasks that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
782
+ deleteEmptyTasks(minAgeS) {
339
783
  const empties = this.db
340
784
  .prepare(`
341
- SELECT s.id FROM sessions s
342
- LEFT JOIN events e ON e.session_id = s.id
785
+ SELECT s.id, a.agent_session_id FROM tasks s
786
+ JOIN agent_sessions a ON a.task_id = s.id
787
+ LEFT JOIN events e ON e.task_id = s.id
343
788
  WHERE e.id IS NULL
789
+ AND s.id != ?
790
+ AND a.agent_key = ?
791
+ AND s.deleted_at IS NULL
344
792
  AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
345
793
  `)
346
- .all(minAgeS);
794
+ .all(ROOT_TASK_ID, this.agentKey, minAgeS);
347
795
  if (empties.length === 0)
348
796
  return [];
349
- const del = this.db.prepare("DELETE FROM sessions WHERE id = ?");
350
- for (const r of empties)
797
+ const del = this.db.prepare("DELETE FROM tasks WHERE id = ?");
798
+ const removed = [];
799
+ for (const r of empties) {
800
+ // A junk task may still be someone's parent; keep the children by
801
+ // re-parenting them under Root instead of deleting them.
802
+ this.reparentChildrenToRoot(r.id);
803
+ this.failOutstandingDeliveriesForDeletedTask(r.id);
351
804
  del.run(r.id);
352
- return empties.map((r) => r.id);
805
+ removed.push({ id: r.id, agentSessionId: r.agent_session_id });
806
+ }
807
+ return removed;
353
808
  }
354
- updateSessionTitle(id, title) {
809
+ updateTaskTitle(id, title) {
810
+ this.db.prepare("UPDATE tasks SET title = ? WHERE id = ?").run(title, id);
811
+ }
812
+ updateTaskWorkflowStatus(id, status) {
355
813
  this.db
356
- .prepare("UPDATE sessions SET title = ? WHERE id = ?")
357
- .run(title, id);
814
+ .prepare("UPDATE tasks SET workflow_status = ? WHERE id = ?")
815
+ .run(status, id);
358
816
  }
359
- updateSessionLastActive(id) {
817
+ updateTaskLastActive(id) {
360
818
  this.db
361
- .prepare("UPDATE sessions SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?")
819
+ .prepare("UPDATE tasks SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?")
362
820
  .run(id);
363
821
  }
364
- /** Update a config option value (model, mode, reasoning_effort) for a session. */
365
- updateSessionConfig(id, configId, value) {
822
+ /** Return the hidden summary waiting to be prepended to the next prompt. */
823
+ getPendingCompactSummary(id) {
824
+ const row = this.db
825
+ .prepare("SELECT pending_compact_summary FROM tasks WHERE id = ?")
826
+ .get(id);
827
+ return row?.pending_compact_summary ?? null;
828
+ }
829
+ /**
830
+ * Persist the visible assistant summary and its hidden pending copy together.
831
+ * The latter is consumed only when the next real prompt is accepted.
832
+ */
833
+ saveCompactSummary(taskId, summary) {
834
+ return this.db.transaction(() => {
835
+ const seq = this.db
836
+ .prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE task_id = ?")
837
+ .get(taskId).next;
838
+ this.db
839
+ .prepare("INSERT INTO events (task_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
840
+ .run(taskId, seq, "assistant_message", JSON.stringify({ text: summary }), "agent");
841
+ this.db
842
+ .prepare("UPDATE tasks SET pending_compact_summary = ? WHERE id = ?")
843
+ .run(summary, taskId);
844
+ return this.db
845
+ .prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
846
+ .get(taskId, seq);
847
+ })();
848
+ }
849
+ /** Clear a pending summary, optionally only when it is still the expected value. */
850
+ clearPendingCompactSummary(id, expected) {
851
+ const result = expected === undefined
852
+ ? this.db
853
+ .prepare("UPDATE tasks SET pending_compact_summary = NULL WHERE id = ?")
854
+ .run(id)
855
+ : this.db
856
+ .prepare("UPDATE tasks SET pending_compact_summary = NULL WHERE id = ? AND pending_compact_summary = ?")
857
+ .run(id, expected);
858
+ return result.changes > 0;
859
+ }
860
+ /** Update a config option value (model, mode, reasoning_effort) for a task. */
861
+ updateTaskConfig(id, configId, value) {
366
862
  const column = {
367
863
  model: "model",
368
864
  mode: "mode",
369
865
  reasoning_effort: "reasoning_effort",
866
+ thought_level: "reasoning_effort",
370
867
  }[configId];
371
868
  if (!column)
372
869
  return;
373
870
  this.db
374
- .prepare(`UPDATE sessions SET ${column} = ? WHERE id = ?`)
871
+ .prepare(`UPDATE tasks SET ${column} = ? WHERE id = ?`)
375
872
  .run(value, id);
376
873
  }
377
- saveEvent(sessionId, type, data = {}, opts) {
874
+ saveEvent(taskId, type, data = {}, opts) {
378
875
  const seq = this.db
379
- .prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE session_id = ?")
380
- .get(sessionId).next;
876
+ .prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE task_id = ?")
877
+ .get(taskId).next;
381
878
  // Origin marker is required. Every writer must pass an explicit value;
382
879
  // missing/empty fails loudly so a forgotten retrofit can't silently
383
880
  // mis-bucket a row in production. Valid values:
384
881
  // 'user' | 'system' | 'agent' | 'msg:<id>'.
385
882
  const fromRef = opts?.from_ref;
386
883
  if (!fromRef) {
387
- throw new Error(`saveEvent: from_ref is required (type=${type} session=${sessionId.slice(0, 8)}) — pass { from_ref: 'user' | 'system' | 'agent' | 'msg:<id>' }`);
884
+ throw new Error(`saveEvent: from_ref is required (type=${type} task=${taskId.slice(0, 8)}) — pass { from_ref: 'user' | 'system' | 'agent' | 'msg:<id>' }`);
388
885
  }
389
886
  this.db
390
- .prepare("INSERT INTO events (session_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
391
- .run(sessionId, seq, type, JSON.stringify(data), fromRef);
887
+ .prepare("INSERT INTO events (task_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
888
+ .run(taskId, seq, type, JSON.stringify(data), fromRef);
392
889
  return this.db
393
- .prepare("SELECT * FROM events WHERE session_id = ? AND seq = ?")
394
- .get(sessionId, seq);
890
+ .prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
891
+ .get(taskId, seq);
395
892
  }
396
- getEvents(sessionId, opts) {
893
+ getEvent(taskId, seq) {
894
+ return this.db
895
+ .prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
896
+ .get(taskId, seq);
897
+ }
898
+ getEvents(taskId, opts) {
397
899
  const hasLimit = opts?.limit != null && opts.limit > 0;
398
- const conditions = ["session_id = ?"];
399
- const params = [sessionId];
900
+ const conditions = ["task_id = ?"];
901
+ const params = [taskId];
400
902
  if (opts?.afterSeq != null) {
401
903
  conditions.push("seq > ?");
402
904
  params.push(opts.afterSeq);
@@ -408,6 +910,11 @@ export class Store {
408
910
  if (opts?.excludeThinking) {
409
911
  conditions.push("type != 'thinking'");
410
912
  }
913
+ if (opts?.text !== undefined) {
914
+ conditions.push("data LIKE ? ESCAPE '\\'");
915
+ const escaped = opts.text.replace(/[\\%_]/g, (char) => `\\${char}`);
916
+ params.push(`%${escaped}%`);
917
+ }
411
918
  const where = conditions.join(" AND ");
412
919
  if (hasLimit) {
413
920
  // Fetch the last N matching rows: subquery orders DESC with LIMIT,
@@ -420,34 +927,37 @@ export class Store {
420
927
  .prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq`)
421
928
  .all(...params);
422
929
  }
423
- getEventCount(sessionId, opts) {
424
- let query = "SELECT COUNT(*) as count FROM events WHERE session_id = ?";
425
- const params = [sessionId];
930
+ getEventCount(taskId, opts) {
931
+ let query = "SELECT COUNT(*) as count FROM events WHERE task_id = ?";
932
+ const params = [taskId];
426
933
  if (opts?.excludeThinking) {
427
934
  query += " AND type != 'thinking'";
428
935
  }
429
936
  return this.db.prepare(query).get(...params).count;
430
937
  }
431
- /** Highest seq of any stored event for this session (0 when empty). */
432
- getLastEventSeq(sessionId) {
938
+ /** Highest seq of any stored event for this task (0 when empty). */
939
+ getLastEventSeq(taskId) {
433
940
  const row = this.db
434
- .prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE session_id = ?")
435
- .get(sessionId);
941
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE task_id = ?")
942
+ .get(taskId);
436
943
  return row.seq;
437
944
  }
438
- /** Check if the most recent agent turn was interrupted (user_message without a following prompt_done). */
439
- hasInterruptedTurn(sessionId) {
945
+ /** Check if the most recent agent turn lacks a completion or error terminal event. */
946
+ hasInterruptedTurn(taskId) {
440
947
  const row = this.db
441
948
  .prepare(`
442
949
  SELECT 1 FROM events
443
- WHERE session_id = ? AND type = 'user_message'
950
+ WHERE task_id = ? AND type = 'user_message'
444
951
  AND seq > COALESCE(
445
- (SELECT MAX(seq) FROM events WHERE session_id = ? AND type = 'prompt_done'),
952
+ (
953
+ SELECT MAX(seq) FROM events
954
+ WHERE task_id = ? AND type IN ('prompt_done', 'error')
955
+ ),
446
956
  0
447
957
  )
448
958
  LIMIT 1
449
959
  `)
450
- .get(sessionId, sessionId);
960
+ .get(taskId, taskId);
451
961
  return Boolean(row);
452
962
  }
453
963
  // --- Push subscriptions ---
@@ -496,30 +1006,34 @@ export class Store {
496
1006
  deleteRecentPath(cwd) {
497
1007
  this.db.prepare("DELETE FROM recent_paths WHERE cwd = ?").run(cwd);
498
1008
  }
499
- // ===== messages (pending unbound notifications) =====
1009
+ // ===== inbox messages (pending unbound notifications) =====
500
1010
  createMessage(input) {
501
1011
  this.db
502
- .prepare(`INSERT INTO messages
1012
+ .prepare(`INSERT INTO inbox_messages
503
1013
  (id, from_ref, from_label, to_ref, deliver, dedup_key, title, body, cwd, created_at)
504
1014
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
505
1015
  .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);
506
1016
  }
507
1017
  getMessage(id) {
508
- return this.db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
1018
+ return this.db
1019
+ .prepare("SELECT * FROM inbox_messages WHERE id = ?")
1020
+ .get(id);
509
1021
  }
510
1022
  listUnprocessed() {
511
1023
  return this.db
512
- .prepare("SELECT * FROM messages ORDER BY created_at DESC")
1024
+ .prepare("SELECT * FROM inbox_messages ORDER BY created_at DESC")
513
1025
  .all();
514
1026
  }
515
1027
  countUnprocessed() {
516
1028
  const row = this.db
517
- .prepare("SELECT COUNT(*) AS count FROM messages")
1029
+ .prepare("SELECT COUNT(*) AS count FROM inbox_messages")
518
1030
  .get();
519
1031
  return row.count;
520
1032
  }
521
1033
  deleteMessage(id) {
522
- const info = this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
1034
+ const info = this.db
1035
+ .prepare("DELETE FROM inbox_messages WHERE id = ?")
1036
+ .run(id);
523
1037
  return info.changes;
524
1038
  }
525
1039
  /**
@@ -528,7 +1042,7 @@ export class Store {
528
1042
  */
529
1043
  deleteOlderThan(thresholdMs) {
530
1044
  const info = this.db
531
- .prepare("DELETE FROM messages WHERE created_at < ?")
1045
+ .prepare("DELETE FROM inbox_messages WHERE created_at < ?")
532
1046
  .run(thresholdMs);
533
1047
  return info.changes;
534
1048
  }
@@ -537,25 +1051,25 @@ export class Store {
537
1051
  if (!dedup_key)
538
1052
  return undefined;
539
1053
  return this.db
540
- .prepare("SELECT * FROM messages WHERE to_ref = ? AND dedup_key = ? LIMIT 1")
1054
+ .prepare("SELECT * FROM inbox_messages WHERE to_ref = ? AND dedup_key = ? LIMIT 1")
541
1055
  .get(to_ref, dedup_key);
542
1056
  }
543
1057
  /**
544
- * Atomically move a pending message into an existing session. Session
1058
+ * Atomically move a pending message into an existing task. Task
545
1059
  * lifecycle belongs to SessionManager because ACP creation is asynchronous
546
1060
  * and cannot participate in this SQLite transaction.
547
1061
  */
548
- consumeMessageTx(messageId, sessionId) {
549
- const existing = this.findConsumedMessageSession(messageId);
1062
+ consumeMessageTx(messageId, taskId) {
1063
+ const existing = this.findConsumedMessageTask(messageId);
550
1064
  if (existing) {
551
- return { sessionId: existing, alreadyConsumed: true };
1065
+ return { taskId: existing, alreadyConsumed: true };
552
1066
  }
553
1067
  const row = this.getMessage(messageId);
554
1068
  if (!row) {
555
1069
  throw new MessageNotFoundError(messageId);
556
1070
  }
557
1071
  const tx = this.db.transaction(() => {
558
- this.saveEvent(sessionId, "message", {
1072
+ this.saveEvent(taskId, "message", {
559
1073
  message_id: row.id,
560
1074
  from_ref: row.from_ref,
561
1075
  from_label: row.from_label,
@@ -564,33 +1078,225 @@ export class Store {
564
1078
  cwd: row.cwd,
565
1079
  }, { from_ref: row.from_ref });
566
1080
  const del = this.db
567
- .prepare("DELETE FROM messages WHERE id = ?")
1081
+ .prepare("DELETE FROM inbox_messages WHERE id = ?")
568
1082
  .run(messageId);
569
1083
  if (del.changes === 0) {
570
1084
  throw new MessageNotFoundError(messageId);
571
1085
  }
572
1086
  });
573
1087
  tx();
574
- return { sessionId, alreadyConsumed: false };
1088
+ return { taskId, alreadyConsumed: false };
575
1089
  }
576
- findConsumedMessageSession(messageId) {
1090
+ findConsumedMessageTask(messageId) {
577
1091
  const row = this.db
578
- .prepare(`SELECT session_id FROM events
1092
+ .prepare(`SELECT task_id FROM events
579
1093
  WHERE type = 'message'
580
1094
  AND json_extract(data, '$.message_id') = ?
581
1095
  LIMIT 1`)
582
1096
  .get(messageId);
583
- return row?.session_id;
1097
+ return row?.task_id;
1098
+ }
1099
+ // ===== collaboration messages =====
1100
+ requireLiveTask(taskId) {
1101
+ const task = this.getTaskIncludingDeleted(taskId);
1102
+ if (task?.deleted_at !== null) {
1103
+ throw new Error(`Live task not found: ${taskId}`);
1104
+ }
1105
+ return task;
1106
+ }
1107
+ lowestCommonAncestor(sourceTaskId, targetTaskId) {
1108
+ const sourceLineage = this.getTaskLineage(sourceTaskId);
1109
+ const targetLineage = this.getTaskLineage(targetTaskId);
1110
+ if (!sourceLineage || !targetLineage) {
1111
+ throw new Error("Cannot determine collaboration task lineage");
1112
+ }
1113
+ const sourceAncestors = new Set(sourceLineage);
1114
+ for (let index = targetLineage.length - 1; index >= 0; index--) {
1115
+ const taskId = targetLineage[index];
1116
+ if (sourceAncestors.has(taskId))
1117
+ return taskId;
1118
+ }
1119
+ throw new Error("Collaboration tasks have no common ancestor");
1120
+ }
1121
+ /**
1122
+ * Persist one cross-task fact, its task-timeline projections, and the sole
1123
+ * target Delivery in one SQLite transaction. Delivery mechanics live above
1124
+ * this Store API; this method never resolves paths or applies reachability
1125
+ * policy.
1126
+ */
1127
+ createCollaborationMessage(input) {
1128
+ return this.db.transaction(() => this.createCollaborationMessageInTransaction(input))();
1129
+ }
1130
+ createCollaborationMessageInTransaction(input) {
1131
+ const createdAt = input.createdAt ?? Date.now();
1132
+ if (input.sourceTaskId === input.directTargetTaskId) {
1133
+ throw new Error("Collaboration source and target must differ");
1134
+ }
1135
+ this.requireLiveTask(input.sourceTaskId);
1136
+ this.requireLiveTask(input.directTargetTaskId);
1137
+ const lcaTaskId = this.lowestCommonAncestor(input.sourceTaskId, input.directTargetTaskId);
1138
+ this.db
1139
+ .prepare(`INSERT INTO messages
1140
+ (id, source_task_id, direct_target_task_id, source_actor, body, created_at)
1141
+ VALUES (?, ?, ?, ?, ?, ?)`)
1142
+ .run(input.id, input.sourceTaskId, input.directTargetTaskId, input.sourceActor, input.body, createdAt);
1143
+ const insertProjection = this.db.prepare(`INSERT INTO message_projections (message_id, task_id, role, created_at)
1144
+ VALUES (?, ?, ?, ?)`);
1145
+ insertProjection.run(input.id, input.sourceTaskId, "source", createdAt);
1146
+ insertProjection.run(input.id, input.directTargetTaskId, "target", createdAt);
1147
+ if (lcaTaskId !== input.sourceTaskId &&
1148
+ lcaTaskId !== input.directTargetTaskId) {
1149
+ insertProjection.run(input.id, lcaTaskId, "supervisor", createdAt);
1150
+ }
1151
+ const sourceLabel = this.getTask(input.sourceTaskId)?.title ?? input.sourceTaskId.slice(0, 8);
1152
+ const targetLabel = this.getTask(input.directTargetTaskId)?.title ??
1153
+ input.directTargetTaskId.slice(0, 8);
1154
+ const collaborationTitle = `${formatTaskReference(sourceLabel)} sent ${formatTaskReference(targetLabel)}`;
1155
+ for (const projection of this.listCollaborationProjections(input.id)) {
1156
+ this.saveEvent(projection.task_id, "system_message", {
1157
+ kind: "collaboration",
1158
+ messageId: input.id,
1159
+ sourceTaskId: input.sourceTaskId,
1160
+ sourceLabel,
1161
+ targetTaskId: input.directTargetTaskId,
1162
+ targetLabel,
1163
+ role: projection.role,
1164
+ title: collaborationTitle,
1165
+ body: input.body,
1166
+ }, { from_ref: `msg:${input.id}` });
1167
+ }
1168
+ this.db
1169
+ .prepare(`INSERT INTO deliveries
1170
+ (id, message_id, recipient_task_id, idempotency_key, status, queued_at)
1171
+ VALUES (?, ?, ?, ?, 'queued', ?)`)
1172
+ .run(input.deliveryId, input.id, input.directTargetTaskId, `${input.id}:${input.directTargetTaskId}`, createdAt);
1173
+ return {
1174
+ message: this.getCollaborationMessage(input.id),
1175
+ delivery: this.getCollaborationDelivery(input.deliveryId),
1176
+ };
1177
+ }
1178
+ /** Atomically record an Agent workflow update and its parent handoff. */
1179
+ recordAgentWorkflowUpdate(taskId, status, body) {
1180
+ return this.db.transaction(() => {
1181
+ const task = this.requireLiveTask(taskId);
1182
+ this.db
1183
+ .prepare("UPDATE tasks SET workflow_status = ? WHERE id = ?")
1184
+ .run(status, taskId);
1185
+ this.saveEvent(taskId, "task_update", { status, body }, { from_ref: "agent" });
1186
+ if (!task.parent_id) {
1187
+ return { parentTaskId: null, collaborationMessageId: null };
1188
+ }
1189
+ const collaborationMessageId = randomUUID();
1190
+ this.createCollaborationMessageInTransaction({
1191
+ id: collaborationMessageId,
1192
+ deliveryId: randomUUID(),
1193
+ sourceTaskId: taskId,
1194
+ directTargetTaskId: task.parent_id,
1195
+ sourceActor: "agent",
1196
+ body: `Task status: ${status}\n${body}`,
1197
+ });
1198
+ return {
1199
+ parentTaskId: task.parent_id,
1200
+ collaborationMessageId,
1201
+ };
1202
+ })();
1203
+ }
1204
+ getCollaborationMessage(id) {
1205
+ return this.db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
1206
+ }
1207
+ listCollaborationProjections(messageId) {
1208
+ return this.db
1209
+ .prepare(`SELECT task_id, role FROM message_projections
1210
+ WHERE message_id = ?
1211
+ ORDER BY CASE role
1212
+ WHEN 'source' THEN 0
1213
+ WHEN 'target' THEN 1
1214
+ ELSE 2
1215
+ END, task_id`)
1216
+ .all(messageId);
1217
+ }
1218
+ getCollaborationDelivery(id) {
1219
+ return this.db.prepare("SELECT * FROM deliveries WHERE id = ?").get(id);
1220
+ }
1221
+ /** Read-only queued count; lets a caller skip busy-turn churn entirely. */
1222
+ countQueuedDeliveries(recipientTaskId) {
1223
+ const row = this.db
1224
+ .prepare("SELECT COUNT(*) AS count FROM deliveries WHERE recipient_task_id = ? AND status = 'queued'")
1225
+ .get(recipientTaskId);
1226
+ return row.count;
1227
+ }
1228
+ /** Atomically claim every currently queued Delivery for one target task. */
1229
+ claimQueuedDeliveries(recipientTaskId) {
1230
+ const claimedAt = Date.now();
1231
+ return this.db.transaction(() => {
1232
+ const ids = this.db
1233
+ .prepare(`SELECT id FROM deliveries
1234
+ WHERE recipient_task_id = ? AND status = 'queued'
1235
+ ORDER BY queued_at, id`)
1236
+ .all(recipientTaskId);
1237
+ const claim = this.db.prepare(`UPDATE deliveries
1238
+ SET status = 'draining', claimed_at = ?
1239
+ WHERE id = ? AND status = 'queued'`);
1240
+ const claimed = [];
1241
+ for (const { id } of ids) {
1242
+ if (claim.run(claimedAt, id).changes !== 1)
1243
+ continue;
1244
+ const delivery = this.getCollaborationDelivery(id);
1245
+ if (delivery)
1246
+ claimed.push(delivery);
1247
+ }
1248
+ return claimed;
1249
+ })();
1250
+ }
1251
+ markCollaborationDeliveriesDelivered(ids, deliveredAt = Date.now()) {
1252
+ const mark = this.db.prepare(`UPDATE deliveries
1253
+ SET status = 'delivered', delivered_at = ?, failure_reason = NULL, failed_at = NULL
1254
+ WHERE id = ? AND status = 'draining'`);
1255
+ const tx = this.db.transaction(() => {
1256
+ for (const id of ids)
1257
+ mark.run(deliveredAt, id);
1258
+ });
1259
+ tx();
1260
+ }
1261
+ failCollaborationDeliveries(ids, failureReason, failedAt = Date.now()) {
1262
+ const fail = this.db.prepare(`UPDATE deliveries
1263
+ SET status = 'failed', failed_at = ?, failure_reason = ?
1264
+ WHERE id = ? AND status = 'draining'`);
1265
+ const tx = this.db.transaction(() => {
1266
+ for (const id of ids)
1267
+ fail.run(failedAt, failureReason, id);
1268
+ });
1269
+ tx();
1270
+ }
1271
+ failOutstandingDeliveriesForTaskClear(taskId) {
1272
+ const failedAt = Date.now();
1273
+ this.db
1274
+ .prepare(`UPDATE deliveries
1275
+ SET status = 'failed', failed_at = ?, failure_reason =
1276
+ CASE status
1277
+ WHEN 'queued' THEN 'cleared_before_delivery'
1278
+ ELSE 'cleared_during_delivery'
1279
+ END
1280
+ WHERE recipient_task_id = ? AND status IN ('queued', 'draining')`)
1281
+ .run(failedAt, taskId);
1282
+ }
1283
+ /** Preserve the fact while recording that a deleted target cannot receive it. */
1284
+ failOutstandingDeliveriesForDeletedTask(taskId) {
1285
+ this.db
1286
+ .prepare(`UPDATE deliveries
1287
+ SET status = 'failed', failed_at = ?, failure_reason = 'target_deleted'
1288
+ WHERE recipient_task_id = ? AND status IN ('queued', 'draining')`)
1289
+ .run(Date.now(), taskId);
584
1290
  }
585
1291
  // --- client-server-split M2: client_ops idempotency ---
586
1292
  /**
587
- * Look up a previously-cached response for (sessionId, clientOpId).
1293
+ * Look up a previously-cached response for (taskId, clientOpId).
588
1294
  * Returns the parsed result or null if no cached entry exists.
589
1295
  */
590
- getClientOp(sessionId, clientOpId) {
1296
+ getClientOp(taskId, clientOpId) {
591
1297
  const row = this.db
592
- .prepare("SELECT result_json FROM client_ops WHERE session_id = ? AND client_op_id = ?")
593
- .get(sessionId, clientOpId);
1298
+ .prepare("SELECT result_json FROM client_ops WHERE task_id = ? AND client_op_id = ?")
1299
+ .get(taskId, clientOpId);
594
1300
  if (!row)
595
1301
  return null;
596
1302
  try {
@@ -601,13 +1307,13 @@ export class Store {
601
1307
  }
602
1308
  }
603
1309
  /**
604
- * Cache a successful response for (sessionId, clientOpId). Uses
1310
+ * Cache a successful response for (taskId, clientOpId). Uses
605
1311
  * INSERT OR IGNORE so a concurrent winner is preserved.
606
1312
  */
607
- saveClientOp(sessionId, clientOpId, result) {
1313
+ saveClientOp(taskId, clientOpId, result) {
608
1314
  this.db
609
- .prepare("INSERT OR IGNORE INTO client_ops (session_id, client_op_id, result_json) VALUES (?, ?, ?)")
610
- .run(sessionId, clientOpId, JSON.stringify(result));
1315
+ .prepare("INSERT OR IGNORE INTO client_ops (task_id, client_op_id, result_json) VALUES (?, ?, ?)")
1316
+ .run(taskId, clientOpId, JSON.stringify(result));
611
1317
  }
612
1318
  /** Prune client_ops rows older than `maxAgeMs` (milliseconds). Returns rows deleted. */
613
1319
  pruneClientOps(maxAgeMs) {
@@ -620,55 +1326,55 @@ export class Store {
620
1326
  // ===== attachments (uploads-plan v2.6 §1.2) =====
621
1327
  /**
622
1328
  * Insert a new attachment row. upload_seq is computed as
623
- * `COALESCE(MAX(events.seq), 0)` for the session at insert time. Callers
1329
+ * `COALESCE(MAX(events.seq), 0)` for the task at insert time. Callers
624
1330
  * must have already written the file under
625
- * <data_dir>/sessions/<sid>/attachments/<id>.<ext> and resolved its
626
- * realpath. The row is bound by FK CASCADE to its session.
1331
+ * <data_dir>/tasks/<sid>/attachments/<id>.<ext> and resolved its
1332
+ * realpath. The row is bound by FK CASCADE to its task.
627
1333
  */
628
1334
  insertAttachment(input) {
629
1335
  const seqRow = this.db
630
- .prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE session_id = ?")
631
- .get(input.sessionId);
1336
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE task_id = ?")
1337
+ .get(input.taskId);
632
1338
  const uploadSeq = seqRow.s;
633
1339
  this.db
634
1340
  .prepare(`INSERT INTO attachments
635
- (id, session_id, kind, name, mime, size, realpath, upload_seq, width, height)
1341
+ (id, task_id, kind, name, mime, size, realpath, upload_seq, width, height)
636
1342
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
637
- .run(input.id, input.sessionId, input.kind, input.name, input.mime, input.size, input.realpath, uploadSeq, input.width ?? null, input.height ?? null);
1343
+ .run(input.id, input.taskId, input.kind, input.name, input.mime, input.size, input.realpath, uploadSeq, input.width ?? null, input.height ?? null);
638
1344
  return this.db
639
1345
  .prepare("SELECT * FROM attachments WHERE id = ?")
640
1346
  .get(input.id);
641
1347
  }
642
- /** Look up an attachment row by (session_id, id). */
643
- getAttachment(sessionId, id) {
1348
+ /** Look up an attachment row by (task_id, id). */
1349
+ getAttachment(taskId, id) {
644
1350
  return this.db
645
- .prepare("SELECT * FROM attachments WHERE session_id = ? AND id = ?")
646
- .get(sessionId, id);
1351
+ .prepare("SELECT * FROM attachments WHERE task_id = ? AND id = ?")
1352
+ .get(taskId, id);
647
1353
  }
648
1354
  /**
649
1355
  * For the permission interceptor: list all attachment realpaths for a
650
- * session so we can compare against `toolCall.locations[].path` after
1356
+ * task so we can compare against `toolCall.locations[].path` after
651
1357
  * realpath-ing each side. The set is small (≤ a few hundred per
652
- * session) so we hand back an in-memory array.
1358
+ * task) so we hand back an in-memory array.
653
1359
  */
654
- listAttachmentRealpaths(sessionId) {
1360
+ listAttachmentRealpaths(taskId) {
655
1361
  const rows = this.db
656
- .prepare("SELECT realpath FROM attachments WHERE session_id = ?")
657
- .all(sessionId);
1362
+ .prepare("SELECT realpath FROM attachments WHERE task_id = ?")
1363
+ .all(taskId);
658
1364
  return rows.map((r) => r.realpath);
659
1365
  }
660
1366
  /**
661
1367
  * For the egress label-rewrite (CLAUDE.md "Attachment label egress
662
1368
  * rewrite"): list each attachment's id, user-supplied name, and
663
- * realpath for a session. Caller (session-manager label cache)
1369
+ * realpath for a task. Caller (task-manager label cache)
664
1370
  * derives the label string `<name> [#<id4>]`. Pure DB read; no
665
1371
  * realpath syscalls (the stored realpath is already resolved at
666
1372
  * upload time).
667
1373
  */
668
- listAttachmentLabels(sessionId) {
1374
+ listAttachmentLabels(taskId) {
669
1375
  const rows = this.db
670
- .prepare("SELECT id, name, realpath FROM attachments WHERE session_id = ?")
671
- .all(sessionId);
1376
+ .prepare("SELECT id, name, realpath FROM attachments WHERE task_id = ?")
1377
+ .all(taskId);
672
1378
  return rows;
673
1379
  }
674
1380
  /**
@@ -676,10 +1382,10 @@ export class Store {
676
1382
  * filename portion of its URL (`<id>.<ext>`). The id is the uuid prefix
677
1383
  * of the file segment.
678
1384
  */
679
- getAttachmentByFile(sessionId, file) {
1385
+ getAttachmentByFile(taskId, file) {
680
1386
  const dot = file.indexOf(".");
681
1387
  const id = dot === -1 ? file : file.slice(0, dot);
682
- return this.getAttachment(sessionId, id);
1388
+ return this.getAttachment(taskId, id);
683
1389
  }
684
1390
  close() {
685
1391
  this.db.close();
@@ -691,22 +1397,22 @@ export class Store {
691
1397
  * §4.3 R1-c2). Returns the inserted row.
692
1398
  *
693
1399
  * May throw SQLITE_CONSTRAINT_UNIQUE on shares_one_active_preview;
694
- * callers handle via findActivePreviewBySession fallback (§4.3 R2-c2).
1400
+ * callers handle via findActivePreviewByTask fallback (§4.3 R2-c2).
695
1401
  */
696
1402
  insertSharePreview(input) {
697
1403
  this.db
698
- .prepare(`INSERT INTO shares (token, session_id, share_snapshot_seq, ttl_hours, display_name, owner_label)
1404
+ .prepare(`INSERT INTO shares (token, task_id, share_snapshot_seq, ttl_hours, display_name, owner_label)
699
1405
  VALUES (?, ?, ?, ?, ?, ?)`)
700
- .run(input.token, input.sessionId, input.snapshotSeq, input.ttlHours ?? null, input.displayName ?? null, input.ownerLabel ?? null);
1406
+ .run(input.token, input.taskId, input.snapshotSeq, input.ttlHours ?? null, input.displayName ?? null, input.ownerLabel ?? null);
701
1407
  return this.getShareByToken(input.token);
702
1408
  }
703
- /** SELECT the single un-activated preview for this session (partial unique). */
704
- findActivePreviewBySession(sessionId) {
1409
+ /** SELECT the single un-activated preview for this task (partial unique). */
1410
+ findActivePreviewByTask(taskId) {
705
1411
  return this.db
706
1412
  .prepare(`SELECT * FROM shares
707
- WHERE session_id = ? AND shared_at IS NULL
1413
+ WHERE task_id = ? AND shared_at IS NULL
708
1414
  ORDER BY created_at DESC LIMIT 1`)
709
- .get(sessionId);
1415
+ .get(taskId);
710
1416
  }
711
1417
  getShareByToken(token) {
712
1418
  return this.db
@@ -761,8 +1467,8 @@ export class Store {
761
1467
  return this.db
762
1468
  .prepare(`SELECT
763
1469
  s.token AS token,
764
- s.session_id AS session_id,
765
- sess.title AS session_title,
1470
+ s.task_id AS task_id,
1471
+ t.title AS task_title,
766
1472
  s.shared_at AS shared_at,
767
1473
  s.created_at AS created_at,
768
1474
  s.display_name AS display_name,
@@ -771,9 +1477,11 @@ export class Store {
771
1477
  s.ttl_hours AS ttl_hours,
772
1478
  s.last_accessed_at AS last_accessed_at
773
1479
  FROM shares s
774
- LEFT JOIN sessions sess ON sess.id = s.session_id
1480
+ JOIN agent_sessions a ON a.task_id = s.task_id
1481
+ LEFT JOIN tasks t ON t.id = s.task_id
1482
+ WHERE a.agent_key = ?
775
1483
  ORDER BY s.created_at DESC`)
776
- .all();
1484
+ .all(this.agentKey);
777
1485
  }
778
1486
  /**
779
1487
  * One-time write of last_accessed_at (share-plan §4.1 R2 ENG-6a +