bothouse-dsh 0.1.0 → 0.1.40

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.
package/collab/store.mjs CHANGED
@@ -10,17 +10,29 @@ export const MAX_TEXT = 128 * 1024;
10
10
  // 「默认混合模型」); the same names as the bridge's dispatch_mode config.
11
11
  export const DISPATCH_MODES = ['solo', 'team', 'mixed'];
12
12
  export const DEFAULT_DISPATCH_MODE = 'mixed';
13
- // v7 is #133 (reply_to), v8 is #139 team mode A, v9 is #139 team mode B tasks.
14
- const SCHEMA_VERSION = 10;
13
+ // v7 is #133 (reply_to), v8 is #139 team mode A, v9 is #139 team mode B tasks, v10 is #177
14
+ // (room_settings.dispatch_explicit); columns are added by ensureSchema() on every open
15
+ // (checked with PRAGMA table_info), so either merge or release order ends with every column;
16
+ // user_version only records the newest schema known.
17
+ // v11 is #148 (edited_at).
18
+ const SCHEMA_VERSION = 11;
15
19
  /** Columns added after v1, as [table, column, definition]; ensureSchema() adds any missing one. */
16
20
  const COLUMNS = [
21
+ ['members', 'name_customized', 'INTEGER'],
22
+ ['bindings', 'workspace_path', 'TEXT'], // Keep a CLI conversation in its original directory until /new. // NULL keeps existing names unchanged; only new UI members opt in.
23
+
17
24
  ['jobs', 'input_id', 'TEXT'], // v2: jobs remember the id of the input they admitted, to bind its consuming turn.
25
+ ['members', 'rooms', 'TEXT'], // NULL or [] retains legacy global visibility.
18
26
  ['members', 'cli', 'TEXT'], // v4: a member may be a local CLI agent (claude | codex | gemini) instead of a model route.
19
27
  ['members', 'avatar', 'TEXT'], // v5: a member may show an avatar (built-in slug); NULL means the initial badge.
28
+ // A local CLI member's DSH permission preset (cli-member.mjs PERMISSION_PRESETS); NULL means DSH's default.
29
+ ['members', 'permission_preset', 'TEXT'],
20
30
  // v6 (#121): a streamed reply is stored as a draft (shown, not fanned out) until commit
21
31
  // clears the flag. Durable, so a restart between commit and finishJob never fans it out twice.
22
32
  ['messages', 'draft', 'INTEGER NOT NULL DEFAULT 0'],
23
33
  ['messages', 'reply_to', 'INTEGER'], // v7 (#133): the message a user reply quotes.
34
+ // #144: JSON list of uploaded files ({id, name, mime, size}); bytes live under dataDir/attachments.
35
+ ['messages', 'attachments', 'TEXT'],
24
36
  // v8 (#139 team mode A): a room is a chat channel or a workspace under one chat channel.
25
37
  ['rooms', 'kind', "TEXT NOT NULL DEFAULT 'chat' CHECK (kind IN ('chat', 'workspace'))"],
26
38
  ['rooms', 'parent_id', 'INTEGER REFERENCES rooms(id)'],
@@ -30,12 +42,18 @@ const COLUMNS = [
30
42
  // The room's highest task id when its owner last claimed it; a claim no task followed
31
43
  // yet (the handoff is not worked on) keeps it owned when an earlier task ends (Tasks.releaseIfDone).
32
44
  ['rooms', 'claim_task', 'INTEGER'],
45
+ ['rooms', 'claim_at', 'REAL'], // A fresh claim gets time to open its handoff before idle recovery.
33
46
  // 1 = the bridge claimed it (cloudweb.mjs op claim/create): its lead is a bridge session, so
34
47
  // only the bridge's op release (lead reset) frees it, never Tasks.releaseIfDone.
35
48
  ['rooms', 'claim_bridge', 'INTEGER NOT NULL DEFAULT 0'],
36
49
  // Id of the current claim, bumped by every claim (claimWorkspace, createWorkspace): a release
37
50
  // naming an earlier claim (the bridge's late op release) is refused ('stale-claim').
38
51
  ['rooms', 'claim_seq', 'INTEGER NOT NULL DEFAULT 0'],
52
+ ['rooms', 'workspace_path', 'TEXT'], // Optional chat directory; workspace rooms inherit their current parent.
53
+ ['messages', 'edited_at', 'REAL'], // v11 (#148): when the owner last edited their own message; NULL if never.
54
+ // #597 G34: when a role last changed the row in place (a streamed draft growing, a progress card
55
+ // edited, the draft committed); NULL if never. The task watch counts it as progress (Tasks.progressed).
56
+ ['messages', 'changed_at', 'REAL'],
39
57
  ];
40
58
  /** Workspace rooms are named 工作区<n> (setting added in #139 team mode A, not user-specified). */
41
59
  export const WORKSPACE_PREFIX = '工作区';
@@ -51,6 +69,9 @@ export class Store {
51
69
  constructor(path) {
52
70
  if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
53
71
  this.db = new DatabaseSync(path);
72
+ this.closed = false;
73
+ /** fn(roomId) when a workspace's sessions are dropped for a new claim (resetSessions). */
74
+ this.sessionResets = [];
54
75
  this.db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
55
76
  let version = this.db.prepare('PRAGMA user_version').get().user_version;
56
77
  if (version > SCHEMA_VERSION) throw Error(`collab: store schema ${version} is newer than ${SCHEMA_VERSION}`);
@@ -60,25 +81,30 @@ export class Store {
60
81
  rooms: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms ORDER BY id'),
61
82
  children: this.db.prepare("SELECT id, name FROM rooms WHERE kind = 'workspace' AND parent_id = ? ORDER BY id"),
62
83
  // A new workspace is claimed with no task yet (claim_task 0): its first task is the handoff.
63
- createWorkspace: this.db.prepare(`INSERT INTO rooms (id, name, kind, parent_id, claim_task, claim_bridge)
84
+ createWorkspace: this.db.prepare(`INSERT INTO rooms (id, name, kind, parent_id, claim_task, claim_bridge, claim_at)
64
85
  SELECT MAX(${WORKSPACE_ID_MIN}, COALESCE((SELECT MAX(id) + 1 FROM rooms WHERE id >= ${WORKSPACE_ID_MIN}), ${WORKSPACE_ID_MIN})),
65
- ?, 'workspace', ?, 0, ? RETURNING id, name, kind, parent_id`),
66
- ensureRoom: this.db.prepare('INSERT INTO rooms (id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name'),
67
- insert: this.db.prepare(`INSERT INTO messages (room_id, author_kind, author, name, text, created_at, from_turn, card, client_key, draft, reply_to)
68
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`),
86
+ ?, 'workspace', ?, 0, ?, unixepoch() RETURNING id, name, kind, parent_id`),
87
+ ensureRoom: this.db.prepare('INSERT INTO rooms (id, name, workspace_path) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, workspace_path = excluded.workspace_path'),
88
+ workspacePath: this.db.prepare(`SELECT COALESCE(parent.workspace_path, room.workspace_path) AS path
89
+ FROM rooms room LEFT JOIN rooms parent ON room.kind = 'workspace' AND parent.id = room.parent_id WHERE room.id = ?`),
90
+ insert: this.db.prepare(`INSERT INTO messages (room_id, author_kind, author, name, text, created_at, from_turn, card, client_key, draft, reply_to, attachments)
91
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`),
69
92
  byKey: this.db.prepare('SELECT * FROM messages WHERE client_key = ?'),
70
93
  byId: this.db.prepare('SELECT * FROM messages WHERE id = ? AND deleted = 0'),
71
- edit: this.db.prepare('UPDATE messages SET text = ?, card = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
72
- commit: this.db.prepare('UPDATE messages SET text = ?, from_turn = ?, draft = 0 WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 AND card IS NULL AND draft = 1 RETURNING *'),
94
+ edit: this.db.prepare('UPDATE messages SET text = ?, card = ?, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
95
+ commit: this.db.prepare('UPDATE messages SET text = ?, from_turn = ?, draft = 0, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 AND card IS NULL AND draft = 1 RETURNING *'),
96
+ editUser: this.db.prepare(`UPDATE messages SET text = ?, edited_at = ? WHERE id = ? AND author_kind = 'user' AND author = ?
97
+ AND deleted = 0 AND draft = 0 AND card IS NULL RETURNING *`),
73
98
  del: this.db.prepare('UPDATE messages SET deleted = 1 WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
74
99
  before: this.db.prepare('SELECT * FROM messages WHERE room_id = ? AND id < ? AND deleted = 0 ORDER BY id DESC LIMIT ?'),
75
100
  after: this.db.prepare('SELECT * FROM messages WHERE room_id = ? AND id > ? AND deleted = 0 ORDER BY id ASC LIMIT ?'),
76
- relay: this.db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE room_id = ? AND id <= ? AND deleted = 0 AND author_kind = 'role'
101
+ relay: this.db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE room_id = ? AND id <= ? AND deleted = 0 AND author_kind = 'role' AND card IS NULL
77
102
  AND id > COALESCE((SELECT MAX(id) FROM messages WHERE room_id = ? AND id <= ? AND author_kind = 'user'), 0)`),
78
103
  binding: this.db.prepare('SELECT session_id, context_cursor FROM bindings WHERE member_id = ? AND room_id = ?'),
79
104
  // A fresh session starts reading after the room's last release (room_resets), not from its start.
80
105
  bind: this.db.prepare(`INSERT INTO bindings (member_id, room_id, session_id, context_cursor)
81
106
  VALUES (?, ?, ?, COALESCE((SELECT message_id FROM room_resets WHERE room_id = ?), 0))`),
107
+ rebind: this.db.prepare('UPDATE bindings SET session_id = ? WHERE member_id = ? AND room_id = ?'),
82
108
  cursor: this.db.prepare('UPDATE bindings SET context_cursor = MAX(context_cursor, ?) WHERE member_id = ? AND room_id = ?'),
83
109
  firstDraft: this.db.prepare(`SELECT MIN(id) AS id FROM messages WHERE room_id = ? AND id <= ? AND author <> ?
84
110
  AND draft = 1 AND deleted = 0 AND card IS NULL`),
@@ -87,19 +113,22 @@ export class Store {
87
113
  addJob: this.db.prepare(`INSERT INTO jobs (member_id, room_id, trigger_id, state, created_at)
88
114
  VALUES (?, ?, ?, 'pending', ?) ON CONFLICT(member_id, trigger_id) DO NOTHING RETURNING *`),
89
115
  job: this.db.prepare('SELECT * FROM jobs WHERE id = ?'),
116
+ jobByTrigger: this.db.prepare('SELECT * FROM jobs WHERE member_id = ? AND trigger_id = ?'),
117
+ resetJob: this.db.prepare("UPDATE jobs SET state = ?, created_at = ?, reply_id = NULL, session_id = NULL, seq_before = NULL, input_id = NULL WHERE id = ? RETURNING *"),
118
+ lastUserMessage: this.db.prepare("SELECT * FROM messages WHERE room_id = ? AND id < ? AND author_kind = 'user' AND deleted = 0 ORDER BY id DESC LIMIT 1"),
90
119
  openJobs: this.db.prepare("SELECT * FROM jobs WHERE state IN ('pending', 'sent') ORDER BY id"),
91
120
  sent: this.db.prepare("UPDATE jobs SET state = 'sent', session_id = ?, seq_before = ?, input_id = ? WHERE id = ? AND state = 'pending' RETURNING *"),
92
121
  finish: this.db.prepare("UPDATE jobs SET state = ?, reply_id = ? WHERE id = ? AND state IN ('pending', 'sent') RETURNING *"),
93
122
  createRoom: this.db.prepare("INSERT INTO rooms (name, kind) VALUES (?, 'chat') RETURNING id, name, kind, parent_id"),
94
123
  members: this.db.prepare('SELECT * FROM members WHERE removed = 0 ORDER BY created_at, id'),
95
124
  member: this.db.prepare('SELECT * FROM members WHERE id = ?'),
96
- seedMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, source, created_at, removed)
97
- VALUES (?, ?, ?, ?, ?, ?, ?, 'config', ?, 0)
125
+ seedMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, permission_preset, rooms, source, created_at, removed)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'config', ?, 0)
98
127
  ON CONFLICT(id) DO UPDATE SET name = excluded.name, provider = excluded.provider, model = excluded.model,
99
- cli = excluded.cli, persona = excluded.persona, avatar = excluded.avatar, source = 'config', removed = 0`),
100
- addMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, source, created_at, removed)
101
- VALUES (?, ?, ?, ?, ?, ?, ?, 'ui', ?, 0) RETURNING *`),
102
- updateMember: this.db.prepare(`UPDATE members SET name = ?, provider = ?, model = ?, cli = ?, persona = ?, avatar = ?
128
+ cli = excluded.cli, persona = excluded.persona, avatar = excluded.avatar, permission_preset = excluded.permission_preset, rooms = excluded.rooms, source = 'config', removed = 0`),
129
+ addMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, permission_preset, name_customized, rooms, source, created_at, removed)
130
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ui', ?, 0) RETURNING *`),
131
+ updateMember: this.db.prepare(`UPDATE members SET name = ?, provider = ?, model = ?, cli = ?, persona = ?, avatar = ?, permission_preset = ?, name_customized = ?, rooms = ?
103
132
  WHERE id = ? AND source = 'ui' AND removed = 0 RETURNING *`),
104
133
  removeMember: this.db.prepare("UPDATE members SET removed = 1 WHERE id = ? AND source = 'ui' AND removed = 0 RETURNING *"),
105
134
  };
@@ -157,6 +186,8 @@ export class Store {
157
186
  // Old mixed rows were also generated by reads, so they are not explicit choices.
158
187
  this.db.exec("UPDATE room_settings SET dispatch_explicit = 1 WHERE dispatch_mode <> 'mixed'");
159
188
  }
189
+ if (!settingColumns.some(c => c.name === 'lead_id')) this.db.exec('ALTER TABLE room_settings ADD COLUMN lead_id TEXT');
190
+ if (!settingColumns.some(c => c.name === 'workspace_path')) this.db.exec('ALTER TABLE room_settings ADD COLUMN workspace_path TEXT');
160
191
  this.db.exec('DROP TABLE IF EXISTS collab_settings');
161
192
  if (version < SCHEMA_VERSION) this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
162
193
  });
@@ -186,13 +217,27 @@ export class Store {
186
217
  updated_at REAL NOT NULL,
187
218
  returned_at REAL,
188
219
  remind_msg_id INTEGER,
189
- reason TEXT)`);
220
+ reason TEXT,
221
+ paused_at REAL,
222
+ paused_chat INTEGER,
223
+ source TEXT,
224
+ ref TEXT,
225
+ base TEXT,
226
+ card_member_id TEXT,
227
+ renewals INTEGER NOT NULL DEFAULT 0)`);
190
228
  } else {
191
229
  const cols = new Set(this.db.prepare('PRAGMA table_info(tasks)').all().map(c => c.name));
192
230
  for (const [name, type] of [['parent_task', 'INTEGER'], ['next_check_at', 'REAL'], ['result_msg_id', 'INTEGER'],
193
- ['assign_msg_id', 'INTEGER'], ['note', 'TEXT'], ['returned_at', 'REAL'], ['remind_msg_id', 'INTEGER'], ['reason', 'TEXT']]) {
231
+ ['assign_msg_id', 'INTEGER'], ['note', 'TEXT'], ['returned_at', 'REAL'], ['remind_msg_id', 'INTEGER'], ['reason', 'TEXT'], ['paused_at', 'REAL'], ['paused_chat', 'INTEGER'], ['source', 'TEXT'], ['ref', 'TEXT'], ['base', 'TEXT'], ['renewals', 'INTEGER NOT NULL DEFAULT 0'], ['card_member_id', 'TEXT']]) {
194
232
  if (!cols.has(name)) this.db.exec(`ALTER TABLE tasks ADD COLUMN ${name} ${type}`);
195
233
  }
