@oar-sh/oar 0.9.14 → 0.9.16

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 (59) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +525 -12
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  6. package/server/claude-worker/claude-session-process.mjs +4 -4
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  8. package/server/db-schema.mjs +52 -0
  9. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  10. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  11. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  12. package/server/migrations/0022-relay-schedules.mjs +142 -0
  13. package/server/migrations/0023-relay-asks.mjs +141 -0
  14. package/server/public/app/ask-user-view.js +3 -0
  15. package/server/public/app/bootstrap.js +61 -3
  16. package/server/public/app/context-usage-view.mjs +17 -0
  17. package/server/public/app/conversation-group-order.mjs +35 -0
  18. package/server/public/app/inbox-ui.js +356 -0
  19. package/server/public/app/inbox-view.mjs +370 -0
  20. package/server/public/app/journal-view.js +23 -2
  21. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  22. package/server/public/app/relay-event-view.mjs +33 -7
  23. package/server/public/app/relay-schedule-view.mjs +119 -0
  24. package/server/public/app/relay-schedules-ui.js +172 -0
  25. package/server/public/app/settings-modal.js +17 -3
  26. package/server/public/app/socket-handlers.js +27 -3
  27. package/server/public/app/store.js +18 -5
  28. package/server/public/index.html +159 -2
  29. package/server/relay-tools.md +18 -3
  30. package/server/repositories/conversation-group-repository.mjs +97 -11
  31. package/server/repositories/remote-relay-repository.mjs +20 -2
  32. package/server/repositories/session-repository.mjs +7 -1
  33. package/server/routes/ask-user-routes.mjs +57 -3
  34. package/server/routes/cache-routes.mjs +6 -0
  35. package/server/routes/conversation-group-routes.mjs +17 -2
  36. package/server/routes/messages-routes.mjs +3 -3
  37. package/server/routes/relay-ask-routes.mjs +387 -0
  38. package/server/routes/relay-event-routes.mjs +3 -1
  39. package/server/routes/relay-schedule-routes.mjs +57 -0
  40. package/server/routes/sessions-routes.mjs +613 -56
  41. package/server/server-runtime.mjs +80 -1
  42. package/server/services/cache-rebuild-service.mjs +19 -2
  43. package/server/services/conversation-preferences-service.mjs +17 -0
  44. package/server/services/oar-cli-helpers.mjs +12 -4
  45. package/server/services/relay-ask-link-service.mjs +174 -0
  46. package/server/services/relay-ask-service.mjs +604 -0
  47. package/server/services/relay-event-service.mjs +589 -73
  48. package/server/services/relay-schedule-service.mjs +596 -0
  49. package/server/services/remote-relay-caller-context.mjs +89 -20
  50. package/server/services/remote-relay-dispatcher.mjs +956 -30
  51. package/server/services/windows-autostart-service.mjs +88 -16
  52. package/server/services/windows-boot-autostart-service.mjs +775 -126
  53. package/shared/relay-asks.mjs +145 -0
  54. package/shared/relay-events.mjs +268 -18
  55. package/shared/relay-schedules.mjs +128 -0
  56. package/shared/remote-relay-contract.mjs +553 -25
  57. package/shared/remote-relay-tool-core.mjs +16 -5
  58. package/.github/extensions/web-relay/README.md +0 -190
  59. package/server/README.md +0 -2334
@@ -23,7 +23,7 @@ Use `restart: true` in the request body when the user wants a real relay restart
23
23
 
24
24
  Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.
25
25
 
26
- To go on with your work once the relay is back, subscribe first with `remote_relay` `{"action":"subscribe","events":["relay_restarted"],"once":true}` (no sessions needed), then request the restart: your own timers and scheduled jobs may not survive it. A restart requested while a turn that `relay_restarted` woke is still running does not wake that conversation (a `once` subscription ends with it).
26
+ To go on with your work once the relay is back, subscribe first with `remote_relay` `{"action":"subscribe","events":["relay_restarted"],"once":true}` (no sessions needed), then request the restart. Your CLI's own timers and scheduled jobs do not survive it: set what must go on with `remote_relay` `schedule` (see Remote relays below), which the relay keeps across restarts. A restart requested while a turn that `relay_restarted` woke is still running does not wake that conversation (a `once` subscription ends with it).
27
27
 
28
28
  ## Preview servers
29
29
 
