@oar-sh/oar 0.9.9 → 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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -24,7 +24,7 @@ Use `restart: true` in the request body when the user wants a real relay restart
24
24
 
25
25
  ## Preview servers
26
26
 
27
- Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Previews never expire on their own; close them with {action:"close"} when the user is done.
27
+ Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Publishing the same port or directory again in this conversation returns the same link. Previews never expire and survive a relay restart (a port preview until the machine reboots); close them with {action:"close"} when the user is done.
28
28
 
29
29
  If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
30
30
 
@@ -38,6 +38,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
38
38
 
39
39
  - `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
40
40
  - The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
41
+ - Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. Agents only read groups, the user arranges them.
41
42
  - A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
42
43
  - Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
43
44
  - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
package/server/relay.mjs CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  extractModelDescriptors,
33
33
  normalizeContextLimitTokens,
34
34
  } from '../shared/model-descriptors.mjs';
35
+ import { windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
35
36
 
36
37
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
38
 
@@ -379,7 +380,7 @@ async function spawnForegroundCliViaPowerShell(tcpConnectionToken, port) {
379
380
  ].join(' ');
380
381
 
381
382
  const psCommand = `$env:COPILOT_CONNECTION_TOKEN='${tcpConnectionToken}'; & '${NODE_PATH}' ${nodeArgs}`;
382
- const child = await waitForProcessSpawn('powershell.exe', [
383
+ const child = await waitForProcessSpawn(windowsSystemToolPath('powershell.exe'), [
383
384
  '-NoExit',
384
385
  '-Command',
385
386
  psCommand,
@@ -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,
@@ -346,6 +347,12 @@ export function createMessageRepository(db) {
346
347
  //
347
348
  // A turn blocked on an unanswered AskUserQuestion is never stale — it is
348
349
  // waiting on the human, and relay_questions carries its own expiry.
350
+ //
351
+ // The ceiling counts from the last human interaction with the turn, not
352
+ // from its start: answering a question restarts the clock. Otherwise an
353
+ // interview that waited hours for its answers would be cut off by the
354
+ // ceiling the moment the last answer arrived (live 2026-10-10: a
355
+ // five-question interview lost its reply that way).
349
356
  listRecoverableProcessing: db.prepare(`
350
357
  SELECT id, conversation_id, kind, relay_mode, model, retry_count, consumed_at
351
358
  FROM queue
@@ -353,7 +360,13 @@ export function createMessageRepository(db) {
353
360
  AND COALESCE(kind, '') != 'continuation'
354
361
  AND (
355
362
  COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
356
- OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
363
+ OR (
364
+ @ceilingBefore IS NOT NULL
365
+ AND MAX(
366
+ COALESCE(processing_at, timestamp),
367
+ COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
368
+ ) < @ceilingBefore
369
+ )
357
370
  )
358
371
  AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
359
372
  `),
@@ -431,15 +444,29 @@ export function createMessageRepository(db) {
431
444
  // A continuation with no live worker can only be torn down — replaying
432
445
  // it as deliverable work would send the CLI's own bookkeeping text back
433
446
  // to the CLI as a user prompt.
447
+ //
448
+ // Same staleness rules as listRecoverableProcessing: a continuation
449
+ // waiting on an unanswered question is never stale, and an answer
450
+ // restarts the ceiling clock. A background-agent interview hangs its
451
+ // question cards on the continuation row, so without this a long
452
+ // interview was torn down by the ceiling while the human was still
453
+ // answering (live 2026-10-10).
434
454
  listStaleProcessingContinuations: db.prepare(`
435
- SELECT id, conversation_id
455
+ SELECT id, conversation_id, timestamp, processing_at, owner_last_claimed_at
436
456
  FROM queue
437
457
  WHERE status = 'processing'
438
458
  AND kind = 'continuation'
439
459
  AND (
440
460
  COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
441
- OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
461
+ OR (
462
+ @ceilingBefore IS NOT NULL
463
+ AND MAX(
464
+ COALESCE(processing_at, timestamp),
465
+ COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
466
+ ) < @ceilingBefore
467
+ )
442
468
  )
469
+ AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
443
470
  `),
444
471
  listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
445
472
  // Authoritative "is a turn in flight" signal for the conversation list.
@@ -1,7 +1,13 @@
1
1
  'use strict';
2
2
 
3
+ import { createSubagentTimelineStatements } from './subagent-timeline-statements.mjs';
4
+
3
5
  export function createQuestionRepository(db) {
6
+ const subagentTimeline = createSubagentTimelineStatements(db);
7
+ const runColumns = subagentTimeline.subagentRunSelectColumns;
8
+ const runColumnsAs = (alias) => runColumns.split(', ').map((column) => `${alias}.${column}`).join(', ');
4
9
  return {
10
+ ...subagentTimeline,
5
11
  // relay questions
6
12
  insertQuestion: db.prepare(`INSERT INTO relay_questions (id, queue_id, conversation_id, message_id, relay_mode, prompt, choices, request, request_schema, status, answer, structured_answer, sdk_session_id, owner_worker_id, continuation_id, continuation_question_id, created_at, answered_at, expires_at, attempt_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, ?, ?, ?, NULL, ?, ?)`),
7
13
  getQuestion: db.prepare(`SELECT * FROM relay_questions WHERE id = ?`),
@@ -99,7 +105,7 @@ export function createQuestionRepository(db) {
99
105
  WHERE id = ?
100
106
  `),
