@oar-sh/oar 0.9.15 → 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 (52) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  5. package/server/claude-worker/claude-session-process.mjs +4 -4
  6. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  7. package/server/db-schema.mjs +52 -0
  8. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  9. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  10. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  11. package/server/migrations/0022-relay-schedules.mjs +142 -0
  12. package/server/migrations/0023-relay-asks.mjs +141 -0
  13. package/server/public/app/ask-user-view.js +3 -0
  14. package/server/public/app/bootstrap.js +61 -3
  15. package/server/public/app/context-usage-view.mjs +17 -0
  16. package/server/public/app/conversation-group-order.mjs +35 -0
  17. package/server/public/app/inbox-ui.js +356 -0
  18. package/server/public/app/inbox-view.mjs +370 -0
  19. package/server/public/app/journal-view.js +23 -2
  20. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  21. package/server/public/app/relay-event-view.mjs +33 -7
  22. package/server/public/app/relay-schedule-view.mjs +119 -0
  23. package/server/public/app/relay-schedules-ui.js +172 -0
  24. package/server/public/app/socket-handlers.js +27 -3
  25. package/server/public/app/store.js +18 -5
  26. package/server/public/index.html +159 -2
  27. package/server/relay-tools.md +18 -3
  28. package/server/repositories/conversation-group-repository.mjs +97 -11
  29. package/server/repositories/remote-relay-repository.mjs +20 -2
  30. package/server/repositories/session-repository.mjs +7 -1
  31. package/server/routes/ask-user-routes.mjs +57 -3
  32. package/server/routes/cache-routes.mjs +6 -0
  33. package/server/routes/conversation-group-routes.mjs +17 -2
  34. package/server/routes/messages-routes.mjs +3 -3
  35. package/server/routes/relay-ask-routes.mjs +387 -0
  36. package/server/routes/relay-event-routes.mjs +3 -1
  37. package/server/routes/relay-schedule-routes.mjs +57 -0
  38. package/server/routes/sessions-routes.mjs +568 -42
  39. package/server/server-runtime.mjs +80 -1
  40. package/server/services/cache-rebuild-service.mjs +19 -2
  41. package/server/services/conversation-preferences-service.mjs +17 -0
  42. package/server/services/relay-ask-link-service.mjs +174 -0
  43. package/server/services/relay-ask-service.mjs +604 -0
  44. package/server/services/relay-event-service.mjs +589 -73
  45. package/server/services/relay-schedule-service.mjs +596 -0
  46. package/server/services/remote-relay-caller-context.mjs +89 -20
  47. package/server/services/remote-relay-dispatcher.mjs +956 -30
  48. package/shared/relay-asks.mjs +145 -0
  49. package/shared/relay-events.mjs +268 -18
  50. package/shared/relay-schedules.mjs +128 -0
  51. package/shared/remote-relay-contract.mjs +553 -25
  52. package/shared/remote-relay-tool-core.mjs +16 -5
package/CHANGELOG.md CHANGED
@@ -3,6 +3,91 @@
3
3
  All notable changes to OAR are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [0.9.16] — 2026-10-24
