@lelouchhe/webagent 0.9.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 (47) hide show
  1. package/README.md +39 -14
  2. package/config.toml +7 -27
  3. package/dist/index.html +4 -4
  4. package/dist/js/app.INIQQEGD.js +5 -0
  5. package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
  6. package/dist/js/viewer.RHZMFYWJ.js +1 -0
  7. package/dist/login.html +1 -1
  8. package/dist/share-viewer.html +5 -5
  9. package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
  10. package/dist/sw.js +6 -6
  11. package/lib/attachment-dispatch.js +60 -31
  12. package/lib/attachment-interceptor.js +7 -7
  13. package/lib/attachment-labels.js +1 -1
  14. package/lib/attachments.js +25 -0
  15. package/lib/auth-middleware.js +2 -2
  16. package/lib/auth.js +2 -2
  17. package/lib/bridge.js +109 -83
  18. package/lib/client-registry.js +12 -12
  19. package/lib/config.js +2 -31
  20. package/lib/event-handler.js +143 -90
  21. package/lib/files/routes.js +1 -1
  22. package/lib/mcp/capability.js +74 -0
  23. package/lib/mcp/server.js +148 -0
  24. package/lib/mcp/task-history.js +245 -0
  25. package/lib/mcp/task-host.js +253 -0
  26. package/lib/mcp/tools.js +168 -0
  27. package/lib/mode-bucket.js +1 -1
  28. package/lib/push-service.js +33 -35
  29. package/lib/routes.js +947 -489
  30. package/lib/server.js +64 -16
  31. package/lib/share/routes.js +88 -88
  32. package/lib/shared/task-reference.js +20 -0
  33. package/lib/sse-manager.js +8 -8
  34. package/lib/store.js +941 -314
  35. package/lib/task-collaboration.js +15 -0
  36. package/lib/task-manager.js +1409 -0
  37. package/lib/task-path.js +131 -0
  38. package/lib/{session-state.js → task-state.js} +64 -41
  39. package/lib/task-tree-lock.js +74 -0
  40. package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
  41. package/lib/tokens.js +1 -1
  42. package/lib/types.js +2 -2
  43. package/package.json +7 -1
  44. package/dist/js/app.QC7IRDTP.js +0 -5
  45. package/dist/js/viewer.GP5VXAUY.js +0 -1
  46. package/lib/session-manager.js +0 -638
  47. package/lib/title-service.js +0 -95
package/lib/store.js CHANGED
@@ -1,55 +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;
55
+ dataDir;
12
56
  agentKey;
13
57
  constructor(dataDir, agentKey) {
14
58
  if (!agentKey)
15
59
  throw new Error("agentKey is required");
16
60
  this.agentKey = agentKey;
61
+ this.dataDir = dataDir;
17
62
  mkdirSync(dataDir, { recursive: true });
18
63
  this.db = new Database(join(dataDir, "webagent.db"));
19
- this.db.pragma("journal_mode = WAL");
20
- this.migrate();
21
- // Enforce foreign keys *after* migrate() so the one-time orphan cleanup
22
- // can run without pragma interfering with legacy cleanup queries.
23
- 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
+ }
24
189
  }