@@ -40,16 +40,31 @@ A paired relay stays locked until the user mentions it in this conversation (`@n
40
40
  This relay itself is listed with "self": true when its owner switched agent sessions on, and is addressed by its name or as "this"; it needs no mention. Use it to hand parts of your task to other sessions:
41
41
 
42
42
  - `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.
43
- - 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).
44
- - 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.
43
+ - 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), unless the owner allowed it for that session: `list_sessions` and `read_session` then show "mayCreateSessions": true. Only the owner sets that.
44
+ - 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. `create_session` takes "group" (a group id, or its name in any case, from `relay_info`'s "groups") to put the new session into an existing group instead, here or on a paired relay; an unknown group is refused with the groups listed and nothing is created, and the result carries the "group". Renaming and dissolving groups is the user's.
45
+ - `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `send` that names another mode or comes from a caller in another mode: a `send` without "mode" passes the caller's own mode.
46
+ - Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
45
47
  - A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
46
48
  - 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.
47
49
  - 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).
48
50
  - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
51
+ - Pass only the arguments an action takes. `create_session` and `update_session` refuse any other one, list their valid arguments, and change nothing; every other action still runs and names the arguments it did not take in "ignored".
49
52
  - `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
50
53
 
51
54
  On this relay and on a paired one, `subscribe` ("sessions": ids, or ["all"]) wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command. "relay_restarted" (this relay only, never in the default) wakes you when the relay runs again after a restart, with who requested it and why; named alone it needs no sessions.
52
55
 
56
+ Each wake-up costs you a turn; these `subscribe` options cut them (questions and errors always come at once):
57
+ - "digest_minutes": N gathers the subscription's other events into one message every N minutes (1 to 1440).
58
+ - "quiet_hours": {"from":"23:00","to":"06:00","tz":"Europe/Berlin"} holds them inside that daily window (an IANA zone; it may cross midnight) and delivers them as one message when it ends. When the clocks go back it ends the first time "to" comes; when they skip "to", the moment they jump. A "from" inside the hour the clocks repeat starts the window on the first pass; on the second pass the minutes before "from" read as outside.
59
+ - "exclude": session ids a ["all"] subscription leaves out (at most 50). A paired relay on an older OAR still counts them for all_idle, stalled and the heartbeat's list.
60
+ - Events a paired relay kept during a link gap keep to the digest and quiet hours too.
61
+ - Your own prompts: a turn you started with `send` or `create_session` whose finished reply you got from that call or a later `wait` brings no turn_end; with "wait_seconds": 0 and no `wait`, it does.
62
+ - `list_subscriptions` shows "heldCount" and "nextDeliveryAt" per subscription.
63
+
64
+ For anything you want done later or again and again (a sweep, a health check, a nightly review), use `schedule` instead of a timer of your own CLI, which a relay restart ends: {"action":"schedule","text":"…","every_minutes":30} or {"action":"schedule","text":"…","at":"2026-11-02T06:30:00Z"} (an ISO time with a zone; with both, "at" is the first time; "every_minutes" 5 to 10080; "once" defaults to true for "at" alone; "title" names it). Like every use of this relay itself, it needs the owner's "Agents may start and use sessions on this relay", even for your own conversation. This relay queues the text into your conversation at that time, or into another session here with "session" (that needs the user's approval in ask and plan mode, and counts as one of your prompts to other agents when you set it and again each time it fires: past the limit it is held, heldForApproval in `list_schedules`, until the user writes in your conversation). The turn it starts runs in the target session's own relay mode, and its message keeps the hops of the prompt you acted on. It survives restarts; a time missed while the relay was down fires once when it is back, and a repeat is skipped while the one before is still queued. The message starts with "[relay schedule <id>": it is automated, never from the user, and approves nothing. `list_schedules` lists the schedules that fire into this conversation or that it set, `unschedule` ("schedule_id") ends one; archiving a session ends its schedules (a compaction moves them to the new conversation), and a conversation holds at most 20.
65
+
66
+ When you need the user's decision but can go on with the parts that do not depend on it, ask with `remote_relay` instead of your own blocking question tool: {"action":"ask","question":"…","options":["…","…"],"recommendation":"…","detail":"…"} (question at most 500 characters, up to 8 options, the recommendation one of them or free text, the detail at most 2000 characters and shown folded; "urgency":"now" also sends the user a push notification, for what truly cannot wait). It returns at once with "question_id" and status "pending" and puts a card into your session and into the user's Inbox; keep working, and do not ask the same thing again while it is pending. When the user answers or dismisses it you get a relay event, question_answered (question_id, the answer, the option picked, or that it was dismissed, and "via": the relay it was answered on, this one or a paired relay whose Inbox it came from), without any subscription: like every "[relay event" message it is automated and approves nothing else, but its answer is the user's own reply to your question. Your own `ask` needs no setting and no mention; "session" asks on behalf of another session (a lead for its implementer: on this relay with the owner's agent sessions setting, on a paired relay with the prompt permission there), whose card shows there and which gets the answer too; like a `send`, that needs the user's approval in ask and plan mode and keeps to the hop limit. `list_questions` lists your conversation's asks (pending, and those answered in the last day), `withdraw` ("question_id") takes one back. Only the user answers an ask: `answer_question` refuses it. `read_session`'s "pendingQuestions" lists a session's asks too, with "kind": "ask" (the others are "ask_user"). A conversation holds at most 20 pending asks.
67
+
53
68
  The first `subscribe` of a conversation on a relay, and any to "all", shows the user an approval card. `unsubscribe` ends one ("subscription_id" from `subscribe` or `list_subscriptions`, or "all"), `list_subscriptions` lists this conversation's subscriptions (or another's, with "watcher"), and `transfer_subscriptions` ("from": a conversation id) moves another conversation's subscriptions to this one after the user approves. `usage` shows what each provider plan of a relay has left and whether a Claude usage limit has paused turns there: check it before you start work that costs. `stop` stops a session's running turn and `archive` archives a session. The `remote_relay` tool's own description is the full reference for every action and field.