234
+ // #574 B2 upgrade: workspace tasks in work with no check time start their 10-minute watch
235
+ // now (tasks.mjs CHECK_AFTER_S), not from their last change, so an upgrade parks none at once.
236
+ if (!cols.has('renewals')) {
237
+ this.db.prepare(`UPDATE tasks SET next_check_at = ? WHERE next_check_at IS NULL AND paused_at IS NULL
238
+ AND status IN ('assigned', 'accepted', 'asked', 'rework') AND room_id IN (SELECT id FROM rooms WHERE kind = 'workspace')`)
239
+ .run(Date.now() / 1000 + 10 * 60);
240
+ }
196
241
  }
197
242
  this.db.exec('CREATE INDEX IF NOT EXISTS tasks_room ON tasks (room_id, id)');
198
243
  }
@@ -273,7 +318,11 @@ export class Store {
273
318
  UNIQUE (member_id, trigger_id));`);
274
319
  }
275
320
 
276
- close() { this.db.close(); }
321
+ close() {
322
+ if (this.closed) return;
323
+ this.db.close();
324
+ this.closed = true;
325
+ }
277
326
 
278
327
  /** Run fn in one write transaction; a throw rolls back every statement it made. */
279
328
  transaction(fn) {
@@ -294,15 +343,26 @@ export class Store {
294
343
  * predates that range or someone configured an id inside it. Refuse loudly instead of
295
344
  * moving the workspace: bot sessions are keyed room-<id> and must never see it change.
296
345
  */
297
- ensureRoom(id, name) {
346
+ ensureRoom(id, name, workspacePath) {
298
347
  this.transaction(() => {
299
348
  if (this.q.room.get(id)?.kind === 'workspace') {
300
349
  throw new CollabError('workspace-id-conflict',
301
350
  `config room id ${id} collides with workspace room ${id}; workspace ids start at ${WORKSPACE_ID_MIN} and cannot be reassigned`);
302
351
  }
303
- this.q.ensureRoom.run(id, name);
352
+ const saved = this.db.prepare('SELECT workspace_path FROM room_settings WHERE room_id = ?').get(id)?.workspace_path;
353
+ this.q.ensureRoom.run(id, name, saved || workspacePath || null);
304
354
  });
305
355
  }
356
+ /** Rename the channel without changing any room, message or session identity. */
357
+ renameRoom(id, name) {
358
+ if (typeof name !== 'string' || !name || name.length > 32 || /\s/.test(name)) {
359
+ throw new CollabError('invalid', 'room name must be 1-32 characters without spaces');
360
+ }
361
+ const room = this.room(id);
362
+ if (room === undefined) throw new CollabError('room-missing', `room ${id} does not exist`);
363
+ if (room.kind === 'workspace') throw new CollabError('invalid', 'workspace names cannot be changed');
364
+ return this.db.prepare('UPDATE rooms SET name = ? WHERE id = ? RETURNING id, name, kind, parent_id').get(name, id);
365
+ }
306
366
  /** Create a chat channel; its first workspace is made on the first createWorkspace() call. */
307
367
  createRoom(name) {
308
368
  const room = this.transaction(() => this.q.createRoom.get(name));
@@ -326,8 +386,9 @@ export class Store {
326
386
  const num = w => Number(re.exec(w.name)?.[1] ?? 0);
327
387
  const free = all.filter(w => w.free && num(w) > 0 && this.activeTasks(w.id) === 0).sort((a, b) => num(a) - num(b))[0];
328
388
  if (free !== undefined) {
389
+ this.resetSessions(free.id);
329
390
  return this.db.prepare(`UPDATE rooms SET parent_id = ?, free = 0, claim_bridge = ?, claim_seq = claim_seq + 1,