25
- migrate() {
190
+ initializeSchema() {
26
191
  this.db.exec(`
27
- CREATE TABLE IF NOT EXISTS sessions (
192
+ CREATE TABLE IF NOT EXISTS tasks (
28
193
  id TEXT PRIMARY KEY,
29
194
  cwd TEXT NOT NULL,
30
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,
31
206
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
32
207
  last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
33
208
  );
34
209
  CREATE TABLE IF NOT EXISTS agent_sessions (
35
210
  agent_key TEXT NOT NULL,
36
211
  agent_session_id TEXT NOT NULL,
37
- web_session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
212
+ task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
38
213
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
39
214
  PRIMARY KEY (agent_key, agent_session_id)
40
215
  );
41
- CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sessions_web
42
- ON agent_sessions(web_session_id)
43
- WHERE web_session_id IS NOT NULL;
216
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sessions_task
217
+ ON agent_sessions(task_id)
218
+ WHERE task_id IS NOT NULL;
44
219
  CREATE TABLE IF NOT EXISTS events (
45
220
  id INTEGER PRIMARY KEY AUTOINCREMENT,
46
- session_id TEXT NOT NULL REFERENCES sessions(id),
221
+ task_id TEXT NOT NULL REFERENCES tasks(id),
47
222
  seq INTEGER NOT NULL,
48
223
  type TEXT NOT NULL,
49
224
  data TEXT NOT NULL DEFAULT '{}',
50
- 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
51
227
  );
52
- 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);
53
229
  CREATE TABLE IF NOT EXISTS push_subscriptions (
54
230
  id INTEGER PRIMARY KEY AUTOINCREMENT,
55
231
  endpoint TEXT NOT NULL UNIQUE,
@@ -58,54 +234,13 @@ export class Store {
58
234
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
59
235
  );
60
236
  `);
61
- // Migrate existing tables: add columns if missing
62
- const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
63
- const colNames = new Set(cols.map((c) => c.name));
64
- if (!colNames.has("title")) {
65
- this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
66
- }
67
- if (!colNames.has("last_active_at")) {
68
- this.db.exec("ALTER TABLE sessions ADD COLUMN last_active_at TEXT");
69
- // Backfill from created_at
70
- this.db.exec("UPDATE sessions SET last_active_at = created_at WHERE last_active_at IS NULL");
71
- }
72
- if (!colNames.has("model")) {
73
- this.db.exec("ALTER TABLE sessions ADD COLUMN model TEXT");
74
- }
75
- if (!colNames.has("mode")) {
76
- this.db.exec("ALTER TABLE sessions ADD COLUMN mode TEXT");
77
- }
78
- if (!colNames.has("reasoning_effort")) {
79
- this.db.exec("ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT");
80
- }
81
- if (!colNames.has("source")) {
82
- this.db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'auto'");
83
- }
84
- if (!colNames.has("deleted_at")) {
85
- this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
86
- }
87
- // One-time dual-ID migration. Existing WebAgent session IDs were also the
88
- // ACP agent's IDs, so preserve the public IDs and record that identity
89
- // mapping under the agent command active during the upgrade.
90
- this.db
91
- .prepare(`
92
- INSERT INTO agent_sessions (
93
- agent_key, agent_session_id, web_session_id, created_at
94
- )
95
- SELECT ?, s.id, s.id, s.created_at
96
- FROM sessions s
97
- WHERE NOT EXISTS (
98
- SELECT 1 FROM agent_sessions a WHERE a.web_session_id = s.id
99
- )
100
- `)
101
- .run(this.agentKey);
102
- // messages — pending unbound notifications. POST /api/v1/messages with
237
+ // inbox_messages pending unbound notifications. POST /api/v1/messages with
103
238
  // `to = "user"` lands here; consumeMessageTx transactionally moves the
104
- // content into an existing ACP-backed session's events and deletes the
105
- // row. Bound messages (to = session id) skip this table entirely and go
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
106
241
  // straight to `events`.
107
242
  this.db.exec(`
108
- CREATE TABLE IF NOT EXISTS messages (
243
+ CREATE TABLE IF NOT EXISTS inbox_messages (
109
244
  id TEXT PRIMARY KEY,
110
245
  from_ref TEXT NOT NULL,
111
246
  from_label TEXT,
@@ -117,85 +252,85 @@ export class Store {
117
252
  cwd TEXT,
118
253
  created_at INTEGER NOT NULL
119
254
  );
120
- CREATE INDEX IF NOT EXISTS idx_messages_created ON messages (created_at);
121
- 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);
122
295
  `);
123
296
  // client-server-split M2: idempotency for mutating REST calls. Stores
124
- // 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
125
298
  // network/SSE reconnect) return the same result instead of re-executing
126
299
  // side effects.
127
300
  this.db.exec(`
128
301
  CREATE TABLE IF NOT EXISTS client_ops (
129
- session_id TEXT NOT NULL,
302
+ task_id TEXT NOT NULL,
130
303
  client_op_id TEXT NOT NULL,
131
304
  result_json TEXT NOT NULL,
132
305
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
133
- PRIMARY KEY (session_id, client_op_id)
306
+ PRIMARY KEY (task_id, client_op_id)
134
307
  );
135
308
  `);
136
309
  // recent_paths: LRU path list for /new menu
137
- const rpExists = this.db
138
- .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='recent_paths'")
139
- .get();
140
- if (!rpExists) {
141
- this.db.exec(`
142
- CREATE TABLE recent_paths (
143
- cwd TEXT PRIMARY KEY,
144
- last_used_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
145
- );
146
- `);
147
- // Backfill from existing sessions
148
- this.db.exec(`
149
- INSERT OR IGNORE INTO recent_paths (cwd, last_used_at)
150
- SELECT cwd, MAX(COALESCE(last_active_at, created_at))
151
- FROM sessions GROUP BY cwd;
152
- `);
153
- }
154
- // events.from_ref — origin marker for every event row.
155
- // Values: 'user' | 'system' | 'agent' | 'msg:<id>'. The 'msg:<id>'
156
- // form is reserved for events authored by consuming an inbox message
157
- // (see C7+). Bucketed backfill runs once for legacy rows.
158
- const eventCols = this.db
159
- .prepare("PRAGMA table_info(events)")
160
- .all();
161
- const eventColNames = new Set(eventCols.map((c) => c.name));
162
- if (!eventColNames.has("from_ref")) {
163
- this.db.exec("ALTER TABLE events ADD COLUMN from_ref TEXT");
164
- // Buckets:
165
- // user — user-authored input
166
- // system — client-originated side-channel actions + host responses
167
- // (permission responses, local bash, system messages)
168
- // agent — everything else (assistant_message, thinking, tool_call,
169
- // tool_call_update, plan, prompt_done, permission_request,
170
- // etc.)
171
- this.db.exec(`
172
- UPDATE events SET from_ref = CASE
173
- WHEN type = 'user_message' THEN 'user'
174
- WHEN type IN ('permission_response', 'bash_command', 'bash_result',
175
- 'system_message') THEN 'system'
176
- ELSE 'agent'
177
- END
178
- WHERE from_ref IS NULL
179
- `);
180
- }
181
- // One-time orphan cleanup: rows whose session_id no longer exists in
182
- // `sessions`. Pre-FK writes could leave these behind (a session DELETE
183
- // that didn't cascade because the FK pragma was off). Must run before
184
- // enabling FK pragma.
185
- this.db.exec("DELETE FROM events WHERE session_id NOT IN (SELECT id FROM sessions)");
186
- // Secondary index for events queried by (session_id, type, created_at)
187
- // -- used by upcoming inbox/message consume queries.
188
- 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)");
189
324
  // shares — public read-only share links (share-plan §4.1).
190
325
  // State machine: preview (shared_at NULL) → active (shared_at set).
191
326
  // Revocation = hard-delete the row (no audit trail kept).
192
- // Multiple active siblings per session allowed (v4 multi-share).
327
+ // Multiple active siblings per task allowed (v4 multi-share).
193
328
  // Partial unique index enforces at most one un-activated preview per
194
- // session at any time.
329
+ // task at any time.
195
330
  this.db.exec(`
196
331
  CREATE TABLE IF NOT EXISTS shares (
197
332
  token TEXT PRIMARY KEY,
198
- session_id TEXT NOT NULL REFERENCES sessions(id),
333
+ task_id TEXT NOT NULL REFERENCES tasks(id),
199
334
  shared_at INTEGER,
200
335
  share_snapshot_seq INTEGER NOT NULL,
201
336
  ttl_hours INTEGER,
@@ -204,32 +339,17 @@ export class Store {
204
339
  created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000),
205
340
  last_accessed_at INTEGER
206
341
  );
207
- 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);
208
343
  `);
209
- // Migrate: drop revoked_at column from existing tables (v0.5+).
210
- // Revocation is now hard-delete; kept rows are always live.
211
- const shareCols = this.db
212
- .prepare("PRAGMA table_info(shares)")
213
- .all();
214
- const shareColNames = new Set(shareCols.map((c) => c.name));
215
- if (shareColNames.has("revoked_at")) {
216
- // Hard-delete any pre-existing revoked rows so the migration
217
- // doesn't resurrect them as "live" shares after dropping the column.
218
- this.db.exec("DELETE FROM shares WHERE revoked_at IS NOT NULL");
219
- // Drop the partial unique index that references revoked_at, then
220
- // the column, then recreate the index without the revoked_at clause.
221
- this.db.exec("DROP INDEX IF EXISTS shares_one_active_preview");
222
- this.db.exec("ALTER TABLE shares DROP COLUMN revoked_at");
223
- }
224
344
  this.db.exec(`
225
345
  CREATE UNIQUE INDEX IF NOT EXISTS shares_one_active_preview
226
- ON shares(session_id)
346
+ ON shares(task_id)
227
347
  WHERE shared_at IS NULL;
228
348
  `);
229
- // attachments — server-managed file uploads bound to a session.
230
- // Lifecycle = session lifecycle: FK CASCADE removes the row when the
231
- // session row is deleted (hard-delete path). Tombstoned (soft-deleted)
232
- // 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
233
353
  // file references for active shares.
234
354
  //
235
355
  // upload_seq = MAX(events.seq) at upload time. The share proxy uses
@@ -242,7 +362,7 @@ export class Store {
242
362
  this.db.exec(`
243
363
  CREATE TABLE IF NOT EXISTS attachments (
244
364
  id TEXT PRIMARY KEY,
245
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
365
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
246
366
  kind TEXT NOT NULL,
247
367
  name TEXT NOT NULL,
248
368
  mime TEXT NOT NULL,
@@ -253,18 +373,8 @@ export class Store {
253
373
  height INTEGER,
254
374
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
255
375
  );
256
- 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);
257
377
  `);
258
- const attachmentCols = this.db
259
- .prepare("PRAGMA table_info(attachments)")
260
- .all();
261
- const attachmentColNames = new Set(attachmentCols.map((c) => c.name));
262
- if (!attachmentColNames.has("width")) {
263
- this.db.exec("ALTER TABLE attachments ADD COLUMN width INTEGER");
264
- }
265
- if (!attachmentColNames.has("height")) {
266
- this.db.exec("ALTER TABLE attachments ADD COLUMN height INTEGER");
267
- }
268
378
  // owner_prefs — key-value store for owner-scoped defaults (display_name,
269
379
  // last /by selection, etc). Single-user model = single owner scope.
270
380
  // Stored as plain key/value so we don't grow a new table per pref.
@@ -276,168 +386,479 @@ export class Store {
276
386
  );
277
387
  `);
278
388
  }
279
- createSession(id, cwd, source = "auto", agentSessionId = 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
+ })();
435
+ }
436
+ createTask(id, cwd, source = "auto", agentSessionId = id, parentId = null, opts = {}) {
280
437
  return this.db.transaction(() => {
281
438
  this.db
282
- .prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
283
- .run(id, cwd, source);
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");
284
443
  this.db
285
- .prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, web_session_id) VALUES (?, ?, ?)")
444
+ .prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, ?)")
286
445
  .run(this.agentKey, agentSessionId, id);
446
+ if (opts.initialMessage) {
447
+ this.createCollaborationMessage({
448
+ ...opts.initialMessage,
449
+ directTargetTaskId: id,
450
+ });
451
+ }
287
452
  return this.db
288
- .prepare("SELECT * FROM sessions WHERE id = ?")
453
+ .prepare("SELECT * FROM tasks WHERE id = ?")
289
454
  .get(id);
290
455
  })();
291
456
  }
292
- listSessions(opts) {
457
+ listTasks(opts) {
293
458
  if (opts?.source) {
294
459
  return this.db
295
- .prepare(`SELECT s.* FROM sessions s
296
- JOIN agent_sessions a ON a.web_session_id = s.id
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
297
471
  WHERE a.agent_key = ? AND s.source = ? AND s.deleted_at IS NULL
298
472
  ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
299
473
  .all(this.agentKey, opts.source);
300
474
  }
301
475
  return this.db
302
- .prepare(`SELECT s.* FROM sessions s
303
- JOIN agent_sessions a ON a.web_session_id = s.id
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
304
487
  WHERE a.agent_key = ? AND s.deleted_at IS NULL
305
488
  ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
306
489
  .all(this.agentKey);
307
490
  }
308
- /** Returns live sessions only. Soft-deleted (tombstone) rows are hidden. */
309
- getSession(id) {
491
+ /** Returns live tasks only. Soft-deleted (tombstone) rows are hidden. */
492
+ getTask(id) {
310
493
  return this.db
311
- .prepare(`SELECT s.* FROM sessions s
312
- JOIN agent_sessions a ON a.web_session_id = s.id
494
+ .prepare(`SELECT s.* FROM tasks s
495
+ JOIN agent_sessions a ON a.task_id = s.id
313
496
  WHERE s.id = ? AND a.agent_key = ? AND s.deleted_at IS NULL`)
314
497
  .get(id, this.agentKey);
315
498
  }
316
499
  registerInternalAgentSession(agentSessionId) {
317
500
  this.db
318
- .prepare("INSERT OR IGNORE INTO agent_sessions (agent_key, agent_session_id, web_session_id) VALUES (?, ?, NULL)")
501
+ .prepare("INSERT OR IGNORE INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, NULL)")
319
502
  .run(this.agentKey, agentSessionId);
320
503
  const row = this.db
321
504
  .prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ?")
322
505
  .get(this.agentKey, agentSessionId);
323
- if (row.web_session_id) {
324
- throw new Error("Agent reused a user-visible session ID internally");
506
+ if (row.task_id) {
507
+ throw new Error("Agent reused a user-visible task ID internally");
325
508
  }
326
509
  return row;
327
510
  }
328
- getAgentSessionId(webSessionId) {
511
+ getAgentSessionId(taskId) {
329
512
  return this.db
330
- .prepare("SELECT agent_session_id FROM agent_sessions WHERE agent_key = ? AND web_session_id = ?")
331
- .get(this.agentKey, webSessionId)?.agent_session_id;
513
+ .prepare("SELECT agent_session_id FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
514
+ .get(this.agentKey, taskId)?.agent_session_id;
332
515
  }
333
- getWebSessionId(agentSessionId) {
516
+ getTaskId(agentSessionId) {
334
517
  return this.db
335
- .prepare("SELECT web_session_id FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ? AND web_session_id IS NOT NULL")
336
- .get(this.agentKey, agentSessionId)?.web_session_id;
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;
337
520
  }
338
- getAgentSessionBinding(webSessionId) {
521
+ getAgentSessionBinding(taskId) {
339
522
  return this.db
340
- .prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND web_session_id = ?")
341
- .get(this.agentKey, webSessionId);
523
+ .prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
524
+ .get(this.agentKey, taskId);
342
525
  }
343
- ownsSession(webSessionId) {
344
- return this.getAgentSessionBinding(webSessionId) !== undefined;
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;
345
575
  }
346
576
  /**
347
- * 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
348
578
  * viewer, which must keep working after the owner deletes the source
349
- * 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.
610
+ */
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);
631
+ }
632
+ /**
633
+ * Every descendant of a task, transitively (used to gate destructive
634
+ * operations such as the DELETE busy check against in-flight children).
350
635
  */
351
- getSessionIncludingDeleted(id) {
352
- return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
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;
353
687
  }
354
688
  /**
355
- * Delete a session. If any active (published) shares reference it, the
356
- * session row + events are kept (soft-delete via deleted_at) so the
357
- * shared snapshot remains viewable. Otherwise everything is hard-
358
- * deleted. Preview shares (shared_at IS NULL) are always cleared:
359
- * unpublished drafts share the session's lifecycle.
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.
360
696
  *
361
- * Returns "hard" if the row + events were physically removed, "soft"
362
- * if the row was tombstoned because shares still reference it. Callers
363
- * 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.
364
700
  */
365
- 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);
366
713
  // Drop preview shares regardless — they are owner-only drafts and
367
- // share the session's lifecycle by design.
714
+ // share the task's lifecycle by design.
368
715
  this.db
369
- .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")
370
717
  .run(id);
371
718
  const activeShareCount = this.db
372
- .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")
373
720
  .get(id).n;
374
- 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);
375
722
  if (activeShareCount > 0) {
376
- // Soft-delete: keep events + sessions row so public share viewers
377
- // can still resolve. revokeShare() / reapTombstoneIfOrphaned()
378
- // 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.
379
727
  this.db
380
- .prepare("UPDATE sessions SET deleted_at = ? WHERE id = ?")
728
+ .prepare("UPDATE tasks SET deleted_at = ? WHERE id = ?")
381
729
  .run(Date.now(), id);
382
- 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 };
383
741
  }
384
- this.db.prepare("DELETE FROM events WHERE session_id = ?").run(id);
385
- this.db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
386
- 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 };
387
754
  }