7
+
8
+ Highlights: agents can file the sessions they start into your session
9
+ groups, change another session's model, mode or other settings, watch other
10
+ sessions with fewer wake-ups (digests, quiet hours, exclusions), and
11
+ schedule prompts that survive a relay restart. Agents can ask you a question
12
+ without stopping their work, and the new Inbox lists every open question
13
+ from this relay and its paired relays in one place. A new switch lets the
14
+ agents of a conversation start sessions themselves.
15
+
16
+ ### Added
17
+
18
+ - **Agents can put a new session into an existing session group.**
19
+ `remote_relay` create_session takes `group` (a group id, or its name in
20
+ any case) on this relay or a paired one, and `relay_info` lists the groups
21
+ there. An unknown group is refused with the groups listed, and nothing is
22
+ created. The approval card names the group, and the result carries it. A
23
+ relay too old for this is refused before anything is created. Renaming
24
+ and rearranging groups stays the owner's. An argument an action does not
25
+ take is no longer dropped without a word: create_session refuses it, and
26
+ the other actions list it in `ignored`.
27
+ - **Agents can change another session's settings.** `remote_relay`
28
+ update_session sets a session's model, reasoning effort, relay mode,
29
+ auto-compact window, title, group, thinking or working folder for its next
30
+ turns, on this relay or a paired one, and answers the settings and what
31
+ changed. An unknown model is refused with the close matches; a group name
32
+ that does not exist makes a new group, and `none` takes the session out (a
33
+ group you made is never removed this way: it stays, shown empty in the
34
+ sidebar for you to rename or dissolve); a new folder
35
+ restarts the session's CLI there and is refused while it works. Only a
36
+ session the agent's own conversation started may be changed freely; any
37
+ other, yours or another agent's, needs your Allow once per conversation and
38
+ relay. A failed change still lists what it wrote. An open page shows the
39
+ change at once.
40
+ - **"Agents in this conversation may start sessions."** A switch in the 🧠
41
+ settings of a session an agent started lets its agents start sessions
42
+ themselves, past the one-level rule; the limit of four at work and your
43
+ first Allow still apply. Only you can set it: agents and paired relays are
44
+ refused.
45
+ - **Fewer wake-ups for watching agents.** A `remote_relay` subscription takes
46
+ `digest_minutes` (its events come as one message every N minutes),
47
+ `quiet_hours` (a daily window in a time zone, crossing midnight and the
48
+ clock changes included, whose events come as one message when it ends) and
49
+ `exclude` (sessions a subscription to all leaves out). Questions and errors
50
+ still come at once, held events survive a restart, and the hourly limit
51
+ stays on top. A turn the watcher prompted itself and whose finished reply it
52
+ waited for no longer wakes it a second time with turn_end.
53
+ `list_subscriptions` shows how many events each subscription holds and when
54
+ they are due, and the watching chip shows a small digest or quiet-hours hint.
55
+ - **Scheduled prompts that survive a restart.** `remote_relay` schedule
56
+ queues a prompt at a time (`at`) or every N minutes (`every_minutes`, 5 to
57
+ 10080), into the agent's own conversation or, with the user's approval in
58
+ ask and plan mode, another session on this relay. The relay keeps it in its
59
+ database, so a nightly review or a sweep no longer has to be set up again
60
+ after a deploy. A time missed while the relay was down fires once when it is
61
+ back, and a repeat waits while the one before is still queued. The message
62
+ says it is automated and approves nothing. `list_schedules` and `unschedule`
63
+ manage them, archiving a session ends its schedules, and a conversation
64
+ holds at most 20. The sidebar marks a scheduled session, and its header
65
+ lists the schedules with a Cancel button each.
66
+ - **Questions that don't block, and the Inbox.** `remote_relay` ask puts a
67
+ question for you into the agent's session (up to eight options, a
68
+ recommendation, a folded detail, and urgency `now` for a push) and returns
69
+ at once, so the agent goes on with what does not depend on it. Your answer
70
+ or dismissal reaches it as a relay event, question_answered, without a
71
+ subscription, and does not count as your message for the agents' loop
72
+ guard. A lead can ask on behalf of another session, on this relay or a
73
+ paired one (kept where that session lives); `list_questions` and `withdraw`
74
+ manage them, and `read_session` lists them with their kind. The new
75
+ **Inbox** in the sidebar, with its count, lists every pending question
76
+ across this relay and the paired relays that answer, grouped by relay,
77
+ with a filter for your sessions or the agents', and what you answered in
78
+ the last day under a divider. An ask is answered there or in its session,
79
+ also one on a paired relay; a paired relay's cards open there, and the
80
+ pairing never carries an answer to a card. Agent text shows as plain text,
81
+ and on a phone the Inbox is one column with full-width buttons.
82
+
83
+ ### Fixed
84
+
85
+ - **The agents' loop guard no longer counts the relay's own messages as the
86
+ user's.** A relay event, the usage-limit follow-up or the note after a
87
+ restart reset the count of prompts an agent may send to other agents
88
+ before the user is asked, so two agents that watched each other could go
89
+ on without the user. Only a message the user wrote resets it now.
90
+
6
91
  ## [0.9.15] — 2026-10-22