330
- claim_task = (SELECT COALESCE(MAX(id), 0) FROM tasks WHERE room_id = ?) WHERE id = ? RETURNING id, name, kind, parent_id`)
391
+ claim_task = (SELECT COALESCE(MAX(id), 0) FROM tasks WHERE room_id = ?), claim_at = unixepoch() WHERE id = ? RETURNING id, name, kind, parent_id`)
331
392
  .get(parent, bridge ? 1 : 0, free.id, free.id);
332
393
  }
333
394
  const n = Math.max(0, ...all.map(num));
@@ -341,9 +402,11 @@ export class Store {
341
402
  * channel at a time: one another chat channel owns (not free) is refused with
342
403
  * 'workspace-owned' until it is released after its last task (releaseWorkspace).
343
404
  * chatId may be a workspace (then its chat channel is used). bridge: the claim comes from the
344
- * bridge (cloudweb.mjs), whose lead session the bridge releases itself. Returns the workspace room.
405
+ * bridge (cloudweb.mjs), whose lead session the bridge releases itself. handoff: the task this
406
+ * claim is for when it already exists (a paused task resumed, Tasks.resumeClaim), so handoffDone
407
+ * waits for that task instead of one dispatched later. Returns the workspace room.
345
408
  */
346
- claimWorkspace(chatId, workspaceId, { bridge = false } = {}) {
409
+ claimWorkspace(chatId, workspaceId, { bridge = false, handoff = null } = {}) {
347
410
  return this.transaction(() => {
348
411
  const chat = this.q.room.get(chatId);
349
412
  const ws = this.q.room.get(workspaceId);
@@ -354,9 +417,14 @@ export class Store {
354
417
  if (owner != null && owner !== parent) {
355
418
  throw new CollabError('workspace-owned', `workspace ${ws.name} belongs to room ${owner} until it finishes its last task`);
356
419
  }
420
+ // #597 G35: a released workspace keeps its sessions until a new claim. Claimed back for a task of
421
+ // its last claim (rework, a paused task resumed: Tasks.reworkClaim/resumeClaim; claim_task stays
422
+ // after the release), the members go on in the same sessions; any other claim starts fresh.
423
+ const last = this.db.prepare('SELECT claim_task FROM rooms WHERE id = ?').get(ws.id).claim_task;
424
+ if (owner == null && !(handoff != null && last != null && Number(handoff) > last)) this.resetSessions(ws.id);
357
425
  return this.db.prepare(`UPDATE rooms SET parent_id = ?, free = 0, claim_bridge = ?, claim_seq = claim_seq + 1,
358
- claim_task = (SELECT COALESCE(MAX(id), 0) FROM tasks WHERE room_id = ?) WHERE id = ? RETURNING id, name, kind, parent_id`)
359
- .get(parent, bridge ? 1 : 0, ws.id, ws.id);
426
+ claim_task = COALESCE(?, (SELECT COALESCE(MAX(id), 0) FROM tasks WHERE room_id = ?)), claim_at = unixepoch() WHERE id = ? RETURNING id, name, kind, parent_id`)
427
+ .get(parent, bridge ? 1 : 0, handoff == null ? null : Number(handoff) - 1, ws.id, ws.id);
360
428
  });
361
429
  }
362
430
 
@@ -386,16 +454,24 @@ export class Store {
386
454
  return handoff !== undefined && (handoff.status === 'returned' || handoff.status === 'done');
387
455
  }
388
456
 
389
- /** Tasks in work in roomId (same statuses as tasks.mjs WORKING). */
457
+ /**
458
+ * True while workspace roomId is held by a bridge claim: the bridge's handoff watch checks it
459
+ * (core/team_watch.go), so the task watch here leaves it alone (one watcher per handoff).
460
+ */
461
+ bridgeClaimed(roomId) {
462
+ return Boolean(this.db.prepare('SELECT claim_bridge FROM rooms WHERE id = ?').get(roomId)?.claim_bridge);
463
+ }
464
+
465
+ /** Tasks in work in roomId (same statuses as tasks.mjs WORKING; paused ones do not count). */
390
466
  activeTasks(roomId) {
391
467
  return this.db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE room_id = ?
392
- AND status IN ('assigned', 'accepted', 'asked', 'rework')`).get(roomId).n;
468
+ AND status IN ('assigned', 'accepted', 'asked', 'rework') AND paused_at IS NULL`).get(roomId).n;
393
469
  }
394
470
 
395
471
  /**
396
472
  * Release workspace roomId after its last task: it goes back to the pool (free) for any chat
397
- * channel, and its sessions are cleared (bindings dropped; fresh ones start reading after the
398
- * room's last message, room_resets). Refused with 'busy' while tasks are in work there.
473
+ * channel. Its sessions stay until a new claim takes it (resetSessions), so a rework or a resumed
474
+ * task goes on in them (#597 G35). Refused with 'busy' while tasks are in work there.
399
475
  * Returns false if roomId is no owned workspace (already free, or a chat channel). claim (from
400
476
  * the bridge's op release): the claim being ended; refused with 'stale-claim' once the
401
477
  * workspace was claimed again, so a late release never frees the new claim. null = any claim.
@@ -409,15 +485,28 @@ export class Store {
409
485
  throw new CollabError('stale-claim', `workspace ${ws.name} was claimed again since claim ${claim}`);
410
486
  }
411
487
  if (this.activeTasks(ws.id) > 0) throw new CollabError('busy', `workspace ${ws.name} has tasks in work`);
412
- this.db.prepare('UPDATE rooms SET free = 1, claim_task = NULL, claim_bridge = 0 WHERE id = ?').run(ws.id);
413
- this.db.prepare('DELETE FROM bindings WHERE room_id = ?').run(ws.id);
414
- const last = this.db.prepare('SELECT MAX(id) AS id FROM messages WHERE room_id = ?').get(ws.id).id ?? 0;
415
- this.db.prepare(`INSERT INTO room_resets (room_id, message_id) VALUES (?, ?)
416
- ON CONFLICT (room_id) DO UPDATE SET message_id = excluded.message_id`).run(ws.id, last);
488
+ // claim_task stays: it tells a claim back for this claim's own task from a new one (claimWorkspace).
489
+ this.db.prepare('UPDATE rooms SET free = 1, claim_bridge = 0 WHERE id = ?').run(ws.id);
417
490
  return true;
418
491
  });
419
492
  }
420
493
 
494
+ /** Register fn(roomId), called when a workspace's sessions are dropped (resetSessions). */
495
+ onSessionsReset(fn) { this.sessionResets.push(fn); }
496
+
497
+ /**
498
+ * A free workspace taken by a new claim starts over: its sessions are cleared (bindings dropped;
499
+ * fresh ones start reading after the room's last message, room_resets) and the listeners drop
500
+ * what they keep per conversation (dsh-member resetRoom).
501
+ */
502
+ resetSessions(roomId) {
503
+ this.db.prepare('DELETE FROM bindings WHERE room_id = ?').run(roomId);
504
+ const last = this.db.prepare('SELECT MAX(id) AS id FROM messages WHERE room_id = ?').get(roomId).id ?? 0;
505
+ this.db.prepare(`INSERT INTO room_resets (room_id, message_id) VALUES (?, ?)
506
+ ON CONFLICT (room_id) DO UPDATE SET message_id = excluded.message_id`).run(roomId, last);
507
+ for (const fn of this.sessionResets) { try { fn(roomId); } catch { /* in-memory only: the store is already reset */ } }
508
+ }
509
+
421
510
  ensureRoomSettings(roomId) {
422
511
  this.db.prepare('INSERT OR IGNORE INTO room_settings (room_id) VALUES (?)').run(roomId);
423
512
  }
@@ -451,25 +540,69 @@ export class Store {
451
540
  return mode;
452
541
  }
453
542
 
543
+ /** Keep legacy members visible in all channels, including future ones. */
544
+ migrateMemberRooms() {
545
+ return this.db.prepare("UPDATE members SET rooms = '[]' WHERE rooms IS NULL").run().changes > 0;
546
+ }
547
+
454
548
  /** Active DSH members, config seeds and UI-created alike. */
455
549
  members() { return this.q.members.all(); }
456
550
  member(id) { return this.q.member.get(id); }
457
551
  /** Upsert a config member; config always wins for its own ids. */
458
552
  seedMember(m, now = Date.now() / 1000) {
459
- this.q.seedMember.run(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, now);
553
+ this.q.seedMember.run(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, JSON.stringify(m.rooms ?? []), now);
460
554
  }
461
555
  addMember(m, now = Date.now() / 1000) {
462
- return this.q.addMember.get(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, now);
556
+ return this.q.addMember.get(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, m.nameCustomized == null ? null : Number(m.nameCustomized), JSON.stringify(m.rooms ?? []), now);
463
557
  }
464
558
  /** Edit a UI-created member; config members and removed ones are refused (undefined). */
465
559
  updateMember(id, m) {
466
- return this.q.updateMember.get(m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, id);
560
+ return this.q.updateMember.get(m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, m.nameCustomized == null ? null : Number(m.nameCustomized), JSON.stringify(m.rooms ?? []), id);
467
561
  }
468
562
  removeMember(id) { return this.q.removeMember.get(id); }
563
+ leadId(roomId) {
564
+ return this.db.prepare('SELECT lead_id FROM room_settings WHERE room_id = ?').get(this.settingsRoom(roomId))?.lead_id ?? null;
565
+ }
566
+ setLead(roomId, id) {
567
+ roomId = this.settingsRoom(roomId);
568
+ this.ensureRoomSettings(roomId);
569
+ this.db.prepare('UPDATE room_settings SET lead_id = ? WHERE room_id = ?').run(id, roomId);
570
+ }
571
+ bindingWorkspacePath(memberId, roomId, fallback) {
572
+ return this.db.prepare('SELECT workspace_path FROM bindings WHERE member_id = ? AND room_id = ?').get(memberId, roomId)?.workspace_path || fallback;
573
+ }
574
+ setBindingWorkspacePath(memberId, roomId, path) {
575
+ this.db.prepare('UPDATE bindings SET workspace_path = ? WHERE member_id = ? AND room_id = ?').run(path, memberId, roomId);
576
+ }
577
+ setWorkspacePath(roomId, path, fallback) {
578
+ roomId = this.settingsRoom(roomId);
579
+ const previous = this.workspacePath(roomId, fallback);
580
+ if (previous) this.db.prepare(`UPDATE bindings SET workspace_path = ? WHERE workspace_path IS NULL AND
581
+ (room_id = ? OR room_id IN (SELECT id FROM rooms WHERE parent_id = ?))`).run(previous, roomId, roomId);
582
+ this.ensureRoomSettings(roomId);
583
+ this.db.prepare('UPDATE room_settings SET workspace_path = ? WHERE room_id = ?').run(path, roomId);
584
+ this.db.prepare('UPDATE rooms SET workspace_path = ? WHERE id = ?').run(path, roomId);
585
+ }
586
+ workspacePath(roomId, fallback) {
587
+ const saved = this.db.prepare('SELECT workspace_path FROM room_settings WHERE room_id = ?').get(this.settingsRoom(roomId))?.workspace_path;
588
+ return saved || this.q.workspacePath.get(roomId)?.path || fallback;
589
+ }
469
590
  room(id) { return this.q.room.get(id); }
470
591
  rooms() { return this.q.rooms.all(); }
592
+ /** Return stale, empty claims to the pool without taking bridge or paused work. */
593
+ releaseIdleWorkspaces() {
594
+ const idle = this.db.prepare(`SELECT id FROM rooms WHERE kind = 'workspace' AND free = 0
595
+ AND claim_bridge = 0 AND claim_task > 0
596
+ AND (claim_at IS NULL OR claim_at < unixepoch() - 1800)
597
+ AND NOT EXISTS (SELECT 1 FROM tasks WHERE room_id = rooms.id
598
+ AND status IN ('assigned', 'accepted', 'asked', 'rework'))`).all();
599
+ for (const room of idle) this.releaseWorkspace(room.id);
600
+ return idle.length;
601
+ }
602
+
471
603
  /** Rooms for clients: each carries kind, parent_id and its open task count. */
472
604
  roomsView() {
605
+ this.releaseIdleWorkspaces();
473
606
  const counts = new Map(this.db.prepare(`SELECT room_id, COUNT(*) AS n FROM tasks
474
607
  WHERE status IN ('assigned', 'accepted', 'asked', 'rework') GROUP BY room_id`).all().map(t => [t.room_id, t.n]));
475
608
  return this.rooms().map(r => ({ ...r, active: counts.get(r.id) ?? 0, ...this.dispatchSetting(r.id) }));
@@ -482,7 +615,8 @@ export class Store {
482
615
  append(roomId, m, now = Date.now() / 1000) {
483
616
  if (this.room(roomId) === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
484
617
  const text = String(m.text ?? '');
485
- if (!text.trim() && m.card == null) throw new CollabError('empty', 'message text is empty');
618
+ const attachments = Array.isArray(m.attachments) && m.attachments.length ? m.attachments : null;
619
+ if (!text.trim() && m.card == null && attachments === null) throw new CollabError('empty', 'message text is empty');
486
620
  if (text.length > MAX_TEXT) throw new CollabError('too-long', `message exceeds ${MAX_TEXT} characters`);
487
621
  if (m.clientKey != null) {
488
622
  const existing = this.q.byKey.get(m.clientKey);
@@ -496,7 +630,8 @@ export class Store {
496
630
  replyTo = ref.id;
497
631
  }
498
632
  const row = this.q.insert.get(roomId, m.authorKind, m.author, m.name, text, now,
499
- m.fromTurn == null ? null : (m.fromTurn ? 1 : 0), m.card ?? null, m.clientKey ?? null, m.draft ? 1 : 0, replyTo);
633
+ m.fromTurn == null ? null : (m.fromTurn ? 1 : 0), m.card ?? null, m.clientKey ?? null, m.draft ? 1 : 0, replyTo,
634
+ attachments === null ? null : JSON.stringify(attachments));
500
635
  return { message: row, created: true };
501
636
  }
502
637
 
@@ -516,25 +651,41 @@ export class Store {
516
651
  return { id: ref.id, author_kind: ref.author_kind, author: ref.author, name: ref.name, text };
517
652
  }
518
653
  /** Edit or delete only a row the same author wrote; returns the row or undefined. */
519
- edit(id, authorKind, author, text, card) { return this.q.edit.get(text, card ?? null, id, authorKind, author); }
654
+ /** #144: {id, name, mime, size} of a stored attachment, or undefined when no live message carries it. */
655
+ attachmentMeta(id) {
656
+ const row = this.db.prepare("SELECT attachments FROM messages WHERE deleted = 0 AND attachments LIKE ? LIMIT 1").get(`%"${id}"%`);
657
+ return messageAttachments(row).find(a => a.id === id);
658
+ }
659
+
660
+ edit(id, authorKind, author, text, card, now = Date.now() / 1000) { return this.q.edit.get(text, card ?? null, now, id, authorKind, author); }
520
661
  /**
521
662
  * Finish a streamed draft row with its complete text (#121). Returns
522
663
  * { message, first }: first is true only when this call turned the draft
523
664
  * into a reply (the caller fans out once). A row already committed gets the
524
665
  * text and first = false. undefined if it is not that author's live text row.
525
666
  */
526
- commit(id, authorKind, author, text, fromTurn) {
667
+ commit(id, authorKind, author, text, fromTurn, now = Date.now() / 1000) {
527
668
  if (!text.trim() || text.length > MAX_TEXT) return undefined;
528
- const row = this.q.commit.get(text, fromTurn == null ? null : (fromTurn ? 1 : 0), id, authorKind, author);
669
+ const row = this.q.commit.get(text, fromTurn == null ? null : (fromTurn ? 1 : 0), now, id, authorKind, author);
529
670
  if (row !== undefined) return { message: row, first: true };
530
671
  const old = this.message(id);
531
672
  if (old === undefined || old.card != null) return undefined;
532
- const again = this.edit(id, authorKind, author, text);
673
+ const again = this.edit(id, authorKind, author, text, undefined, now);
533
674
  return again === undefined ? undefined : { message: again, first: false };
534
675
  }
676
+ /** #148: a person edits their own plain message; returns the row, or undefined if it is not theirs. */
677
+ editUser(id, author, text, now = Date.now() / 1000) {
678
+ text = String(text ?? '');
679
+ if (!text.trim()) throw new CollabError('empty', 'message text is empty');
680
+ if (text.length > MAX_TEXT) throw new CollabError('too-long', `message exceeds ${MAX_TEXT} characters`);
681
+ return this.q.editUser.get(text, now, id, author);
682
+ }
535
683
  remove(id, authorKind, author) { return this.q.del.get(id, authorKind, author); }
536
684
  /** One author's live text drafts (#121), oldest first. */
537
- drafts(authorKind, author) { return this.q.authorDrafts.all(authorKind, author); }
685
+ drafts(authorKind, author) {
686
+ // WebSocket close events may arrive after plugin teardown has closed SQLite.
687
+ return this.closed ? [] : this.q.authorDrafts.all(authorKind, author);
688
+ }
538
689
 
539
690
  /** Up to `limit` messages before `beforeId` (exclusive), oldest first. */
540
691
  page(roomId, beforeId, limit) {
@@ -549,6 +700,8 @@ export class Store {
549
700
 
550
701
  binding(memberId, roomId) { return this.q.binding.get(memberId, roomId); }
551
702
  bind(memberId, roomId, sessionId) { this.q.bind.run(memberId, roomId, sessionId, roomId); }
703
+ /** Point an existing binding at a new Session; the context cursor stays. */
704
+ rebind(memberId, roomId, sessionId) { this.q.rebind.run(sessionId, memberId, roomId); }
552
705
  /**
553
706
  * Move a member's context cursor up to messageId, but never past a
554
707
  * teammate's uncommitted text draft (#121): it keeps its old id when it
@@ -563,6 +716,19 @@ export class Store {
563
716
  addJob(memberId, roomId, triggerId, now = Date.now() / 1000) {
564
717
  return this.q.addJob.get(memberId, roomId, triggerId, now);
565
718
  }
719
+ jobByTrigger(memberId, triggerId) {
720
+ return this.q.jobByTrigger.get(memberId, triggerId);
721
+ }
722
+ retryJob(memberId, roomId, triggerId, now = Date.now() / 1000) {
723
+ const existing = this.jobByTrigger(memberId, triggerId);
724
+ if (existing !== undefined) {
725
+ return this.q.resetJob.get('pending', now, existing.id);
726
+ }
727
+ return this.addJob(memberId, roomId, triggerId, now);
728
+ }
729
+ lastUserMessage(roomId, beforeId) {
730
+ return this.q.lastUserMessage.get(roomId, beforeId);
731
+ }
566
732
  job(id) { return this.q.job.get(id); }
567
733
  openJobs() { return this.q.openJobs.all(); }
568
734
  /** Record admission before the prompt reaches the Agent; false if already moved on. */
@@ -587,12 +753,21 @@ export function publicMessage(row, store) {
587
753
  const out = {
588
754
  id: row.id, room_id: row.room_id, author_kind: row.author_kind, author: row.author, name: row.name,
589
755
  text: row.text, created_at: row.created_at, card: row.card, from_turn: row.from_turn,
756
+ ...(row.edited_at != null ? { edited_at: row.edited_at } : {}),
590
757
  };
591
758
  const quote = store?.quote(row);
592
759
  if (quote !== undefined) out.reply_to = quote;
760
+ const attachments = messageAttachments(row);
761
+ if (attachments.length) out.attachments = attachments;
593
762
  return out;
594
763
  }
595
764
 
765
+ /** #144: the uploaded files a message carries ([] when none or unreadable). */
766
+ export function messageAttachments(row) {
767
+ if (!row?.attachments) return [];
768
+ try { const list = JSON.parse(row.attachments); return Array.isArray(list) ? list : []; } catch { return []; }
769
+ }
770
+
596
771
  /** Discord-style quote prefix for agent prompts: `[回复 作者:前40字…] ` (discord.go:1645-1650). */
597
772
  export function quotePrefix(quote) {
598
773
  if (quote === undefined) return '';