101
107
  listSubagentRunsByQueueMessage: db.prepare(`
102
- SELECT id, queue_message_id, conversation_id, parent_subagent_id, display_name, status, started_at, updated_at, completed_at
108
+ SELECT ${runColumns}
103
109
  FROM subagent_runs
104
110
  WHERE queue_message_id = ?
105
111
  ORDER BY started_at ASC, id ASC
@@ -108,7 +114,7 @@ export function createQuestionRepository(db) {
108
114
  // abort) also ends its subagent runs — nothing else ever will, and an
109
115
  // un-reconciled row renders as a bubble stuck "running" forever.
110
116
  listRunningSubagentRunsByQueueMessage: db.prepare(`
111
- SELECT id, conversation_id, parent_subagent_id, display_name
117
+ SELECT ${runColumns}
112
118
  FROM subagent_runs
113
119
  WHERE queue_message_id = ? AND status = 'running'
114
120
  `),
@@ -121,7 +127,7 @@ export function createQuestionRepository(db) {
121
127
  WHERE queue_message_id = ? AND status = 'running'
122
128
  `),
123
129
  listSubagentRunsByResponse: db.prepare(`
124
- SELECT sr.id, sr.queue_message_id, sr.conversation_id, sr.parent_subagent_id, sr.display_name, sr.status, sr.started_at, sr.updated_at, sr.completed_at
130
+ SELECT ${runColumnsAs('sr')}
125
131
  FROM subagent_runs sr
126
132
  WHERE sr.queue_message_id IN (
127
133
  SELECT id FROM queue WHERE response_message_id = ?
@@ -29,7 +29,10 @@ export function createRemoteRelayRepository(db) {
29
29
  const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
30
30
  const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC`);
31
31
  const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
32
- const deleteUnlocksForRelay = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ?`);
32
+ // The relay's unlocks and the approvals stored under its id (`<id>:watch`).
33
+ const deleteUnlocksForRelay = db.prepare(`
34
+ DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ? OR substr(remote_relay_id, 1, length(?) + 1) = ? || ':'
35
+ `);
33
36
  const deleteUnlocksForConversation = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE conversation_id = ?`);
34
37
  const setMessageOrigin = db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`);
35
38
  const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
@@ -101,7 +104,8 @@ export function createRemoteRelayRepository(db) {
101
104
  .map((row) => String(row.id));
102
105
  },
103
106
  forgetRelay(remoteRelayId) {
104
- return deleteUnlocksForRelay.run(String(remoteRelayId || '')).changes;
107
+ const id = String(remoteRelayId || '');
108
+ return deleteUnlocksForRelay.run(id, id, id).changes;
105
109
  },
106
110
  forgetConversation(conversationId) {
107
111
  return deleteUnlocksForConversation.run(String(conversationId || '')).changes;