388
755
  /**
389
- * Hard-delete events + sessions row for a session that has been soft-
390
- * 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
391
758
  * is still live (deleted_at IS NULL) or still has active shares.
392
759
  * Returns true if a tombstone was reaped.
393
760
  */
394
- reapTombstoneIfOrphaned(sessionId) {
395
- const sess = this.db
396
- .prepare("SELECT id FROM sessions WHERE id = ? AND deleted_at IS NOT NULL")
397
- .get(sessionId);
398
- 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)
399
766
  return false;
400
767
  const remaining = this.db
401
- .prepare("SELECT COUNT(*) AS n FROM shares WHERE session_id = ?")
402
- .get(sessionId).n;
768
+ .prepare("SELECT COUNT(*) AS n FROM shares WHERE task_id = ?")
769
+ .get(taskId).n;
403
770
  if (remaining > 0)
404
771
  return false;
405
- this.db.prepare("DELETE FROM events WHERE session_id = ?").run(sessionId);
406
- 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);
407
779
  return true;
408
780
  }
409
- /** Delete sessions that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
410
- deleteEmptySessions(minAgeS) {
781
+ /** Delete tasks that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
782
+ deleteEmptyTasks(minAgeS) {
411
783
  const empties = this.db
412
784
  .prepare(`
413
- SELECT s.id FROM sessions s
414
- JOIN agent_sessions a ON a.web_session_id = s.id
415
- 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
416
788
  WHERE e.id IS NULL
789
+ AND s.id != ?
417
790
  AND a.agent_key = ?
418
791
  AND s.deleted_at IS NULL
419
792
  AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
420
793
  `)
421
- .all(this.agentKey, minAgeS);
794
+ .all(ROOT_TASK_ID, this.agentKey, minAgeS);
422
795
  if (empties.length === 0)
423
796
  return [];
424
- const del = this.db.prepare("DELETE FROM sessions WHERE id = ?");
425
- 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);
426
804
  del.run(r.id);
427
- return empties.map((r) => r.id);
805
+ removed.push({ id: r.id, agentSessionId: r.agent_session_id });
806
+ }
807
+ return removed;
808
+ }
809
+ updateTaskTitle(id, title) {
810
+ this.db.prepare("UPDATE tasks SET title = ? WHERE id = ?").run(title, id);
428
811
  }
429
- updateSessionTitle(id, title) {
812
+ updateTaskWorkflowStatus(id, status) {
430
813
  this.db
431
- .prepare("UPDATE sessions SET title = ? WHERE id = ?")
432
- .run(title, id);
814
+ .prepare("UPDATE tasks SET workflow_status = ? WHERE id = ?")
815
+ .run(status, id);
433
816
  }
434
- updateSessionLastActive(id) {
817
+ updateTaskLastActive(id) {
435
818
  this.db
436
- .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 = ?")
437
820
  .run(id);
438
821
  }
439
- /** Update a config option value (model, mode, reasoning_effort) for a session. */
440
- 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) {
441
862
  const column = {
442
863
  model: "model",
443
864
  mode: "mode",
@@ -447,32 +868,37 @@ export class Store {
447
868
  if (!column)
448
869
  return;
449
870
  this.db
450
- .prepare(`UPDATE sessions SET ${column} = ? WHERE id = ?`)
871
+ .prepare(`UPDATE tasks SET ${column} = ? WHERE id = ?`)
451
872
  .run(value, id);
452
873
  }
453
- saveEvent(sessionId, type, data = {}, opts) {
874
+ saveEvent(taskId, type, data = {}, opts) {
454
875
  const seq = this.db
455
- .prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE session_id = ?")
456
- .get(sessionId).next;
876
+ .prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE task_id = ?")
877
+ .get(taskId).next;
457
878
  // Origin marker is required. Every writer must pass an explicit value;
458
879
  // missing/empty fails loudly so a forgotten retrofit can't silently
459
880
  // mis-bucket a row in production. Valid values:
460
881
  // 'user' | 'system' | 'agent' | 'msg:<id>'.
461
882
  const fromRef = opts?.from_ref;
462
883
  if (!fromRef) {
463
- 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>' }`);
464
885
  }
465
886
  this.db
466
- .prepare("INSERT INTO events (session_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
467
- .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);
889
+ return this.db
890
+ .prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
891
+ .get(taskId, seq);
892
+ }
893
+ getEvent(taskId, seq) {
468
894
  return this.db
469
- .prepare("SELECT * FROM events WHERE session_id = ? AND seq = ?")
470
- .get(sessionId, seq);
895
+ .prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
896
+ .get(taskId, seq);
471
897
  }
472
- getEvents(sessionId, opts) {
898
+ getEvents(taskId, opts) {
473
899
  const hasLimit = opts?.limit != null && opts.limit > 0;
474
- const conditions = ["session_id = ?"];
475
- const params = [sessionId];
900
+ const conditions = ["task_id = ?"];
901
+ const params = [taskId];
476
902
  if (opts?.afterSeq != null) {
477
903
  conditions.push("seq > ?");
478
904
  params.push(opts.afterSeq);
@@ -484,6 +910,11 @@ export class Store {
484
910
  if (opts?.excludeThinking) {
485
911
  conditions.push("type != 'thinking'");
486
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
+ }
487
918
  const where = conditions.join(" AND ");
488
919
  if (hasLimit) {
489
920
  // Fetch the last N matching rows: subquery orders DESC with LIMIT,
@@ -496,37 +927,37 @@ export class Store {
496
927
  .prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq`)
497
928
  .all(...params);
498
929
  }
499
- getEventCount(sessionId, opts) {
500
- let query = "SELECT COUNT(*) as count FROM events WHERE session_id = ?";
501
- const params = [sessionId];
930
+ getEventCount(taskId, opts) {
931
+ let query = "SELECT COUNT(*) as count FROM events WHERE task_id = ?";
932
+ const params = [taskId];
502
933
  if (opts?.excludeThinking) {
503
934
  query += " AND type != 'thinking'";
504
935
  }
505
936
  return this.db.prepare(query).get(...params).count;
506
937
  }
507
- /** Highest seq of any stored event for this session (0 when empty). */
508
- getLastEventSeq(sessionId) {
938
+ /** Highest seq of any stored event for this task (0 when empty). */
939
+ getLastEventSeq(taskId) {
509
940
  const row = this.db
510
- .prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE session_id = ?")
511
- .get(sessionId);
941
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE task_id = ?")
942
+ .get(taskId);
512
943
  return row.seq;
513
944
  }
514
945
  /** Check if the most recent agent turn lacks a completion or error terminal event. */
515
- hasInterruptedTurn(sessionId) {
946
+ hasInterruptedTurn(taskId) {
516
947
  const row = this.db
517
948
  .prepare(`
518
949
  SELECT 1 FROM events
519
- WHERE session_id = ? AND type = 'user_message'
950
+ WHERE task_id = ? AND type = 'user_message'
520
951
  AND seq > COALESCE(
521
952
  (
522
953
  SELECT MAX(seq) FROM events
523
- WHERE session_id = ? AND type IN ('prompt_done', 'error')
954
+ WHERE task_id = ? AND type IN ('prompt_done', 'error')
524
955
  ),
525
956
  0
526
957
  )
527
958
  LIMIT 1
528
959
  `)
529
- .get(sessionId, sessionId);
960
+ .get(taskId, taskId);
530
961
  return Boolean(row);
531
962
  }
532
963
  // --- Push subscriptions ---
@@ -575,30 +1006,34 @@ export class Store {
575
1006
  deleteRecentPath(cwd) {
576
1007
  this.db.prepare("DELETE FROM recent_paths WHERE cwd = ?").run(cwd);
577
1008
  }
578
- // ===== messages (pending unbound notifications) =====
1009
+ // ===== inbox messages (pending unbound notifications) =====
579
1010
  createMessage(input) {
580
1011
  this.db
581
- .prepare(`INSERT INTO messages
1012
+ .prepare(`INSERT INTO inbox_messages
582
1013
  (id, from_ref, from_label, to_ref, deliver, dedup_key, title, body, cwd, created_at)
583
1014
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
584
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);
585
1016
  }
586
1017
  getMessage(id) {
587
- 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);
588
1021
  }
589
1022
  listUnprocessed() {
590
1023
  return this.db
591
- .prepare("SELECT * FROM messages ORDER BY created_at DESC")
1024
+ .prepare("SELECT * FROM inbox_messages ORDER BY created_at DESC")
592
1025
  .all();
593
1026
  }
594
1027
  countUnprocessed() {
595
1028
  const row = this.db
596
- .prepare("SELECT COUNT(*) AS count FROM messages")
1029
+ .prepare("SELECT COUNT(*) AS count FROM inbox_messages")
597
1030
  .get();
598
1031
  return row.count;
599
1032
  }
600
1033
  deleteMessage(id) {
601
- 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);
602
1037
  return info.changes;
603
1038
  }
604
1039
  /**
@@ -607,7 +1042,7 @@ export class Store {
607
1042
  */
608
1043
  deleteOlderThan(thresholdMs) {
609
1044
  const info = this.db
610
- .prepare("DELETE FROM messages WHERE created_at < ?")
1045
+ .prepare("DELETE FROM inbox_messages WHERE created_at < ?")
611
1046
  .run(thresholdMs);
612
1047
  return info.changes;
613
1048
  }
@@ -616,25 +1051,25 @@ export class Store {
616
1051
  if (!dedup_key)
617
1052
  return undefined;
618
1053
  return this.db
619
- .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")
620
1055
  .get(to_ref, dedup_key);
621
1056
  }
622
1057
  /**
623
- * Atomically move a pending message into an existing session. Session
1058
+ * Atomically move a pending message into an existing task. Task
624
1059
  * lifecycle belongs to SessionManager because ACP creation is asynchronous
625
1060
  * and cannot participate in this SQLite transaction.
626
1061
  */
627
- consumeMessageTx(messageId, sessionId) {
628
- const existing = this.findConsumedMessageSession(messageId);
1062
+ consumeMessageTx(messageId, taskId) {
1063
+ const existing = this.findConsumedMessageTask(messageId);
629
1064
  if (existing) {
630
- return { sessionId: existing, alreadyConsumed: true };
1065
+ return { taskId: existing, alreadyConsumed: true };
631
1066
  }
632
1067
  const row = this.getMessage(messageId);
633
1068
  if (!row) {
634
1069
  throw new MessageNotFoundError(messageId);
635
1070
  }
636
1071
  const tx = this.db.transaction(() => {
637
- this.saveEvent(sessionId, "message", {
1072
+ this.saveEvent(taskId, "message", {
638
1073
  message_id: row.id,
639
1074
  from_ref: row.from_ref,
640
1075
  from_label: row.from_label,
@@ -643,33 +1078,225 @@ export class Store {
643
1078
  cwd: row.cwd,
644
1079
  }, { from_ref: row.from_ref });
645
1080
  const del = this.db
646
- .prepare("DELETE FROM messages WHERE id = ?")
1081
+ .prepare("DELETE FROM inbox_messages WHERE id = ?")
647
1082
  .run(messageId);
648
1083
  if (del.changes === 0) {
649
1084
  throw new MessageNotFoundError(messageId);
650
1085
  }
651
1086
  });
652
1087
  tx();
653
- return { sessionId, alreadyConsumed: false };
1088
+ return { taskId, alreadyConsumed: false };
654
1089
  }
655
- findConsumedMessageSession(messageId) {
1090
+ findConsumedMessageTask(messageId) {
656
1091
  const row = this.db
657
- .prepare(`SELECT session_id FROM events
1092
+ .prepare(`SELECT task_id FROM events
658
1093
  WHERE type = 'message'
659
1094
  AND json_extract(data, '$.message_id') = ?
660
1095
  LIMIT 1`)
661
1096
  .get(messageId);
662
- 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);
663
1290
  }
664
1291
  // --- client-server-split M2: client_ops idempotency ---
665
1292
  /**
666
- * Look up a previously-cached response for (sessionId, clientOpId).
1293
+ * Look up a previously-cached response for (taskId, clientOpId).
667
1294
  * Returns the parsed result or null if no cached entry exists.
668
1295
  */
669
- getClientOp(sessionId, clientOpId) {
1296
+ getClientOp(taskId, clientOpId) {
670
1297
  const row = this.db
671
- .prepare("SELECT result_json FROM client_ops WHERE session_id = ? AND client_op_id = ?")
672
- .get(sessionId, clientOpId);
1298
+ .prepare("SELECT result_json FROM client_ops WHERE task_id = ? AND client_op_id = ?")
1299
+ .get(taskId, clientOpId);
673
1300
  if (!row)
674
1301
  return null;
675
1302
  try {
@@ -680,13 +1307,13 @@ export class Store {
680
1307
  }
681
1308
  }
682
1309
  /**
683
- * Cache a successful response for (sessionId, clientOpId). Uses
1310
+ * Cache a successful response for (taskId, clientOpId). Uses
684
1311
  * INSERT OR IGNORE so a concurrent winner is preserved.
685
1312
  */
686
- saveClientOp(sessionId, clientOpId, result) {
1313
+ saveClientOp(taskId, clientOpId, result) {
687
1314
  this.db
688
- .prepare("INSERT OR IGNORE INTO client_ops (session_id, client_op_id, result_json) VALUES (?, ?, ?)")
689
- .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));
690
1317
  }
691
1318
  /** Prune client_ops rows older than `maxAgeMs` (milliseconds). Returns rows deleted. */
692
1319
  pruneClientOps(maxAgeMs) {
@@ -699,55 +1326,55 @@ export class Store {
699
1326
  // ===== attachments (uploads-plan v2.6 §1.2) =====
700
1327
  /**
701
1328
  * Insert a new attachment row. upload_seq is computed as
702
- * `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
703
1330
  * must have already written the file under
704
- * <data_dir>/sessions/<sid>/attachments/<id>.<ext> and resolved its
705
- * 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.
706
1333
  */
707
1334
  insertAttachment(input) {
708
1335
  const seqRow = this.db
709
- .prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE session_id = ?")
710
- .get(input.sessionId);
1336
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE task_id = ?")
1337
+ .get(input.taskId);
711
1338
  const uploadSeq = seqRow.s;
712
1339
  this.db
713
1340
  .prepare(`INSERT INTO attachments
714
- (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)
715
1342
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
716
- .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);
717
1344
  return this.db
718
1345
  .prepare("SELECT * FROM attachments WHERE id = ?")
719
1346
  .get(input.id);
720
1347
  }
721
- /** Look up an attachment row by (session_id, id). */
722
- getAttachment(sessionId, id) {
1348
+ /** Look up an attachment row by (task_id, id). */
1349
+ getAttachment(taskId, id) {
723
1350
  return this.db
724
- .prepare("SELECT * FROM attachments WHERE session_id = ? AND id = ?")
725
- .get(sessionId, id);
1351
+ .prepare("SELECT * FROM attachments WHERE task_id = ? AND id = ?")
1352
+ .get(taskId, id);
726
1353
  }
727
1354
  /**
728
1355
  * For the permission interceptor: list all attachment realpaths for a
729
- * session so we can compare against `toolCall.locations[].path` after
1356
+ * task so we can compare against `toolCall.locations[].path` after
730
1357
  * realpath-ing each side. The set is small (≤ a few hundred per
731
- * session) so we hand back an in-memory array.
1358
+ * task) so we hand back an in-memory array.
732
1359
  */
733
- listAttachmentRealpaths(sessionId) {
1360
+ listAttachmentRealpaths(taskId) {
734
1361
  const rows = this.db
735
- .prepare("SELECT realpath FROM attachments WHERE session_id = ?")
736
- .all(sessionId);
1362
+ .prepare("SELECT realpath FROM attachments WHERE task_id = ?")
1363
+ .all(taskId);
737
1364
  return rows.map((r) => r.realpath);
738
1365
  }
739
1366
  /**
740
1367
  * For the egress label-rewrite (CLAUDE.md "Attachment label egress
741
1368
  * rewrite"): list each attachment's id, user-supplied name, and
742
- * realpath for a session. Caller (session-manager label cache)
1369
+ * realpath for a task. Caller (task-manager label cache)
743
1370
  * derives the label string `<name> [#<id4>]`. Pure DB read; no
744
1371
  * realpath syscalls (the stored realpath is already resolved at
745
1372
  * upload time).
746
1373
  */
747
- listAttachmentLabels(sessionId) {
1374
+ listAttachmentLabels(taskId) {
748
1375
  const rows = this.db
749
- .prepare("SELECT id, name, realpath FROM attachments WHERE session_id = ?")
750
- .all(sessionId);
1376
+ .prepare("SELECT id, name, realpath FROM attachments WHERE task_id = ?")
1377
+ .all(taskId);
751
1378
  return rows;
752
1379
  }
753
1380
  /**
@@ -755,10 +1382,10 @@ export class Store {
755
1382
  * filename portion of its URL (`<id>.<ext>`). The id is the uuid prefix
756
1383
  * of the file segment.
757
1384
  */
758
- getAttachmentByFile(sessionId, file) {
1385
+ getAttachmentByFile(taskId, file) {
759
1386
  const dot = file.indexOf(".");
760
1387
  const id = dot === -1 ? file : file.slice(0, dot);
761
- return this.getAttachment(sessionId, id);
1388
+ return this.getAttachment(taskId, id);
762
1389
  }
763
1390
  close() {
764
1391
  this.db.close();
@@ -770,22 +1397,22 @@ export class Store {
770
1397
  * §4.3 R1-c2). Returns the inserted row.
771
1398
  *
772
1399
  * May throw SQLITE_CONSTRAINT_UNIQUE on shares_one_active_preview;
773
- * callers handle via findActivePreviewBySession fallback (§4.3 R2-c2).
1400
+ * callers handle via findActivePreviewByTask fallback (§4.3 R2-c2).
774
1401
  */
775
1402
  insertSharePreview(input) {
776
1403
  this.db
777
- .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)
778
1405
  VALUES (?, ?, ?, ?, ?, ?)`)
779
- .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);
780
1407
  return this.getShareByToken(input.token);
781
1408
  }
782
- /** SELECT the single un-activated preview for this session (partial unique). */
783
- findActivePreviewBySession(sessionId) {
1409
+ /** SELECT the single un-activated preview for this task (partial unique). */
1410
+ findActivePreviewByTask(taskId) {
784
1411
  return this.db
785
1412
  .prepare(`SELECT * FROM shares
786
- WHERE session_id = ? AND shared_at IS NULL
1413
+ WHERE task_id = ? AND shared_at IS NULL
787
1414
  ORDER BY created_at DESC LIMIT 1`)
788
- .get(sessionId);
1415
+ .get(taskId);
789
1416
  }
790
1417
  getShareByToken(token) {
791
1418
  return this.db
@@ -840,8 +1467,8 @@ export class Store {
840
1467
  return this.db
841
1468
  .prepare(`SELECT
842
1469
  s.token AS token,
843
- s.session_id AS session_id,
844
- sess.title AS session_title,
1470
+ s.task_id AS task_id,
1471
+ t.title AS task_title,
845
1472
  s.shared_at AS shared_at,
846
1473
  s.created_at AS created_at,
847
1474
  s.display_name AS display_name,
@@ -850,8 +1477,8 @@ export class Store {
850
1477
  s.ttl_hours AS ttl_hours,
851
1478
  s.last_accessed_at AS last_accessed_at
852
1479
  FROM shares s
853
- JOIN agent_sessions a ON a.web_session_id = s.session_id
854
- 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
855
1482
  WHERE a.agent_key = ?
856
1483
  ORDER BY s.created_at DESC`)
857
1484
  .all(this.agentKey);