@oar-sh/oar 0.9.10 → 0.9.11

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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,444 @@
1
+ 'use strict';
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ import { compareGroupMembers } from '../public/app/conversation-group-order.mjs';
6
+
7
+ // Session groups (migration 0014): the set of conversations that share a
8
+ // `group_id`, with a row in `conversation_groups` for what the set itself
9
+ // needs: a name given by hand, and the head (the session that created the
10
+ // others with the remote_relay tool; NULL for a group made by hand or once
11
+ // the head is deleted).
12
+ //
13
+ // A session an agent creates on this relay joins its creator's group at
14
+ // bootstrap (`attachCreatedConversation`); the origin stays the record of who
15
+ // created it and is not read again for the grouping. Everything else is done
16
+ // by hand in the sidebar. Every reader tolerates a schema without the
17
+ // migration: it then sees no groups and writes nothing.
18
+ //
19
+ // A group can have a hand order (migration 0015): once the owner drags a row
20
+ // into place, every member after the head gets a `group_position` (10, 20,
21
+ // 30, ...) and keeps it; later arrivals join at the end. A group nobody
22
+ // reordered keeps NULL positions and the activity order. Without the column
23
+ // the hand order is off and moves only change the membership.
24
+
25
+ const POSITION_STEP = 10;
26
+
27
+ export const GROUP_NAME_MAX = 60;
28
+
29
+ function toText(value) {
30
+ return String(value ?? '').trim();
31
+ }
32
+
33
+ /** The name a group shows: its own, else the head's current title, else "Group". */
34
+ export function resolveGroupDisplayName({ name = null, headTitle = null } = {}) {
35
+ return toText(name) || toText(headTitle) || 'Group';
36
+ }
37
+
38
+ /** A group name as stored: trimmed, capped, NULL when empty. */
39
+ export function normalizeGroupName(value) {
40
+ const text = toText(value).replace(/\s+/g, ' ');
41
+ return text ? text.slice(0, GROUP_NAME_MAX) : null;
42
+ }
43
+
44
+ function rowToGroup(row) {
45
+ if (!row) return null;
46
+ return {
47
+ id: row.id,
48
+ name: row.name || null,
49
+ parentConversationId: row.parent_conversation_id || null,
50
+ createdAt: row.created_at,
51
+ updatedAt: row.updated_at,
52
+ };
53
+ }
54
+
55
+ export function createConversationGroupRepository(db) {
56
+ const hasTable = !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'conversation_groups'`).get();
57
+ const hasColumn = db.prepare(`PRAGMA table_info(conversations)`).all().some((column) => column.name === 'group_id');
58
+ const enabled = hasTable && hasColumn;
59
+ const hasPosition = enabled && db.prepare(`PRAGMA table_info(conversations)`).all().some((column) => column.name === 'group_position');
60
+ if (!enabled) {
61
+ return {
62
+ enabled: false,
63
+ getGroup: () => null,
64
+ listGroupsByIds: () => new Map(),
65
+ groupOfConversation: () => null,
66
+ membersOf: () => [],
67
+ createGroup: () => null,
68
+ renameGroup: () => null,
69
+ setMembership: () => ({ ok: false, error: 'groups-unavailable' }),
70
+ moveConversation: () => ({ ok: false, error: 'groups-unavailable' }),
71
+ attachCreatedConversation: () => null,
72
+ dissolveGroup: () => false,
73
+ forgetConversation: () => null,
74
+ carryToCompacted: () => null,
75
+ deleteIfEmpty: () => false,
76
+ sweepEmpty: () => 0,
77
+ repair: () => ({ headsDetached: 0, groupsRemoved: 0 }),
78
+ freezeNamesForHeads: () => 0,
79
+ membershipsOf: () => ({}),
80
+ describeGroup: () => null,
81
+ orderOf: () => [],
82
+ positionsOf: () => ({}),
83
+ };
84
+ }
85
+
86
+ const getGroup = db.prepare(`SELECT * FROM conversation_groups WHERE id = ?`);
87
+ const insertGroup = db.prepare(`INSERT INTO conversation_groups (id, name, parent_conversation_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
88
+ const updateName = db.prepare(`UPDATE conversation_groups SET name = ?, updated_at = ? WHERE id = ?`);
89
+ const updateParent = db.prepare(`UPDATE conversation_groups SET parent_conversation_id = ?, updated_at = ? WHERE id = ?`);
90
+ // A frozen head title keeps to the name cap, so a later rename is never
91
+ // refused for a length it did not choose.
92
+ const freezeName = db.prepare(`UPDATE conversation_groups SET name = COALESCE(name, substr(?, 1, ${GROUP_NAME_MAX})), parent_conversation_id = NULL, updated_at = ? WHERE id = ?`);
93
+ const freezeNameKeepHead = db.prepare(`UPDATE conversation_groups SET name = COALESCE(name, substr(?, 1, ${GROUP_NAME_MAX})), updated_at = ? WHERE id = ? AND name IS NULL`);
94
+ const findGroupHeadedBy = db.prepare(`SELECT id FROM conversation_groups WHERE parent_conversation_id = ? ORDER BY created_at ASC LIMIT 1`);
95
+ const listGroupsWithHead = db.prepare(`
96
+ SELECT g.id, g.parent_conversation_id, c.title, c.status
97
+ FROM conversation_groups g LEFT JOIN conversations c ON c.id = g.parent_conversation_id
98
+ WHERE g.parent_conversation_id IS NOT NULL
99
+ `);
100
+ const deleteGroup = db.prepare(`DELETE FROM conversation_groups WHERE id = ?`);
101
+ const positionSelect = hasPosition ? 'group_position' : 'NULL AS group_position';
102
+ const getConversation = db.prepare(`SELECT id, title, archived, status, group_id, ${positionSelect}, updated_at FROM conversations WHERE id = ?`);
103
+ // Every change of group sets the position too: NULL unless given.
104
+ const setGroupStatement = db.prepare(hasPosition
105
+ ? `UPDATE conversations SET group_id = ?, group_position = ? WHERE id = ?`
106
+ : `UPDATE conversations SET group_id = ? WHERE id = ?`);
107
+ const setGroup = {
108
+ run: (groupId, conversationId, position = null) => (hasPosition
109
+ ? setGroupStatement.run(groupId, position, conversationId)
110
+ : setGroupStatement.run(groupId, conversationId)),
111
+ };
112
+ const setPosition = hasPosition ? db.prepare(`UPDATE conversations SET group_position = ? WHERE id = ?`) : null;
113
+ const clearGroup = db.prepare(hasPosition
114
+ ? `UPDATE conversations SET group_id = NULL, group_position = NULL WHERE group_id = ?`
115
+ : `UPDATE conversations SET group_id = NULL WHERE group_id = ?`);
116
+ const listMembersByTime = db.prepare(`
117
+ SELECT id, title, archived, group_id, ${positionSelect}, updated_at FROM conversations
118
+ WHERE group_id = ? AND status != 'deleted'
119
+ ORDER BY updated_at DESC
120
+ `);
121
+ const maxPosition = hasPosition
122
+ ? db.prepare(`SELECT MAX(group_position) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`)
123
+ : null;
124
+ const countMembers = db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`);
125
+ const listEmptyGroups = db.prepare(`
126
+ SELECT g.id FROM conversation_groups g
127
+ WHERE NOT EXISTS (SELECT 1 FROM conversations c WHERE c.group_id = g.id AND c.status != 'deleted')
128
+ `);
129
+
130
+ /** The group's rows in display order: the head, then the members (see compareGroupMembers). */
131
+ const listMembers = {
132
+ all(groupId) {
133
+ const id = toText(groupId);
134
+ const headId = toText(getGroup.get(id)?.parent_conversation_id);
135
+ const rows = listMembersByTime.all(id);
136
+ const head = headId ? rows.find((row) => row.id === headId) || null : null;
137
+ const members = rows.filter((row) => row !== head).sort(compareGroupMembers);
138
+ return head ? [head, ...members] : members;
139
+ },
140
+ };
141
+
142
+ const positionValue = (row) => (row?.group_position == null ? null : Number(row.group_position));
143
+
144
+ /** Where a row joining `groupId` goes: after the last placed member, or NULL when nobody ordered the group. */
145
+ function joiningPosition(groupId) {
146
+ if (!hasPosition) return null;
147
+ const max = maxPosition.get(toText(groupId))?.n;
148
+ return max == null ? null : Number(max) + POSITION_STEP;
149
+ }
150
+
151
+ const liveConversation = (id) => {
152
+ const row = getConversation.get(toText(id));
153
+ return row && String(row.status || '') !== 'deleted' ? row : null;
154
+ };
155
+
156
+ function deleteIfEmpty(groupId) {
157
+ const id = toText(groupId);
158
+ if (!id) return false;
159
+ if (Number(countMembers.get(id)?.n || 0) > 0) return false;
160
+ return deleteGroup.run(id).changes > 0;
161
+ }
162
+
163
+ /**
164
+ * The conversation leaves its group. A head that leaves makes its group
165
+ * headless: the name freezes to the head's title unless one was given by
166
+ * hand. An empty group is removed. Returns the previous group id.
167
+ */
168
+ const leaveGroup = db.transaction((conversationId, nowIso, { anyStatus = false } = {}) => {
169
+ // A row already marked deleted (the delete path marks before it removes)
170
+ // still leaves its group the same way.
171
+ const row = anyStatus ? getConversation.get(toText(conversationId)) : liveConversation(conversationId);
172
+ const previous = toText(row?.group_id);
173
+ if (!row || !previous) return null;
174
+ const group = getGroup.get(previous);
175
+ if (group && toText(group.parent_conversation_id) === row.id) freezeName.run(row.title || null, nowIso, previous);
176
+ setGroup.run(null, row.id);
177
+ deleteIfEmpty(previous);
178
+ return previous;
179
+ });
180
+
181
+ const createGroup = db.transaction(({ name = null, parentConversationId = null, conversationIds = [] } = {}, nowIso = new Date().toISOString()) => {
182
+ const id = `grp_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
183
+ const head = liveConversation(parentConversationId);
184
+ insertGroup.run(id, normalizeGroupName(name), head ? head.id : null, nowIso, nowIso);
185
+ const members = new Set([...(head ? [head.id] : []), ...conversationIds.map(toText).filter(Boolean)]);
186
+ for (const memberId of members) {
187
+ if (!liveConversation(memberId)) continue;
188
+ leaveGroup(memberId, nowIso);
189
+ setGroup.run(id, memberId);
190
+ }
191
+ return rowToGroup(getGroup.get(id));
192
+ });
193
+
194
+ const setMembership = db.transaction((conversationId, groupId, nowIso = new Date().toISOString()) => {
195
+ const row = liveConversation(conversationId);
196
+ if (!row) return { ok: false, error: 'conversation-not-found' };
197
+ const target = toText(groupId);
198
+ if (target && !getGroup.get(target)) return { ok: false, error: 'group-not-found' };
199
+ if (toText(row.group_id) === target) return { ok: true, changed: false, groupId: target || null, previousGroupId: target || null };
200
+ const previousGroupId = leaveGroup(row.id, nowIso);
201
+ if (target) setGroup.run(target, row.id, joiningPosition(target));
202
+ return { ok: true, changed: true, groupId: target || null, previousGroupId };
203
+ });
204
+
205
+ /** The ids of a group in display order, head first. */
206
+ function orderOf(groupId) {
207
+ return listMembers.all(groupId).map((member) => member.id);
208
+ }
209
+
210
+ /**
211
+ * A drag in the sidebar: the conversation goes into `groupId` (null: out of
212
+ * its group) and lands before `beforeConversationId`, or at the end when
213
+ * that is null or not a member there. The group then has a hand order:
214
+ * every member after the head is numbered 10, 20, 30, ... in the order
215
+ * that results. The head stays first and unnumbered; moving it within its
216
+ * own group, or dropping a row before it, never displaces it.
217
+ */
218
+ const moveConversation = db.transaction((conversationId, { groupId = null, beforeConversationId = null } = {}, nowIso = new Date().toISOString()) => {
219
+ const row = liveConversation(conversationId);
220
+ if (!row) return { ok: false, error: 'conversation-not-found' };
221
+ const target = toText(groupId);
222
+ const group = target ? getGroup.get(target) : null;
223
+ if (target && !group) return { ok: false, error: 'group-not-found' };
224
+ const current = toText(row.group_id);
225
+ if (!target) {
226
+ if (!current) return { ok: true, changed: false, groupId: null, previousGroupId: null, order: [] };
227
+ const previousGroupId = leaveGroup(row.id, nowIso);
228
+ return { ok: true, changed: true, groupId: null, previousGroupId, order: [] };
229
+ }
230
+ const headId = toText(group.parent_conversation_id);
231
+ if (current === target && row.id === headId) {
232
+ return { ok: true, changed: false, groupId: target, previousGroupId: target, order: orderOf(target) };
233
+ }
234
+ let changed = false;
235
+ let previousGroupId = current || null;
236
+ if (current !== target) {
237
+ previousGroupId = leaveGroup(row.id, nowIso);
238
+ setGroup.run(target, row.id);
239
+ changed = true;
240
+ }
241
+ // A row that turns out to be the head (a link kept for a re-import) leads unnumbered.
242
+ if (hasPosition && row.id !== headId) {
243
+ const rows = listMembers.all(target).filter((member) => member.id !== headId);
244
+ const before = toText(beforeConversationId);
245
+ const moved = rows.find((member) => member.id === row.id);
246
+ // Dropped before itself: it stays where it is.
247
+ const others = before === row.id ? rows : rows.filter((member) => member !== moved);
248
+ if (before !== row.id) {
249
+ const index = headId && before === headId ? 0 : (before ? others.findIndex((member) => member.id === before) : -1);
250
+ others.splice(index < 0 ? others.length : index, 0, moved);
251
+ }
252
+ others.forEach((member, index) => {
253
+ const position = (index + 1) * POSITION_STEP;
254
+ if (positionValue(member) === position) return;
255
+ setPosition.run(position, member.id);
256
+ changed = true;
257
+ });
258
+ }
259
+ return { ok: true, changed, groupId: target, previousGroupId, order: orderOf(target) };
260
+ });
261
+
262
+ /** `{ [conversationId]: position | null }` for the ids given (rows outside a group read as null). */
263
+ function positionsOf(conversationIds) {
264
+ const out = {};
265
+ for (const raw of new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean))) {
266
+ const row = liveConversation(raw);
267
+ out[raw] = row && toText(row.group_id) ? positionValue(row) : null;
268
+ }
269
+ return out;
270
+ }
271
+
272
+ /**
273
+ * Bootstrap of a session an agent created here: the child joins the
274
+ * creator's group, or a new group with the creator as head. A creator that
275
+ * is gone leaves the child ungrouped.
276
+ */
277
+ const attachCreatedConversation = db.transaction((childId, creatorId, nowIso = new Date().toISOString()) => {
278
+ const child = liveConversation(childId);
279
+ const creator = liveConversation(creatorId);
280
+ if (!child || !creator || child.id === creator.id) return null;
281
+ let groupId = toText(creator.group_id);
282
+ if (!groupId || !getGroup.get(groupId)) {
283
+ // A group that still names this creator as its head (its row was
284
+ // removed and made again, as a cache rebuild does) is taken up again
285
+ // rather than doubled.
286
+ const headed = findGroupHeadedBy.get(creator.id);
287
+ groupId = headed ? toText(headed.id) : createGroup({ parentConversationId: creator.id }, nowIso).id;
288
+ setGroup.run(groupId, creator.id);
289
+ }
290
+ setGroup.run(groupId, child.id, joiningPosition(groupId));
291
+ return groupId;
292
+ });
293
+
294
+ /**
295
+ * What a delete path that does not know groups leaves behind: a head whose
296
+ * row is tombstoned (the group stays headless, the title frozen as its
297
+ * name) and groups without a live member. A head whose row is simply
298
+ * absent keeps its link: a cache wipe froze its name first, and the row
299
+ * may be imported again under the same id and take the group up.
300
+ */
301
+ const repair = db.transaction((nowIso = new Date().toISOString()) => {
302
+ let headsDetached = 0;
303
+ for (const row of listGroupsWithHead.all()) {
304
+ if (!row.status || String(row.status) !== 'deleted') continue;
305
+ freezeName.run(row.title || null, nowIso, row.id);
306
+ headsDetached += 1;
307
+ }
308
+ let groupsRemoved = 0;
309
+ for (const row of listEmptyGroups.all()) groupsRemoved += deleteGroup.run(row.id).changes;
310
+ return { headsDetached, groupsRemoved };
311
+ });
312
+
313
+ /**
314
+ * Before rows are wiped (the cache rebuild): the groups headed by any of
315
+ * them get their head's title as their name, so the group that survives
316
+ * (its hand-titled members stay) is not nameless. The head link stays for
317
+ * a re-import. Returns how many names were frozen.
318
+ */
319
+ const freezeNamesForHeads = db.transaction((conversationIds, nowIso = new Date().toISOString()) => {
320
+ const ids = new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean));
321
+ let frozen = 0;
322
+ for (const row of listGroupsWithHead.all()) {
323
+ if (!ids.has(toText(row.parent_conversation_id))) continue;
324
+ frozen += freezeNameKeepHead.run(row.title || null, nowIso, row.id).changes;
325
+ }
326
+ return frozen;
327
+ });
328
+
329
+ /** `{ [conversationId]: { groupId, role } | null }` for the ids given (deleted rows read as null). */
330
+ function membershipsOf(conversationIds) {
331
+ const out = {};
332
+ for (const raw of new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean))) {
333
+ const row = liveConversation(raw);
334
+ const groupId = toText(row?.group_id);
335
+ const group = groupId ? getGroup.get(groupId) : null;
336
+ out[raw] = row && group
337
+ ? { groupId, role: toText(group.parent_conversation_id) === row.id ? 'head' : 'member' }
338
+ : null;
339
+ }
340
+ return out;
341
+ }
342
+
343
+ return {
344
+ enabled: true,
345
+ getGroup(groupId) {
346
+ return rowToGroup(getGroup.get(toText(groupId)));
347
+ },
348
+ /** `Map<id, group>` for the ids given (unknown ids are left out). */
349
+ listGroupsByIds(groupIds) {
350
+ const map = new Map();
351
+ for (const raw of new Set((Array.isArray(groupIds) ? groupIds : []).map(toText).filter(Boolean))) {
352
+ const group = rowToGroup(getGroup.get(raw));
353
+ if (group) map.set(group.id, group);
354
+ }
355
+ return map;
356
+ },
357
+ groupOfConversation(conversationId) {
358
+ const row = liveConversation(conversationId);
359
+ return row?.group_id ? rowToGroup(getGroup.get(row.group_id)) : null;
360
+ },
361
+ /**
362
+ * `[{ id, title, archived, updatedAt, groupPosition }]` in display order
363
+ * (the head, then the placed members, then the others newest first),
364
+ * deleted rows left out.
365
+ */
366
+ membersOf(groupId) {
367
+ return listMembers.all(toText(groupId)).map((row) => ({
368
+ id: row.id,
369
+ title: row.title,
370
+ archived: Number(row.archived || 0) === 1,
371
+ updatedAt: row.updated_at,
372
+ groupPosition: positionValue(row),
373
+ }));
374
+ },
375
+ createGroup,
376
+ renameGroup(groupId, name, nowIso = new Date().toISOString()) {
377
+ const id = toText(groupId);
378
+ if (!getGroup.get(id)) return null;
379
+ updateName.run(normalizeGroupName(name), nowIso, id);
380
+ return rowToGroup(getGroup.get(id));
381
+ },
382
+ setMembership,
383
+ moveConversation,
384
+ orderOf,
385
+ positionsOf,
386
+ attachCreatedConversation,
387
+ /** Every member leaves; the group row goes. Conversations are untouched otherwise. */
388
+ dissolveGroup(groupId) {
389
+ const id = toText(groupId);
390
+ if (!getGroup.get(id)) return false;
391
+ clearGroup.run(id);
392
+ deleteGroup.run(id);
393
+ return true;
394
+ },
395
+ /**
396
+ * Called before a conversation's rows are deleted: a head leaves its group
397
+ * (name frozen), and a group left empty goes. Returns the group id it was in.
398
+ */
399
+ forgetConversation(conversationId, nowIso = new Date().toISOString()) {
400
+ return leaveGroup(conversationId, nowIso, { anyStatus: true });
401
+ },
402
+ /** Compaction: the new conversation takes the old one's group, its place in it, and its head role. */
403
+ carryToCompacted(sourceId, targetId, nowIso = new Date().toISOString()) {
404
+ const source = liveConversation(sourceId);
405
+ const groupId = toText(source?.group_id);
406
+ if (!groupId || !getGroup.get(groupId) || !getConversation.get(toText(targetId))) return null;
407
+ setGroup.run(groupId, toText(targetId), positionValue(source));
408
+ // The old row stays in the group, unplaced: it must not tie with the new one.
409
+ if (toText(targetId) !== source.id) setGroup.run(groupId, source.id, null);
410
+ const group = getGroup.get(groupId);
411
+ if (toText(group?.parent_conversation_id) === source.id) updateParent.run(toText(targetId), nowIso, groupId);
412
+ return groupId;
413
+ },
414
+ deleteIfEmpty,
415
+ /** Removes groups without a live member; returns how many. */
416
+ sweepEmpty() {
417
+ let removed = 0;
418
+ for (const row of listEmptyGroups.all()) removed += deleteGroup.run(row.id).changes;
419
+ return removed;
420
+ },
421
+ repair,
422
+ freezeNamesForHeads,
423
+ membershipsOf,
424
+ /**
425
+ * The group as the list payload and the change event describe it: name,
426
+ * display name (the head's current title when none was given), head and
427
+ * member counts. `headTitle(title)` may dress the title as the list does.
428
+ */
429
+ describeGroup(groupId, { headTitle = (title) => title } = {}) {
430
+ const group = rowToGroup(getGroup.get(toText(groupId)));
431
+ if (!group) return null;
432
+ const members = listMembers.all(group.id);
433
+ const head = group.parentConversationId ? members.find((member) => member.id === group.parentConversationId) || null : null;
434
+ return {
435
+ id: group.id,
436
+ name: group.name || null,
437
+ displayName: resolveGroupDisplayName({ name: group.name, headTitle: head ? headTitle(head.title) : null }),
438
+ parentConversationId: group.parentConversationId || null,
439
+ memberCount: members.length,
440
+ liveMemberCount: members.filter((member) => Number(member.archived || 0) !== 1).length,
441
+ };
442
+ },
443
+ };
444
+ }
@@ -29,6 +29,7 @@ export function createMessageRepository(db) {
29
29
  getSharedMessages: shareVisibility.getSharedMessages,
30
30
  getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
31
31
  setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
32
+ listHiddenMessages: shareVisibility.listHiddenMessages,
32
33
  setMessagePinnedAt: messagePins.setMessagePinnedAt,
33
34
  listPinnedMessages: messagePins.listPinnedMessages,
34
35
  countPinnedMessages: messagePins.countPinnedMessages,
@@ -24,6 +24,16 @@ export function createSessionRepository(db) {
24
24
  ? 'rs.claude_cloud_cost_usd AS runtime_claude_cloud_cost_usd'
25
25
  : 'NULL AS runtime_claude_cloud_cost_usd',
26
26
  ].join(', ');
27
+ // Session groups (migration 0014). NULL on a schema that predates it.
28
+ const conversationsHaveGroup = db.prepare(`PRAGMA table_info(conversations)`).all()
29
+ .some((column) => String(column?.name || '').trim() === 'group_id');
30
+ // The hand order inside a group (migration 0015).
31
+ const conversationsHaveGroupPosition = db.prepare(`PRAGMA table_info(conversations)`).all()
32
+ .some((column) => String(column?.name || '').trim() === 'group_position');
33
+ const conversationGroupSelect = [
34
+ conversationsHaveGroup ? 'c.group_id' : 'NULL AS group_id',
35
+ conversationsHaveGroupPosition ? 'c.group_position' : 'NULL AS group_position',
36
+ ].join(', ');
27
37
  return {
28
38
  // conversations
29
39
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -31,7 +41,7 @@ export function createSessionRepository(db) {
31
41
  getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
32
42
  listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
33
43
  runtimeSessionsSupportProviders,
34
- listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
44
+ listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
35
45
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
36
46
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
37
47
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
@@ -301,47 +311,12 @@ export function createSessionRepository(db) {
301
311
  FROM conversations
302
312
  WHERE status = 'deleted' AND sdk_session_id = ?
303
313
  `),
304
- // public conversation shares
305
- getConversationShareByConversationId: db.prepare(`
306
- SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
307
- FROM conversation_shares
308
- WHERE conversation_id = ?
309
- AND (revoked_at IS NULL OR revoked_at = '')
310
- ORDER BY created_at DESC
311
- LIMIT 1
312
- `),
313
- getConversationShareByToken: db.prepare(`
314
- SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
315
- FROM conversation_shares
316
- WHERE token = ?
317
- LIMIT 1
318
- `),
319
- insertConversationShare: db.prepare(`
320
- INSERT INTO conversation_shares (
321
- token,
322
- conversation_id,
323
- created_at,
324
- last_accessed_at,
325
- revoked_at
326
- ) VALUES (?, ?, ?, ?, NULL)
327
- `),
314
+ // public conversation shares (the rest lives in share-repository.mjs)
328
315
  touchConversationShare: db.prepare(`
329
316
  UPDATE conversation_shares
330
317
  SET last_accessed_at = ?
331
318
  WHERE token = ?
332
319
  AND (revoked_at IS NULL OR revoked_at = '')
333
320
  `),
334
- revokeConversationShareByToken: db.prepare(`
335
- UPDATE conversation_shares
336
- SET revoked_at = ?
337
- WHERE token = ?
338
- AND (revoked_at IS NULL OR revoked_at = '')
339
- `),
340
- revokeConversationSharesByConversationId: db.prepare(`
341
- UPDATE conversation_shares
342
- SET revoked_at = ?
343
- WHERE conversation_id = ?
344
- AND (revoked_at IS NULL OR revoked_at = '')
345
- `),
346
321
  };
347
322
  }