7
92
 
8
93
  Highlights: `oar setup` asks how the relay starts on Windows, at sign-in or
package/README.md CHANGED
@@ -45,29 +45,29 @@ flowchart LR
45
45
  <table>
46
46
  <tr>
47
47
  <td width="42%" rowspan="4" valign="top">
48
- <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.15"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.15"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.15" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
48
+ <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.16" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
49
49
  <br><sub>On a phone: the same conversations, installable as an app.</sub>
50
50
  </td>
51
51
  <td width="58%" valign="top">
52
- <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.15"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.15"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.15" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
52
+ <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.16" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
53
53
  <br><sub>Agents working in parallel, each with live token counts and its own Stop.</sub>
54
54
  </td>
55
55
  </tr>
56
56
  <tr>
57
57
  <td valign="top">
58
- <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.15"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.15"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.15" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
58
+ <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.16" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
59
59
  <br><sub>When the agent needs a decision, it asks, and waits for your answer.</sub>
60
60
  </td>
61
61
  </tr>
62
62
  <tr>
63
63
  <td valign="top">
64
- <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.15"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.15"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.15" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
64
+ <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.16" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
65
65
  <br><sub>Read the reasoning, not just the answer.</sub>
66
66
  </td>
67
67
  </tr>
68
68
  <tr>
69
69
  <td valign="top">
70
- <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.15"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.15"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.15" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
70
+ <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.16" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
71
71
  <br><sub>Agents that watch agents: woken by events, approving nothing.</sub>
72
72
  </td>
73
73
  </tr>
@@ -116,11 +116,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
116
116
  <table>
117
117
  <tr>
118
118
  <td width="62%" valign="top">
119
- <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.15"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.15"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.15" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
119
+ <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.16" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
120
120
  <br><sub>Send a message while the agent works, and it steers the running turn.</sub>
121
121
  </td>
122
122
  <td width="38%" valign="top">
123
- <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.15"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.15"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.15" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
123
+ <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.16" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
124
124
  <br><sub>What each plan has left, in one place.</sub>
125
125
  </td>
126
126
  </tr>
@@ -137,7 +137,7 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
137
137
  - **Share** a conversation as a read-only link, hide single messages and the host's paths from it, and see when it was opened ([Sharing](docs/web-ui.md#sharing)).