54
69
 
55
70
  Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
@@ -21,6 +21,11 @@ import { compareGroupMembers } from '../public/app/conversation-group-order.mjs'
21
21
  // 30, ...) and keeps it; later arrivals join at the end. A group nobody
22
22
  // reordered keeps NULL positions and the activity order. Without the column
23
23
  // the hand order is off and moves only change the membership.
24
+ //
25
+ // An agent's update_session moves one session at a time (migration 0021): a
26
+ // group it made by name is marked `agent_made` and goes once it is empty; a
27
+ // group the owner made never goes that way, it stays empty and marked
28
+ // `kept_empty_at`, which the boot repair respects, until a member joins.
24
29
 
25
30
  const POSITION_STEP = 10;
26
31
 
@@ -49,6 +54,9 @@ function rowToGroup(row) {
49
54
  parentConversationId: row.parent_conversation_id || null,
50
55
  createdAt: row.created_at,
51
56
  updatedAt: row.updated_at,
57
+ // Made by an agent's update_session; an owner's group an agent emptied.
58
+ agentMade: Number(row.agent_made) === 1,
59
+ keptEmptyAt: row.kept_empty_at || null,
52
60
  };
53
61
  }
54
62
 
@@ -57,6 +65,12 @@ export function createConversationGroupRepository(db) {
57
65
  const hasColumn = db.prepare(`PRAGMA table_info(conversations)`).all().some((column) => column.name === 'group_id');
58
66
  const enabled = hasTable && hasColumn;
59
67
  const hasPosition = enabled && db.prepare(`PRAGMA table_info(conversations)`).all().some((column) => column.name === 'group_position');
68
+ // What update_session may do to a group (migration 0021): a group it made
69
+ // (`agent_made`), and an owner's group it emptied, which stays
70
+ // (`kept_empty_at`). Without the columns a kept group lasts until the boot
71
+ // repair.
72
+ const groupColumns = enabled ? new Set(db.prepare(`PRAGMA table_info(conversation_groups)`).all().map((column) => column.name)) : new Set();
73
+ const hasAgentMarks = groupColumns.has('agent_made') && groupColumns.has('kept_empty_at');
60
74
  if (!enabled) {
61
75
  return {
62
76
  enabled: false,
@@ -78,6 +92,8 @@ export function createConversationGroupRepository(db) {
78
92
  freezeNamesForHeads: () => 0,
79
93
  membershipsOf: () => ({}),
80
94
  describeGroup: () => null,
95
+ listGroups: () => [],
96
+ listKeptEmptyGroups: () => [],
81
97
  orderOf: () => [],
82
98
  positionsOf: () => ({}),
83
99
  };
@@ -85,6 +101,9 @@ export function createConversationGroupRepository(db) {
85
101
 
86
102
  const getGroup = db.prepare(`SELECT * FROM conversation_groups WHERE id = ?`);
87
103
  const insertGroup = db.prepare(`INSERT INTO conversation_groups (id, name, parent_conversation_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
104
+ const markAgentMade = hasAgentMarks ? db.prepare(`UPDATE conversation_groups SET agent_made = 1 WHERE id = ?`) : null;
105
+ const markKeptEmpty = hasAgentMarks ? db.prepare(`UPDATE conversation_groups SET kept_empty_at = ? WHERE id = ?`) : null;
106
+ const clearKeptEmpty = hasAgentMarks ? db.prepare(`UPDATE conversation_groups SET kept_empty_at = NULL WHERE id = ? AND kept_empty_at IS NOT NULL`) : null;
88
107
  const updateName = db.prepare(`UPDATE conversation_groups SET name = ?, updated_at = ? WHERE id = ?`);
89
108
  const updateParent = db.prepare(`UPDATE conversation_groups SET parent_conversation_id = ?, updated_at = ? WHERE id = ?`);
90
109
  // A frozen head title keeps to the name cap, so a later rename is never
@@ -105,9 +124,13 @@ export function createConversationGroupRepository(db) {
105
124
  ? `UPDATE conversations SET group_id = ?, group_position = ? WHERE id = ?`
106
125
  : `UPDATE conversations SET group_id = ? WHERE id = ?`);
107
126
  const setGroup = {
108
- run: (groupId, conversationId, position = null) => (hasPosition
109
- ? setGroupStatement.run(groupId, position, conversationId)
110
- : setGroupStatement.run(groupId, conversationId)),
127
+ run: (groupId, conversationId, position = null) => {
128
+ // A kept empty group that gets a member is an ordinary group again.
129
+ if (groupId) clearKeptEmpty?.run(groupId);
130
+ return hasPosition
131
+ ? setGroupStatement.run(groupId, position, conversationId)
132
+ : setGroupStatement.run(groupId, conversationId);
133
+ },
111
134
  };
112
135
  const setPosition = hasPosition ? db.prepare(`UPDATE conversations SET group_position = ? WHERE id = ?`) : null;
113
136
  const clearGroup = db.prepare(hasPosition
@@ -122,9 +145,33 @@ export function createConversationGroupRepository(db) {
122
145
  ? db.prepare(`SELECT MAX(group_position) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`)
123
146
  : null;
124
147
  const countMembers = db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`);
148
+ // Groups with a live member, the one with the latest activity first, then
149
+ // the owner's groups an agent emptied (kept, and still nameable).
150
+ const listGroupsWithMembers = db.prepare(hasAgentMarks
151
+ ? `
152
+ SELECT g.id, g.name, g.parent_conversation_id, COUNT(c.id) AS member_count, MAX(c.updated_at) AS last_activity
153
+ FROM conversation_groups g LEFT JOIN conversations c ON c.group_id = g.id AND c.status != 'deleted'
154
+ GROUP BY g.id
155
+ HAVING COUNT(c.id) > 0 OR g.kept_empty_at IS NOT NULL
156
+ ORDER BY last_activity DESC, g.created_at DESC
157
+ `
158
+ : `
159
+ SELECT g.id, g.name, g.parent_conversation_id, COUNT(c.id) AS member_count, MAX(c.updated_at) AS last_activity
160
+ FROM conversation_groups g JOIN conversations c ON c.group_id = g.id AND c.status != 'deleted'
161
+ GROUP BY g.id
162
+ ORDER BY last_activity DESC, g.created_at DESC
163
+ `);
164
+ const listKeptEmpty = hasAgentMarks ? db.prepare(`
165
+ SELECT g.* FROM conversation_groups g
166
+ WHERE g.kept_empty_at IS NOT NULL
167
+ AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.group_id = g.id AND c.status != 'deleted')
168
+ ORDER BY g.kept_empty_at DESC, g.id DESC
169
+ `) : null;
170
+ // A kept empty group is not left over: the repair and the sweep pass it by.
125
171
  const listEmptyGroups = db.prepare(`
126
172
  SELECT g.id FROM conversation_groups g
127
173
  WHERE NOT EXISTS (SELECT 1 FROM conversations c WHERE c.group_id = g.id AND c.status != 'deleted')
174
+ ${hasAgentMarks ? 'AND g.kept_empty_at IS NULL' : ''}
128
175
  `);
129
176
 
130
177
  /** The group's rows in display order: the head, then the members (see compareGroupMembers). */
@@ -163,9 +210,11 @@ export function createConversationGroupRepository(db) {
163
210
  /**
164
211
  * The conversation leaves its group. A head that leaves makes its group
165
212
  * 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.
213
+ * hand. An empty group is removed, except with `keepOwnerGroups` (an
214
+ * agent's update_session): then only a group an agent made goes, and the
215
+ * owner's stays, marked as kept empty. Returns the previous group id.
167
216
  */
168
- const leaveGroup = db.transaction((conversationId, nowIso, { anyStatus = false } = {}) => {
217
+ const leaveGroup = db.transaction((conversationId, nowIso, { anyStatus = false, keepOwnerGroups = false } = {}) => {
169
218
  // A row already marked deleted (the delete path marks before it removes)
170
219
  // still leaves its group the same way.
171
220
  const row = anyStatus ? getConversation.get(toText(conversationId)) : liveConversation(conversationId);
@@ -174,30 +223,39 @@ export function createConversationGroupRepository(db) {
174
223
  const group = getGroup.get(previous);
175
224
  if (group && toText(group.parent_conversation_id) === row.id) freezeName.run(row.title || null, nowIso, previous);
176
225
  setGroup.run(null, row.id);
177
- deleteIfEmpty(previous);
226
+ if (keepOwnerGroups && group && Number(group.agent_made) !== 1) {
227
+ if (Number(countMembers.get(previous)?.n || 0) === 0) markKeptEmpty?.run(nowIso, previous);
228
+ } else {
229
+ deleteIfEmpty(previous);
230
+ }
178
231
  return previous;
179
232
  });
180
233
 
181
- const createGroup = db.transaction(({ name = null, parentConversationId = null, conversationIds = [] } = {}, nowIso = new Date().toISOString()) => {
234
+ /**
235
+ * A new group. `agentMade` marks one an agent's update_session made;
236
+ * `keepOwnerGroups` is leaveGroup's, for the groups its members leave.
237
+ */
238
+ const createGroup = db.transaction(({ name = null, parentConversationId = null, conversationIds = [], agentMade = false, keepOwnerGroups = false } = {}, nowIso = new Date().toISOString()) => {
182
239
  const id = `grp_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
183
240
  const head = liveConversation(parentConversationId);
184
241
  insertGroup.run(id, normalizeGroupName(name), head ? head.id : null, nowIso, nowIso);
242
+ if (agentMade) markAgentMade?.run(id);
185
243
  const members = new Set([...(head ? [head.id] : []), ...conversationIds.map(toText).filter(Boolean)]);
186
244
  for (const memberId of members) {
187
245
  if (!liveConversation(memberId)) continue;
188
- leaveGroup(memberId, nowIso);
246
+ leaveGroup(memberId, nowIso, { keepOwnerGroups });
189
247
  setGroup.run(id, memberId);
190
248
  }
191
249
  return rowToGroup(getGroup.get(id));
192
250
  });
193
251
 
194
- const setMembership = db.transaction((conversationId, groupId, nowIso = new Date().toISOString()) => {
252
+ const setMembership = db.transaction((conversationId, groupId, nowIso = new Date().toISOString(), { keepOwnerGroups = false } = {}) => {
195
253
  const row = liveConversation(conversationId);
196
254
  if (!row) return { ok: false, error: 'conversation-not-found' };
197
255
  const target = toText(groupId);
198
256
  if (target && !getGroup.get(target)) return { ok: false, error: 'group-not-found' };
199
257
  if (toText(row.group_id) === target) return { ok: true, changed: false, groupId: target || null, previousGroupId: target || null };
200
- const previousGroupId = leaveGroup(row.id, nowIso);
258
+ const previousGroupId = leaveGroup(row.id, nowIso, { keepOwnerGroups });
201
259
  if (target) setGroup.run(target, row.id, joiningPosition(target));
202
260
  return { ok: true, changed: true, groupId: target || null, previousGroupId };
203
261
  });
@@ -284,7 +342,9 @@ export function createConversationGroupRepository(db) {
284
342
  // removed and made again, as a cache rebuild does) is taken up again
285
343
  // rather than doubled.
286
344
  const headed = findGroupHeadedBy.get(creator.id);
287
- groupId = headed ? toText(headed.id) : createGroup({ parentConversationId: creator.id }, nowIso).id;
345
+ // The group an agent's create_session forms is an agent's: it goes once
346
+ // the agent path empties it.
347
+ groupId = headed ? toText(headed.id) : createGroup({ parentConversationId: creator.id, agentMade: true }, nowIso).id;
288
348
  setGroup.run(groupId, creator.id);
289
349
  }
290
350
  setGroup.run(groupId, child.id, joiningPosition(groupId));
@@ -438,7 +498,33 @@ export function createConversationGroupRepository(db) {
438
498
  parentConversationId: group.parentConversationId || null,
439
499
  memberCount: members.length,
440
500
  liveMemberCount: members.filter((member) => Number(member.archived || 0) !== 1).length,
501
+ keptEmptyAt: group.keptEmptyAt,
441
502
  };
442
503
  },
504
+ /**
505
+ * The owner's groups an agent emptied and that stay (kept empty, no live
506
+ * member), newest first: the sidebar shows them, so the owner can rename
507
+ * or dissolve them. Nothing removes them by itself.
508
+ */
509
+ listKeptEmptyGroups() {
510
+ return listKeptEmpty ? listKeptEmpty.all().map(rowToGroup) : [];
511
+ },
512
+ /**
513
+ * Every group with a live member, latest activity first, as an agent may
514
+ * see it: `[{ id, name, memberCount }]`, `name` being what the sidebar
515
+ * shows (the head's current title for a group nobody named). No member
516
+ * ids, no other titles.
517
+ */
518
+ listGroups({ headTitle = (title) => title } = {}) {
519
+ return listGroupsWithMembers.all().map((row) => {
520
+ const head = row.parent_conversation_id ? liveConversation(row.parent_conversation_id) : null;
521
+ const headInGroup = head && toText(head.group_id) === row.id ? head : null;
522
+ return {
523
+ id: row.id,
524
+ name: resolveGroupDisplayName({ name: row.name, headTitle: headInGroup ? headTitle(headInGroup.title) : null }),
525
+ memberCount: Number(row.member_count || 0),
526
+ };
527
+ });
528
+ },
443
529
  };
444
530
  }
@@ -14,7 +14,9 @@ import { REMOTE_RELAY_LOCAL_ID, normalizeRemoteRelayOrigin } from '../../shared/
14
14
  // Agent sessions on this relay itself reuse both: the user's "Allow" for a
15
15
  // conversation's agent to start sessions here is an unlock under the local
16
16
  // target's id, and a conversation such an agent created carries an origin
17
- // with `local: true` that names the conversation it was created from.
17
+ // with `local: true` that names the conversation it was created from. The
18
+ // Allow to change sessions a conversation did not start (update_session) is an unlock
19
+ // under `<relay id>:session-settings`, like the watch approvals.
18
20
 
19
21
  function parseOrigin(json) {
20
22
  if (!json) return null;
@@ -56,11 +58,23 @@ export function createRemoteRelayRepository(db) {
56
58
  WHERE q.conversation_id = c.id AND q.status IN ('pending', 'processing', 'parked')
57
59
  )
58
60
  `);
61
+ // The user's own messages have no kind. Every user-role message with one is
62
+ // the relay's (a relay event, a scheduled prompt, the usage-limit follow-up,
63
+ // the note after a restart): it must not reset the loop guard that counts
64
+ // agents' prompts since the user wrote, or two agents could keep each other
65
+ // going through events.
59
66
  const getLatestHumanMessage = db.prepare(`
60
67
  SELECT id FROM messages
61
- WHERE conversation_id = ? AND role = 'user' AND origin_json IS NULL
68
+ WHERE conversation_id = ? AND role = 'user' AND origin_json IS NULL AND NULLIF(kind, '') IS NULL
62
69
  ORDER BY timestamp DESC LIMIT 1
63
70
  `);
71
+ // The owner's "agents in this conversation may start sessions" (migration
72
+ // 0020). Without the column nobody has it.
73
+ const hasMayCreateColumn = db.prepare(`PRAGMA table_info(conversations)`).all()
74
+ .some((column) => column.name === 'may_create_sessions');
75
+ const getMayCreateSessions = hasMayCreateColumn
76
+ ? db.prepare(`SELECT may_create_sessions FROM conversations WHERE id = ?`)
77
+ : null;
64
78
 
65
79
  return {
66
80
  hasUnlock(conversationId, remoteRelayId) {
@@ -143,6 +157,10 @@ export function createRemoteRelayRepository(db) {
143
157
  latestHumanMessageId(conversationId) {
144
158
  return getLatestHumanMessage.get(String(conversationId || ''))?.id || null;
145
159
  },
160
+ /** Did the owner let the agents of this (agent-started) conversation start sessions? */
161
+ mayCreateSessions(conversationId) {
162
+ return Number(getMayCreateSessions?.get(String(conversationId || ''))?.may_create_sessions) === 1;
163
+ },
146
164
  };
147
165
  }
148
166
 
@@ -34,6 +34,11 @@ export function createSessionRepository(db) {
34
34
  conversationsHaveGroup ? 'c.group_id' : 'NULL AS group_id',
35
35
  conversationsHaveGroupPosition ? 'c.group_position' : 'NULL AS group_position',
36
36
  ].join(', ');
37
+ // The owner's "agents in this conversation may start sessions" (migration
38
+ // 0020). NULL on a schema that predates it, where it cannot be set.
39
+ const conversationsHaveMayCreateSessions = db.prepare(`PRAGMA table_info(conversations)`).all()
40
+ .some((column) => String(column?.name || '').trim() === 'may_create_sessions');
41
+ const conversationMayCreateSelect = conversationsHaveMayCreateSessions ? 'c.may_create_sessions' : 'NULL AS may_create_sessions';
37
42
  return {
38
43
  // conversations
39
44
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -41,7 +46,8 @@ export function createSessionRepository(db) {
41
46
  getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
42
47
  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'`),
43
48
  runtimeSessionsSupportProviders,
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`),
49
+ conversationsHaveMayCreateSessions,
50
+ 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}, ${conversationMayCreateSelect}, 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`),
45
51
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
46
52
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
47
53
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
4
4
  import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
5
- import { isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
5
+ import { REMOTE_RELAY_ERROR_CODES, isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
6
+ import { isRelayAskId, relayQuestionKind } from '../../shared/relay-asks.mjs';
7
+ import { sendRelayAskError } from './relay-ask-routes.mjs';
6
8
  import { closedQuestionAnswer, questionAcceptsFreeText } from '../../shared/question-answer-rule.mjs';
7
9
  import {
8
10
  extractRequestedSchema,
@@ -142,6 +144,9 @@ export function registerAskUserRoutes(app, deps) {
142
144
  // Remote relays: another relay's agent answering a question is refused
143
145
  // while the inbound switch is off. Absent in older wiring (no-op).
144
146
  remoteRelayInbound = null,
147
+ // Questions that don't block (relay-ask-service.mjs): their ids answer
148
+ // here like a card's. Absent in older wiring (no asks).
149
+ relayAskService = null,
145
150
  } = deps;
146
151
 
147
152
  const continuationRoutingEnabled = runtimeState?.featureFlags?.SESSION_WORKER_CONTINUATION_ROUTING_ENABLED === true;
@@ -163,15 +168,63 @@ export function registerAskUserRoutes(app, deps) {
163
168
  const status = String(req.query.status || 'pending').trim() || 'pending';
164
169
  let rows = stmts.listQuestions.all(status, conversationId, conversationId);
165
170
  if (fromRemoteRelay(req)) rows = rows.filter((row) => !isRemoteRelayApprovalRow(row));
166
- res.json({ questions: rows.map(formatQuestionRow) });
171
+ const questions = rows.map(formatQuestionRow).map((question) => ({ ...question, kind: relayQuestionKind(question) }));
172
+ // Questions that don't block, on request (read_session's pendingQuestions):
173
+ // the cards' own readers take only what a turn waits on.
174
+ if (String(req.query.includeAsks || '') === '1' && relayAskService?.isAvailable?.() && status === 'pending') {
175
+ const asks = conversationId ? relayAskService.listOf(conversationId) : relayAskService.listHere();
176
+ questions.push(...asks.filter((ask) => !ask.relayId && ask.status === 'pending' && (!conversationId || ask.conversationId === conversationId)));
177
+ }
178
+ res.json({ questions });
167
179
  });
168
180
 
169
181
  app.get('/api/relay-question/:id', auth, (req, res) => {
182
+ if (isRelayAskId(req.params.id)) {
183
+ const ask = relayAskService?.get?.(req.params.id) || null;
184
+ return ask ? res.json({ question: ask }) : res.status(404).json({ error: 'Not found' });
185
+ }
170
186
  const row = stmts.getQuestion.get(req.params.id);
171
187
  if (!row || (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req))) {
172
188
  return res.status(404).json({ error: 'Not found' });
173
189
  }
174
- res.json({ question: formatQuestionRow(row) });
190
+ const question = formatQuestionRow(row);
191
+ res.json({ question: { ...question, kind: relayQuestionKind(question) } });
192
+ });
193
+
194
+ /**
195
+ * An ask (remote_relay `ask`) is the user's to answer: from this relay's
196
+ * own UI here, from a paired relay's Inbox through its own route
197
+ * (relay-ask-routes.mjs). Never from an agent, this relay's or another's.
198
+ */
199
+ function refuseAgentOnAsk(req, res) {
200
+ if (!fromRemoteRelay(req)) return false;
201
+ console.warn(`[${ts()}] QUESTION reject id=${String(req.params.id || '').slice(0, 12)} reason=ask-from-agent`);
202
+ res.status(403).json({
203
+ error: 'This question was asked with ask: only the user answers it, in the Inbox or the session.',
204
+ code: REMOTE_RELAY_ERROR_CODES.forbidden,
205
+ });
206
+ return true;
207
+ }
208
+
209
+ function answerAsk(req, res, dismiss) {
210
+ if (refuseAgentOnAsk(req, res)) return;
211
+ if (!relayAskService?.isAvailable?.()) return res.status(503).json({ error: 'Questions that don\'t block are not available on this relay right now' });
212
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
213
+ try {
214
+ const ask = dismiss
215
+ ? relayAskService.dismiss(req.params.id)
216
+ : relayAskService.answer(req.params.id, { answer: String(body.answer ?? ''), choice: String(body.choice ?? '') });
217
+ console.log(`[${ts()}] QUESTION ${String(ask.id).slice(0, 12)} ${dismiss ? 'dismissed' : 'answered'} (ask)`);
218
+ res.json({ ok: true, question: ask });
219
+ } catch (error) {
220
+ sendRelayAskError(res, error, dismiss ? 'The dismissal' : 'The answer');
221
+ }
222
+ }
223
+
224
+ // POST /api/relay-question/:id/dismiss — an ask closed without an answer.
225
+ app.post('/api/relay-question/:id/dismiss', auth, (req, res) => {
226
+ if (!isRelayAskId(req.params.id)) return res.status(400).json({ error: 'Only a question asked with ask can be dismissed; a card waits for its answer.' });
227
+ answerAsk(req, res, true);
175
228
  });
176
229
 
177
230
  app.post('/api/relay-question', auth, (req, res) => {
@@ -275,6 +328,7 @@ export function registerAskUserRoutes(app, deps) {
275
328
 
276
329
  app.post('/api/relay-question/:id/answer', auth, (req, res) => {
277
330
  if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
331
+ if (isRelayAskId(req.params.id)) return answerAsk(req, res, false);
278
332
  const id = String(req.params.id || '').trim();
279
333
  const { answer, structuredAnswer, sdk_session_id, continuation_id, continuation_question_id } = req.body;
280
334
  const sdkSessionId = normalizeId(sdk_session_id);
@@ -16,6 +16,8 @@ export function registerCacheRoutes(app, deps) {
16
16
  deleteOrphanedUploads,
17
17
  closeConversationPreviews,
18
18
  conversationGroups = null,
19
+ relayScheduleService = null,
20
+ relayAskService = null,
19
21
  } = deps;
20
22
 
21
23
  const cacheRebuildService = createCacheRebuildService({
@@ -30,6 +32,10 @@ export function registerCacheRoutes(app, deps) {
30
32
  deleteOrphanedUploads,
31
33
  closeConversationPreviews,
32
34
  conversationGroups,
35
+ onConversationGone: (conversationId, options) => {
36
+ relayScheduleService?.noteConversationGone?.(conversationId, options);
37
+ relayAskService?.noteConversationGone?.(conversationId);
38
+ },
33
39
  });
34
40
 
35
41
  app.post('/api/cache/rebuild', auth, (req, res) => {
@@ -8,8 +8,17 @@
8
8
  //
9
9
  // The owner's action only: every route is behind `auth`, and a request that
10
10
  // arrived from a paired relay is refused, as the remote_relay tool's own
11
- // route refuses calls from other relays. The tool reads groups, never
12
- // changes them.
11
+ // route refuses calls from other relays. The tool reads groups, names an
12
+ // existing one for a session it creates (the bootstrap route), and moves one
13
+ // session into a group, out of it, or into a new group of a name it gives
14
+ // (update_session, PATCH /api/conversation/:id/settings in
15
+ // sessions-routes.mjs). Renaming and dissolving groups, and moving several
16
+ // rows at once, stay here, the owner's.
17
+ //
18
+ // The one read, GET /api/conversation-groups, is the list relay_info and
19
+ // create_session look a group up in: open to whoever may list conversations
20
+ // (the same `auth` as GET /api/conversations), paired relays included, and
21
+ // only id, name and member count.
13
22
 
14
23
  import { readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
15
24
  import { GROUP_NAME_MAX } from '../repositories/conversation-group-repository.mjs';
@@ -62,6 +71,12 @@ export function registerConversationGroupRoutes(app, deps) {
62
71
  };
63
72
  }
64
73
 
74
+ // GET /api/conversation-groups — `{ ok, groups: [{ id, name, memberCount }] }`, latest activity first.
75
+ app.get('/api/conversation-groups', auth, (req, res) => {
76
+ if (!conversationGroups?.enabled) return unavailable(res);
77
+ return res.json({ ok: true, groups: conversationGroups.listGroups({ headTitle: resolveConversationTitleText }) });
78
+ });
79
+
65
80
  // POST /api/conversation-groups { name?, conversationIds } — a group by hand.
66
81
  app.post('/api/conversation-groups', auth, (req, res) => {
67
82
  if (refuseRemote(req, res)) return;
@@ -65,7 +65,7 @@ import {
65
65
  TIMELINE_READ_DEFAULT_LIMIT,
66
66
  } from '../services/subagent-timeline-service.mjs';
67
67
  import { SUBAGENT_TASK_TYPES, isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
68
- import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
68
+ import { isRelayAutomatedMessageKind } from '../../shared/relay-events.mjs';
69
69
  import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
70
70
  import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
71
71
  import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
@@ -1216,7 +1216,7 @@ export function buildDequeuedRelayMessage({
1216
1216
  fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
1217
1217
  })
1218
1218
  : null;
1219
- // An event message the relay wrote (relay event subscriptions): the
1219
+ // A message the relay wrote itself (a relay event, a scheduled prompt): the
1220
1220
  // workers deliver it without the steer note, which speaks as the user.
1221
1221
  let messageKind = '';
1222
1222
  try {
@@ -1253,7 +1253,7 @@ export function buildDequeuedRelayMessage({
1253
1253
  ownerAssignedAt: msg.owner_assigned_at || null,
1254
1254
  ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
1255
1255
  imageOperationId: String(msg.image_operation_id || '').trim() || null,
1256
- ...(messageKind === RELAY_EVENT_MESSAGE_KIND ? { messageKind } : {}),
1256
+ ...(isRelayAutomatedMessageKind(messageKind) ? { messageKind } : {}),
1257
1257
  };
1258
1258
  }
1259
1259