138
138
  - **On your phone**: install OAR as an app and get push notifications ([Notifications and the app](docs/web-ui.md#notifications-and-the-app)).
139
139
 
140
- The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.15](CHANGELOG.md#0915--2026-10-22) as the latest.
140
+ The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.16](CHANGELOG.md#0916--2026-10-24) as the latest.
141
141
 
142
142
  ## Remote access
143
143
 
@@ -188,7 +188,7 @@ flowchart LR
188
188
 
189
189
  You stay in charge: per paired relay you decide whether agents may only read, also prompt, or do everything, and in the `ask` and `plan` modes every write asks you first. A subscription only notifies; it approves nothing.
190
190
 
191
- <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.15"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.15"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.15" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
191
+ <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.16" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
192
192
 
193
193
  How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
194
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -13,7 +13,9 @@ import {
13
13
  REMOTE_RELAY_MODES,
14
14
  REMOTE_RELAY_PROVIDERS,
15
15
  REMOTE_RELAY_SESSION_SCOPES,
16
+ REMOTE_RELAY_THINKING_DISPLAYS,
16
17
  } from '../../shared/remote-relay-contract.mjs';
18
+ import { RELAY_ASK_URGENCIES } from '../../shared/relay-asks.mjs';
17
19
  import {
18
20
  REMOTE_RELAY_CALL_FAILED,
19
21
  REMOTE_RELAY_TOOL_DESCRIPTION,
@@ -67,7 +69,21 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
67
69
  mode: choice('mode', REMOTE_RELAY_MODES),
68
70
  effort: text('effort'),
69
71
  title: text('title'),
72
+ // The one exception to the parity with the JSON schema: Claude may send
73
+ // null for update_session's group (out of the group), as "none" does on
74
+ // the providers whose strict schemas take a string only.
75
+ group: z.string().nullable().optional().describe(FIELDS.group.description),
76
+ auto_compact_window: number('auto_compact_window'),
77
+ thinking_enabled: z.boolean().optional().describe(FIELDS.thinking_enabled.description),
78
+ thinking_display: choice('thinking_display', REMOTE_RELAY_THINKING_DISPLAYS),
70
79
  question_id: text('question_id'),
80
+ // ask: the contract's validator refuses a question or detail that is too
81
+ // long, or more than eight options, with a message the model can act on.
82
+ question: text('question'),
83
+ options: z.array(z.string()).optional().describe(FIELDS.options.description),
84
+ recommendation: text('recommendation'),
85
+ detail: text('detail'),
86
+ urgency: choice('urgency', RELAY_ASK_URGENCIES),
71
87
  answer: text('answer'),
72
88
  choices: z.array(z.string()).optional().describe(FIELDS.choices.description),
73
89
  if_busy: choice('if_busy', REMOTE_RELAY_IF_BUSY),
@@ -78,18 +94,37 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
78
94
  match: text('match'),
79
95
  stalled_minutes: number('stalled_minutes'),
80
96
  heartbeat_minutes: number('heartbeat_minutes'),
97
+ digest_minutes: number('digest_minutes'),
98
+ // Loose and every field optional: the contract's validator checks the
99
+ // times and the zone and says what is wrong; an empty one is no option.
100
+ quiet_hours: z.looseObject({
101
+ from: z.string().optional().describe(FIELDS.quiet_hours.properties.from.description),
102
+ to: z.string().optional().describe(FIELDS.quiet_hours.properties.to.description),
103
+ tz: z.string().optional().describe(FIELDS.quiet_hours.properties.tz.description),
104
+ }).optional().describe(FIELDS.quiet_hours.description),
105
+ // A list, or the comma-separated text the contract takes as well.
106
+ exclude: z.union([z.array(z.string()), z.string()]).optional().describe(FIELDS.exclude.description),
81
107
  subscription_id: text('subscription_id'),
82
108
  watcher: text('watcher'),
83
109
  from: text('from'),
110
+ at: text('at'),
111
+ every_minutes: number('every_minutes'),
112
+ schedule_id: text('schedule_id'),
84
113
  task_id: text('task_id'),
85
114
  lines: number('lines'),
86
115
  };
87
116
 
117
+ // What the tool registers: the shape as a loose object. A raw shape becomes a
118
+ // strict-stripping object in the SDK's MCP layer, which would drop a key the
119
+ // schema lacks before the contract's validator could refuse it (create_session)
120
+ // or name it in `ignored` (the other actions). The loose object lets it through.
121
+ export const REMOTE_RELAY_TOOL_ZOD_SCHEMA = z.looseObject(REMOTE_RELAY_TOOL_ZOD_SHAPE);
122
+
88
123
  export function createRemoteRelayToolDefinition({ api, getConversationId = () => '', dbg = () => {} } = {}) {
89
124
  return {
90
125
  name: REMOTE_RELAY_TOOL_NAME,
91
126
  description: REMOTE_RELAY_TOOL_DESCRIPTION,
92
- inputSchema: REMOTE_RELAY_TOOL_ZOD_SHAPE,
127
+ inputSchema: REMOTE_RELAY_TOOL_ZOD_SCHEMA,
93
128
  // `extra.signal` is the MCP request's: the SDK aborts it when the CLI
94
129
  // cancels the call (a Stop interrupting the turn) or the process's MCP
95
130
  // transport closes, and the pending relay request goes with it.
@@ -53,7 +53,7 @@ import {
53
53
  STEER_STOPPED_TEXT,
54
54
  } from '../../shared/steer-settle-markers.mjs';
55
55
  import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
56
- import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
56
+ import { isRelayAutomatedDelivery } from '../../shared/relay-events.mjs';
57
57
  import {
58
58
  stripSlashCommandGuard,
59
59
  stripSlashCommandGuardContent,
@@ -4209,9 +4209,9 @@ export function createClaudeSessionRunner({
4209
4209
  // noted steer no longer starts with "/" and is pushed as noted. The
4210
4210
  // replay signatures strip the guard too, so expectedText stays the
4211
4211
  // user's own text here as well.
4212
- // Nor into an event message the relay wrote: the note speaks as the
4213
- // user, the event must not (shared/relay-events.mjs).
4214
- const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !isRelayEventDelivery(message)
4212
+ // Nor into a message the relay wrote (an event, a scheduled prompt):
4213
+ // the note speaks as the user, that message must not (shared/relay-events.mjs).
4214
+ const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !isRelayAutomatedDelivery(message)
4215
4215
  ? withSteerNoteContent(content)
4216
4216
  : content);
4217
4217
  procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now(), steered });
@@ -112,7 +112,7 @@ import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT, STEER_TURN_FAILED_TEXT } from '.
112
112
  import { buildSteerSettleFailure } from '../../shared/steer-settle-failure.mjs';
113
113
  import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
114
114
  import { withSteerNote } from '../../shared/steer-note.mjs';
115
- import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
115
+ import { isRelayAutomatedDelivery } from '../../shared/relay-events.mjs';
116
116
  import { isTransientRelayError } from '../../shared/worker-runtime/relay-errors.mjs';
117
117
  import {
118
118
  createTurnLiveness,
@@ -4456,7 +4456,7 @@ export function createCopilotSdkSessionRunner({
4456
4456
  // Not in a continuation: there the running work is the runtime's own
4457
4457
  // follow-up, not a request of the user's to keep going with.
4458
4458
  // Nor in an event message the relay wrote: the note speaks as the user.
4459
- const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' || isRelayEventDelivery(message) ? body : withSteerNote(body));
4459
+ const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' || isRelayAutomatedDelivery(message) ? body : withSteerNote(body));
4460
4460
  // Re-checked AFTER the awaits and before anything is sent or registered.
4461
4461
  // Nothing has been sent yet, so handing the row back to the normal path is
4462
4462
  // free.
@@ -24,6 +24,11 @@ import { migrateConversationGroupPosition } from './migrations/0015-conversation
24
24
  import { migrateShareAccessLog } from './migrations/0016-share-access-log.mjs';
25
25
  import { migrateConversationHistoryIndexes } from './migrations/0017-conversation-history-indexes.mjs';
26
26
  import { migrateBackgroundTaskRows } from './migrations/0018-background-task-rows.mjs';
27
+ import { migrateRelayEventSubscriptionOptions } from './migrations/0019-relay-event-subscription-options.mjs';
28
+ import { migrateConversationMayCreateSessions } from './migrations/0020-conversation-may-create-sessions.mjs';
29
+ import { migrateConversationGroupAgentMarks } from './migrations/0021-conversation-group-agent-marks.mjs';
30
+ import { migrateRelaySchedules } from './migrations/0022-relay-schedules.mjs';
31
+ import { migrateRelayAsks } from './migrations/0023-relay-asks.mjs';
27
32
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
28
33
 
29
34
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -855,6 +860,53 @@ try {
855
860
  console.warn(`[background-tasks] migration 0018 failed; retrying on next boot: ${error?.message || error}`);
856
861
  }
857
862
 
863
+ // Options of relay event subscriptions (digest, quiet hours, exclude) and the
864
+ // watcher's own prompts: columns on 0011's table and one table. The event
865
+ // service checks for them and refuses the options without them, so a failure
866
+ // is logged and retried next boot like 0018.
867
+ try {
868
+ migrateRelayEventSubscriptionOptions(db);
869
+ } catch (error) {
870
+ console.warn(`[relay-events] migration 0019 failed; retrying on next boot: ${error?.message || error}`);
871
+ }
872
+
873
+ // The owner's "agents in this conversation may start sessions": one column.
874
+ // Without it nobody can turn the switch on (the preferences route refuses
875
+ // it) and the one-level rule holds for every session, so a failure is logged
876
+ // and retried next boot like 0019.
877
+ try {
878
+ migrateConversationMayCreateSessions(db);
879
+ } catch (error) {
880
+ console.warn(`[remote-relays] migration 0020 failed; retrying on next boot: ${error?.message || error}`);
881
+ }
882
+
883
+ // What an agent's update_session may do to a group: two columns. Without
884
+ // them an owner's group an agent emptied is kept until the next boot repair
885
+ // only, so a failure is logged and retried next boot like 0020.
886
+ try {
887
+ migrateConversationGroupAgentMarks(db);
888
+ } catch (error) {
889
+ console.warn(`[session-groups] migration 0021 failed; retrying on next boot: ${error?.message || error}`);
890
+ }
891
+
892
+ // Relay schedules: one new table. The schedule service checks for it and
893
+ // refuses the schedule actions without it, so a failure is logged and
894
+ // retried next boot like 0021.
895
+ try {
896
+ migrateRelaySchedules(db);
897
+ } catch (error) {
898
+ console.warn(`[relay-schedules] migration 0022 failed; retrying on next boot: ${error?.message || error}`);
899
+ }
900
+
901
+ // Questions that don't block (remote_relay ask): one new table. The ask
902
+ // service checks for it and refuses the ask actions without it, so a failure
903
+ // is logged and retried next boot like 0022.
904
+ try {
905
+ migrateRelayAsks(db);
906
+ } catch (error) {
907
+ console.warn(`[relay-asks] migration 0023 failed; retrying on next boot: ${error?.message || error}`);
908
+ }
909
+
858
910
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
859
911
  if (runtimeSessionColumns.length) {
860
912
  if (!runtimeSessionColumns.includes('strategy')) {
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: options of relay event subscriptions.
5
+ *
6
+ * Columns on `relay_event_subscriptions` (all NULL = off, as before):
7
+ *
8
+ * - `digest_minutes`: the subscription's non-urgent events are held and
9
+ * delivered as one row every N minutes; `digest_last_at` is when its last
10
+ * such row went out (the next one is due N minutes after it).
11
+ * - `quiet_hours_json`: `{ from, to, tz }`; non-urgent events in the window
12
+ * are held until it ends.
13
+ * - `exclude_json`: session ids an "all" subscription leaves out.
14
+ *
15
+ * `relay_event_own_prompts`: the prompts a watcher sent through send or
16
+ * create_session (`message_id` is the prompt's), and when it received the
17
+ * finished reply (`received_at`): the turn_end of such a turn is not
18
+ * delivered to it again. Rows go after a day.
19
+ *
20
+ * Held events are ordinary undelivered `relay_events` rows: they and the
21
+ * timers rebuilt from them survive a restart without anything more here.
22
+ *
23
+ * Additive and idempotent. Safe to run on every boot.
24
+ *
25
+ * Usage: node server/migrations/0019-relay-event-subscription-options.mjs [path/to/copilot.db]
26
+ */
27
+
28
+ import { fileURLToPath } from 'url';
29
+ import path from 'path';
30
+ import process from 'process';
31
+ import Database from 'better-sqlite3';
32
+
33
+ export const RELAY_EVENT_OPTION_COLUMNS = Object.freeze([
34
+ ['digest_minutes', 'INTEGER'],
35
+ ['digest_last_at', 'TEXT'],
36
+ ['quiet_hours_json', 'TEXT'],
37
+ ['exclude_json', 'TEXT'],
38
+ ]);
39
+
40
+ function tableExists(db, name) {
41
+ return !!db
42
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
43
+ .get(name);
44
+ }
45
+
46
+ export function migrateRelayEventSubscriptionOptions(db) {
47
+ if (!tableExists(db, 'relay_event_subscriptions')) {
48
+ throw new Error('relay_event_subscriptions is missing (migration 0011 has not run)');
49
+ }
50
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_event_subscriptions)`).all().map((column) => column.name));
51
+ let columnsAdded = 0;
52
+ for (const [name, type] of RELAY_EVENT_OPTION_COLUMNS) {
53
+ if (present.has(name)) continue;
54
+ db.exec(`ALTER TABLE relay_event_subscriptions ADD COLUMN ${name} ${type}`);
55
+ columnsAdded += 1;
56
+ }
57
+ let tablesAdded = 0;
58
+ if (!tableExists(db, 'relay_event_own_prompts')) {
59
+ db.exec(`
60
+ CREATE TABLE relay_event_own_prompts (
61
+ message_id TEXT PRIMARY KEY,
62
+ watcher_conversation_id TEXT NOT NULL,
63
+ relay_id TEXT NOT NULL DEFAULT 'self',
64
+ session_id TEXT NOT NULL,
65
+ created_at TEXT NOT NULL,
66
+ received_at TEXT
67
+ )
68
+ `);
69
+ tablesAdded += 1;
70
+ }
71
+ db.exec(`
72
+ CREATE INDEX IF NOT EXISTS idx_relay_event_own_prompts_created
73
+ ON relay_event_own_prompts(created_at)
74
+ `);
75
+ return { applied: columnsAdded > 0 || tablesAdded > 0, columnsAdded, tablesAdded };
76
+ }
77
+
78
+ function defaultDbPath() {
79
+ const here = path.dirname(fileURLToPath(import.meta.url));
80
+ return path.resolve(here, '..', 'data', 'copilot.db');
81
+ }
82
+
83
+ export function migrate(dbPath, options = {}) {
84
+ const db = new Database(dbPath, options);
85
+ try {
86
+ return migrateRelayEventSubscriptionOptions(db);
87
+ } finally {
88
+ db.close();
89
+ }
90
+ }
91
+
92
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
93
+ if (isMain) {
94
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
95
+ try {
96
+ const result = migrate(dbPath);
97
+ console.log(`Migration 0019 on ${dbPath}`);
98
+ console.log(result.applied
99
+ ? ` relay event subscription options: columns added ${result.columnsAdded}, tables added ${result.tablesAdded}`
100
+ : ' skipped: nothing to change');
101
+ process.exit(0);
102
+ } catch (err) {
103
+ console.error(`Migration 0019 failed: ${err?.message || err}`);
104
+ process.exit(1);
105
+ }
106
+ }
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the owner's "agents in this conversation may start sessions".
5
+ *
6
+ * `may_create_sessions` is 1 when the owner allowed the agents of a session
7
+ * that an agent started to start sessions on this relay themselves (the
8
+ * remote_relay tool's one-level rule is lifted for it); NULL or 0 otherwise.
9
+ * Only the owner sets it, in the conversation's settings or through the
10
+ * preferences route; no agent can.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
13
+ * data change: older code ignores the column and keeps the one-level rule.
14
+ *
15
+ * Usage: node server/migrations/0020-conversation-may-create-sessions.mjs [path/to/copilot.db]
16
+ */
17
+
18
+ import { fileURLToPath } from 'url';
19
+ import path from 'path';
20
+ import process from 'process';
21
+ import Database from 'better-sqlite3';
22
+
23
+ function tableExists(db, name) {
24
+ return !!db
25
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
26
+ .get(name);
27
+ }
28
+
29
+ export function migrateConversationMayCreateSessions(db) {
30
+ if (!tableExists(db, 'conversations')) {
31
+ throw new Error('conversations is missing (the base schema has not run)');
32
+ }
33
+ const present = new Set(db.prepare(`PRAGMA table_info(conversations)`).all().map((column) => column.name));
34
+ if (present.has('may_create_sessions')) return { applied: false, columnsAdded: 0 };
35
+ db.exec(`ALTER TABLE conversations ADD COLUMN may_create_sessions INTEGER`);
36
+ return { applied: true, columnsAdded: 1 };
37
+ }
38
+
39
+ function defaultDbPath() {
40
+ const here = path.dirname(fileURLToPath(import.meta.url));
41
+ return path.resolve(here, '..', 'data', 'copilot.db');
42
+ }
43
+
44
+ export function migrate(dbPath, options = {}) {
45
+ const db = new Database(dbPath, options);
46
+ try {
47
+ return migrateConversationMayCreateSessions(db);
48
+ } finally {
49
+ db.close();
50
+ }
51
+ }
52
+
53
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
54
+ if (isMain) {
55
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
56
+ try {
57
+ const result = migrate(dbPath);
58
+ console.log(`Migration 0020 on ${dbPath}`);
59
+ console.log(result.applied
60
+ ? ` may create sessions: columns added ${result.columnsAdded}`
61
+ : ' skipped: nothing to change');
62
+ process.exit(0);
63
+ } catch (err) {
64
+ console.error(`Migration 0020 failed: ${err?.message || err}`);
65
+ process.exit(1);
66
+ }
67
+ }
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: what an agent's update_session may do to a session group.
5
+ *
6
+ * - `conversation_groups.agent_made` is 1 for a group update_session made
7
+ * (a name the relay did not have). Only such a group goes once an agent
8
+ * takes its last member out.
9
+ * - `conversation_groups.kept_empty_at` is set when an agent took the last
10
+ * member out of a group the owner made: the group stays (empty), the boot
11
+ * repair and the sweep leave it alone, and agents can still name it. The
12
+ * mark goes as soon as a member joins again.
13
+ *
14
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
15
+ * data change: older code ignores both columns (its boot repair removes an
16
+ * empty group, as it always did).
17
+ *
18
+ * Usage: node server/migrations/0021-conversation-group-agent-marks.mjs [path/to/copilot.db]
19
+ */
20
+
21
+ import { fileURLToPath } from 'url';
22
+ import path from 'path';
23
+ import process from 'process';
24
+ import Database from 'better-sqlite3';
25
+
26
+ function tableExists(db, name) {
27
+ return !!db
28
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
29
+ .get(name);
30
+ }
31
+
32
+ export function migrateConversationGroupAgentMarks(db) {
33
+ if (!tableExists(db, 'conversation_groups')) {
34
+ throw new Error('conversation_groups is missing (migration 0014 has not run)');
35
+ }
36
+ const present = new Set(db.prepare(`PRAGMA table_info(conversation_groups)`).all().map((column) => column.name));
37
+ let columnsAdded = 0;
38
+ if (!present.has('agent_made')) {
39
+ db.exec(`ALTER TABLE conversation_groups ADD COLUMN agent_made INTEGER`);
40
+ columnsAdded += 1;
41
+ }
42
+ if (!present.has('kept_empty_at')) {
43
+ db.exec(`ALTER TABLE conversation_groups ADD COLUMN kept_empty_at TEXT`);
44
+ columnsAdded += 1;
45
+ }
46
+ return { applied: columnsAdded > 0, columnsAdded };
47
+ }
48
+
49
+ function defaultDbPath() {
50
+ const here = path.dirname(fileURLToPath(import.meta.url));
51
+ return path.resolve(here, '..', 'data', 'copilot.db');
52
+ }
53
+
54
+ export function migrate(dbPath, options = {}) {
55
+ const db = new Database(dbPath, options);
56
+ try {
57
+ return migrateConversationGroupAgentMarks(db);
58
+ } finally {
59
+ db.close();
60
+ }
61
+ }
62
+
63
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
64
+ if (isMain) {
65
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
66
+ try {
67
+ const result = migrate(dbPath);
68
+ console.log(`Migration 0021 on ${dbPath}`);
69
+ console.log(result.applied
70
+ ? ` group agent marks: columns added ${result.columnsAdded}`
71
+ : ' skipped: nothing to change');
72
+ process.exit(0);
73
+ } catch (err) {
74
+ console.error(`Migration 0021 failed: ${err?.message || err}`);
75
+ process.exit(1);
76
+ }
77
+ }