@oar-sh/oar 0.9.19 → 0.9.21

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 (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -191,6 +191,14 @@ async function handleNotificationClick(event) {
191
191
  if (ok) return;
192
192
  }
193
193
  }
194
+ // A card on a paired relay: it carries no question id, only the
195
+ // link of its session there, and is answered there ("Open there").
196
+ const url = String(data.url || '');
197
+ if (!data.questionId && /^https?:\/\//i.test(url)) {
198
+ event.notification.close();
199
+ await self.clients.openWindow(url);
200
+ return;
201
+ }
194
202
  event.notification.close();
195
203
  await focusOrOpenConversation(String(data.conversationId || ''));
196
204
  }
@@ -141,7 +141,9 @@ export function configuredHeapLimit({ configPath, fsImpl = fs } = {}) {
141
141
  return Number.isInteger(mb) && mb >= MIN_HEAP_LIMIT_MB ? { mb } : { invalid: value };
142
142
  }
143
143
 
144
- function freeBytes(dir, fsImpl) {
144
+ // Free bytes on the disk of `dir`, or null when unknown. Also used by the
145
+ // database backup's space check (db-backup-service.mjs), with diskBytes.
146
+ export function freeBytes(dir, fsImpl) {
145
147
  try {
146
148
  if (typeof fsImpl.statfsSync !== 'function') return null;
147
149
  const stats = fsImpl.statfsSync(dir);
@@ -151,6 +153,17 @@ function freeBytes(dir, fsImpl) {
151
153
  }
152
154
  }
153
155
 
156
+ // The size of the disk of `dir`, or null when unknown.
157
+ export function diskBytes(dir, fsImpl) {
158
+ try {
159
+ if (typeof fsImpl.statfsSync !== 'function') return null;
160
+ const stats = fsImpl.statfsSync(dir);
161
+ return Number(stats.blocks) * Number(stats.bsize);
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
154
167
  /** A path for the log: `~` for the home folder, so a log line names no user. */
155
168
  export function displayPath(target, homeDir = os.homedir()) {
156
169
  const resolved = path.resolve(String(target || ''));
@@ -1,7 +1,8 @@
1
- import { spawn } from 'child_process';
1
+ import { spawn, spawnSync } from 'child_process';
2
2
  import path from 'path';
3
3
  import { RELAY_RESTART_EXIT_CODE, RELAY_STOP_EXIT_CODE } from './relay-exit-codes.mjs';
4
4
  import { relayRuntimeNodeFlags, relayRuntimeNodeFlagsOrNone } from './relay-heap-guard.mjs';
5
+ import { detectRelayHosting } from './services/relay-restart-picture.mjs';
5
6
 
6
7
  // Role selection travels on argv, never in the environment. The runtime's env is
7
8
  // inherited by tmux worker sessions and by the Copilot CLI it launches, and the
@@ -76,6 +77,64 @@ function waitForChildExit(child) {
76
77
  });
77
78
  }
78
79
 
80
+ // A systemctl --user call of the handover: its result, never a throw.
81
+ function runSystemctl(spawnSyncImpl, args) {
82
+ try {
83
+ return spawnSyncImpl('systemctl', ['--user', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 }) || { status: null };
84
+ } catch (error) {
85
+ return { status: null, error };
86
+ }
87
+ }
88
+
89
+ function systemctlFailure(result) {
90
+ if (result.error) return result.error.message || String(result.error);
91
+ const said = String(result.stderr || '').trim().split('\n')[0];
92
+ return `status ${result.status ?? 'none'}${said ? `: ${said}` : ''}`;
93
+ }
94
+
95
+ /**
96
+ * The runtime asked for a restart (exit 75). Under a user service whose main
97
+ * process this supervisor is, systemd restarts the whole service, so the new
98
+ * supervisor and runtime run the code on disk with fresh Node flags: true
99
+ * once systemd's stop (SIGTERM) has come. False, with one log line, wherever
100
+ * that cannot be done (not the unit's main process: a relay started inside a
101
+ * worker of the service), systemctl fails, or no stop comes within
102
+ * `handoverWaitMs`; the caller relaunches the runtime itself then.
103
+ */
104
+ async function handRestartToSystemd({ hosting, spawnSyncImpl, pid, handoverWaitMs, shutdownRequested, isShuttingDown, delay, logger }) {
105
+ if (!hosting?.systemd || !hosting.userManager || !hosting.unit) return false;
106
+ const { unit } = hosting;
107
+ const main = runSystemctl(spawnSyncImpl, ['show', '-p', 'MainPID', '--value', unit]);
108
+ const mainPid = Number(String(main.stdout ?? '').trim());
109
+ if (main.status !== 0 || mainPid !== pid) {
110
+ const whose = main.status === 0 ? `its main process is ${mainPid || 'none'}` : `its main process could not be read (${systemctlFailure(main)})`;
111
+ logger?.log?.(`[relay] restart: not handed to systemd, ${unit} is not this supervisor's (pid ${pid}; ${whose}).`);
112
+ return false;
113
+ }
114
+ // Clears a failed state and the restart backoff; restart's answer alone decides.
115
+ runSystemctl(spawnSyncImpl, ['reset-failed', unit]);
116
+ // A stop that came in meanwhile wins: a queued restart could replace its job.
117
+ // The calls above block; a signal that arrived during them waits on the
118
+ // signal pipe, which only the loop's poll phase reads. This runs from the
119
+ // runtime's 'exit' callback, itself in a poll phase, so one setImmediate
120
+ // would run in the check phase right after it, before the pipe is read;
121
+ // the second runs after the next poll phase has delivered the signal.
122
+ await new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
123
+ if (isShuttingDown()) {
124
+ logger?.log?.(`[relay] restart: a stop came in; not restarting ${unit}.`);
125
+ return true;
126
+ }
127
+ const restart = runSystemctl(spawnSyncImpl, ['restart', '--no-block', unit]);
128
+ if (restart.status !== 0) {
129
+ logger?.log?.(`[relay] restart: systemctl --user restart --no-block ${unit} failed (${systemctlFailure(restart)}).`);
130
+ return false;
131
+ }
132
+ logger?.log?.(`[relay] restart handed to systemd: ${unit} restarts with the code on disk.`);
133
+ const stopped = await Promise.race([shutdownRequested.then(() => true), Promise.resolve(delay(handoverWaitMs)).then(isShuttingDown)]);
134
+ if (!stopped) logger?.log?.(`[relay] restart: systemd did not stop ${unit} within ${Math.round(handoverWaitMs / 1000)} s.`);
135
+ return stopped;
136
+ }
137
+
79
138
  export async function runDirectRelaySupervisor({
80
139
  scriptPath,
81
140
  args = [],
@@ -96,16 +155,26 @@ export async function runDirectRelaySupervisor({
96
155
  // crashes days apart are not a crash loop.
97
156
  crashCountResetMs = 10 * 60 * 1000,
98
157
  now = Date.now,
158
+ // How this process is run (detectRelayHosting); read at the first restart
159
+ // request when not given.
160
+ hosting = null,
161
+ spawnSyncImpl = spawnSync,
162
+ pid = process.pid,
163
+ // How long a restart handed to systemd waits for its SIGTERM.
164
+ handoverWaitMs = 30_000,
99
165
  } = {}) {
100
166
  let runtimeProc = null;
101
167
  let shuttingDown = false;
102
168
  let shutdownExitCode = 0;
169
+ let shutdownArrived;
170
+ const shutdownRequested = new Promise((resolve) => { shutdownArrived = resolve; });
103
171
 
104
172
  const requestShutdown = (signal = 'SIGTERM', exitCode = 0) => {
105
173
  if (shuttingDown) return;
106
174
  shuttingDown = true;
107
175
  shutdownExitCode = exitCode;
108
176
  stopChildProcess(runtimeProc, { signal });
177
+ shutdownArrived();
109
178
  };
110
179
 
111
180
  const handleSigInt = () => requestShutdown('SIGINT', 0);
@@ -165,8 +234,20 @@ export async function runDirectRelaySupervisor({
165
234
  : null;
166
235
  const signal = exitCode === null ? (result.signal || null) : null;
167
236
  if (exitCode === RELAY_RESTART_EXIT_CODE) {
168
- logger?.log?.(`[relay] runtime requested restart; relaunching ${path.basename(String(scriptPath || ''))}...`);
169
237
  crashCount = 0;
238
+ const handedOver = await handRestartToSystemd({
239
+ hosting: hosting || detectRelayHosting({ env }),
240
+ spawnSyncImpl,
241
+ pid,
242
+ handoverWaitMs,
243
+ shutdownRequested,
244
+ isShuttingDown: () => shuttingDown,
245
+ delay,
246
+ logger,
247
+ });
248
+ // systemd's stop: this supervisor ends, systemd starts the next one.
249
+ if (handedOver || shuttingDown) return await exitImpl(shutdownExitCode);
250
+ logger?.log?.(`[relay] runtime requested restart; relaunching ${path.basename(String(scriptPath || ''))}...`);
170
251
  await delay(restartDelayMs);
171
252
  continue;
172
253
  }
@@ -39,10 +39,12 @@ A paired relay stays locked until the user mentions it in this conversation (`@n
39
39
 
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
- - `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". The provider ids are the target relay's, as `relay_info` shows them; an id it does not know is refused with the list it has. A session started before a relay update can use providers the update added, without a restart. At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
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". The provider ids are the target relay's, as `relay_info` shows them; an id it does not know is refused with the list it has. A session started before a relay update can use providers the update added, without a restart. Without "mode" the new session gets your session's relay mode. At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
43
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
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.
45
+ - `list_sessions` and `read_session` show a session's worker process, while it has one, as "worker" (kind, buildId, stale); "stale": true means it runs older code than that relay, which replaces it by itself once the session is idle, so there is nothing to do about it.
46
+ - `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd", "planOnly") 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 `update_session` that names another mode; a `send` that names a mode runs only that turn in it, and one without "mode" leaves the session in its own mode.
47
+ - "planOnly": true makes a plan-only session: agents can't take it out of plan mode. Set it only when the user asks; plan mode alone never sets it. `create_session` with "planOnly": true and no "mode" starts in plan; with another mode named it is refused and nothing is created. A target relay too old for planOnly creates the session without the lock and says so in "planOnlyWarning". `update_session` with "planOnly": true sets it on an existing session (with "mode": "plan" unless it is in plan already). While it is set, a `send` or `update_session` that names another mode is refused with 409, code "plan_only" ("This session is plan-only; agents can't change its mode. Clear planOnly first, or ask the user."); a `send` without "mode" runs in the session's saved mode. To clear it, pass "planOnly": false in the same `update_session` that names the new mode (`{"planOnly":false,"mode":"agent"}`); alone, or with "mode": "plan", it is ignored, and on a plan-only session the answer notes "planOnly unchanged: clear it in the same call as the new mode." Use `update_session` for all this, not the preferences route (it refuses an agent's mode change with 409 and any planOnly change with 403). `list_sessions` and `read_session` show "planOnly": true. In a plan-only conversation you count as a plan-mode caller, also in the one agent turn after "Approve plan": your `create_session` defaults to plan and your write actions ask the user first.
46
48
  - 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.
47
49
  - 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. Copilot ids use dots (`claude-opus-5.5`, not the Claude form `claude-opus-5-5`); a hyphen form is refused with the close match named, and a Copilot session never moves to another provider because of its model name. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
48
50
  - 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.
@@ -51,7 +53,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
51
53
  - 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".
52
54
  - `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.
53
55
 
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.
56
+ 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, board_settled) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls, all_idle and a plan card the user settled (board_settled: the button chosen and the message it queued, if any), 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.
55
57
 
56
58
  Each wake-up costs you a turn; these `subscribe` options cut them (questions and errors always come at once):
57
59
  - "digest_minutes": N gathers the subscription's other events into one message every N minutes (1 to 1440).
@@ -5,6 +5,33 @@ import { createMessagePinStatements } from './message-pin-statements.mjs';
5
5
  import { createConversationHistoryPageStatements } from '../services/conversation-history-page.mjs';
6
6
  import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
7
7
 
8
+ /**
9
+ * The claim's plan-boundary hold, part of every findPending*
10
+ * statement: a pending row waits while its conversation has a live turn on
11
+ * the other side of plan mode, because handing it to the worker would steer
12
+ * it into that turn and switch what the turn may do. The conversation's later
13
+ * rows wait behind it whatever their mode, so one conversation keeps its
14
+ * order, while the claim goes on to other conversations' rows.
15
+ * `PLAN_BOUNDARY_HOLD_MARK` lets a test find any claim without it.
16
+ */
17
+ export const PLAN_BOUNDARY_HOLD_MARK = '/* plan-boundary-hold */';
18
+
19
+ export function planBoundaryHoldSql(alias) {
20
+ return `${PLAN_BOUNDARY_HOLD_MARK} NOT EXISTS (
21
+ SELECT 1
22
+ FROM queue held
23
+ JOIN queue live
24
+ ON live.conversation_id = held.conversation_id
25
+ AND live.status = 'processing'
26
+ AND live.id != held.id
27
+ WHERE held.conversation_id = ${alias}.conversation_id
28
+ AND held.status = 'pending'
29
+ AND COALESCE(held.kind, '') != 'continuation'
30
+ AND (held.timestamp < ${alias}.timestamp OR (held.timestamp = ${alias}.timestamp AND held.rowid <= ${alias}.rowid))
31
+ AND (LOWER(TRIM(COALESCE(live.relay_mode, ''))) = 'plan') != (LOWER(TRIM(COALESCE(held.relay_mode, ''))) = 'plan')
32
+ )`;
33
+ }
34
+
8
35
  export function createMessageRepository(db) {
9
36
  const shareVisibility = createShareVisibilityStatements(db);
10
37
  const messagePins = createMessagePinStatements(db);
@@ -187,7 +214,7 @@ export function createMessageRepository(db) {
187
214
  // steer-recovered row waited 5 minutes while newer messages jumped
188
215
  // it). Head-of-line protection is the WHERE clause's next_attempt_at
189
216
  // backoff plus MAX_REQUEUE_RETRIES, which fails a row for good.
190
- findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY timestamp ASC, rowid ASC LIMIT 1`),
217
+ findPending: db.prepare(`SELECT q.* FROM queue q WHERE q.status = 'pending' AND COALESCE(q.kind, '') != 'continuation' AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?) AND ${planBoundaryHoldSql('q')} ORDER BY q.timestamp ASC, q.rowid ASC LIMIT 1`),
191
218
  // Global fallback for requesters without a session identity (the legacy
192
219
  // Copilot relay CLI). Conversations bound to a session-worker provider
193
220
  // (claude/cursor/grok) are excluded outright: handing one of their turns
@@ -218,6 +245,7 @@ export function createMessageRepository(db) {
218
245
  NULLIF(rsc.provider_type, ''),
219
246
  'github'
220
247
  )) NOT IN (${sessionWorkerProviderSqlList()})
248
+ AND ${planBoundaryHoldSql('q')}
221
249
  ORDER BY
222
250
  q.timestamp ASC,
223
251
  q.rowid ASC
@@ -254,6 +282,7 @@ export function createMessageRepository(db) {
254
282
  NULLIF(c.sdk_session_id, '')
255
283
  ) = ?
256
284
  )
285
+ AND ${planBoundaryHoldSql('q')}
257
286
  ORDER BY
258
287
  CASE
259
288
  WHEN COALESCE(
@@ -282,6 +311,7 @@ export function createMessageRepository(db) {
282
311
  NULLIF(rs.sdk_session_id, ''),
283
312
  NULLIF(c.sdk_session_id, '')
284
313
  ) = ?
314
+ AND ${planBoundaryHoldSql('q')}
285
315
  ORDER BY
286
316
  q.timestamp ASC,
287
317
  q.rowid ASC
@@ -431,6 +461,29 @@ export function createMessageRepository(db) {
431
461
  WHERE status = 'processing'
432
462
  AND NULLIF(owner_sdk_session_id, '') = ?
433
463
  `),
464
+ // The worker recycle's quiet window (worker-recycle-service.mjs): the
465
+ // last time the session had a delivery or a turn end, as an ISO
466
+ // string, or NULL when the queue holds no row of it. Over the rows the
467
+ // session owns: a row is stamped with its owner at enqueue under
468
+ // session-worker routing and at the latest by its claim, and a
469
+ // conversation rebound to another session leaves its old rows with the
470
+ // session that ran them. The queue keeps no completion time
471
+ // (setDone clears processing_at), so a turn's end is its response
472
+ // message's timestamp; the claim and the heartbeat lease
473
+ // (owner_last_claimed_at) and the assignment stand for the delivery.
474
+ // Each column is COALESCEd: SQLite's multi-argument MAX() is NULL as
475
+ // soon as one argument is.
476
+ getOwnerLastActivityAt: db.prepare(`
477
+ SELECT MAX(NULLIF(MAX(
478
+ COALESCE(q.processing_at, ''),
479
+ COALESCE(q.owner_assigned_at, ''),
480
+ COALESCE(q.owner_last_claimed_at, ''),
481
+ COALESCE(m.timestamp, '')
482
+ ), '')) AS last_activity_at
483
+ FROM queue q
484
+ LEFT JOIN messages m ON m.id = q.response_message_id
485
+ WHERE NULLIF(q.owner_sdk_session_id, '') = ?
486
+ `),
434
487
  recoverProcessingRowKeepOwner: db.prepare(`
435
488
  UPDATE queue
436
489
  SET status = 'pending',
@@ -75,6 +75,12 @@ export function createRemoteRelayRepository(db) {
75
75
  const getMayCreateSessions = hasMayCreateColumn
76
76
  ? db.prepare(`SELECT may_create_sessions FROM conversations WHERE id = ?`)
77
77
  : null;
78
+ // Plan-only (migration 0035). Without the column no session is.
79
+ const hasPlanOnlyColumn = db.prepare(`PRAGMA table_info(conversations)`).all()
80
+ .some((column) => column.name === 'plan_only');
81
+ const getPlanOnly = hasPlanOnlyColumn
82
+ ? db.prepare(`SELECT plan_only FROM conversations WHERE id = ?`)
83
+ : null;
78
84
 
79
85
  return {
80
86
  hasUnlock(conversationId, remoteRelayId) {
@@ -161,6 +167,10 @@ export function createRemoteRelayRepository(db) {
161
167
  mayCreateSessions(conversationId) {
162
168
  return Number(getMayCreateSessions?.get(String(conversationId || ''))?.may_create_sessions) === 1;
163
169
  },
170
+ /** Is this conversation plan-only (agents cannot take it out of plan mode)? */
171
+ planOnly(conversationId) {
172
+ return Number(getPlanOnly?.get(String(conversationId || ''))?.plan_only) === 1;
173
+ },
164
174
  };
165
175
  }
166
176
 
@@ -45,6 +45,10 @@ export function createSessionRepository(db) {
45
45
  const conversationsHaveMayCreateSessions = db.prepare(`PRAGMA table_info(conversations)`).all()
46
46
  .some((column) => String(column?.name || '').trim() === 'may_create_sessions');
47
47
  const conversationMayCreateSelect = conversationsHaveMayCreateSessions ? 'c.may_create_sessions' : 'NULL AS may_create_sessions';
48
+ // Plan-only (migration 0035), the same way.
49
+ const conversationsHavePlanOnly = db.prepare(`PRAGMA table_info(conversations)`).all()
50
+ .some((column) => String(column?.name || '').trim() === 'plan_only');
51
+ const conversationPlanOnlySelect = conversationsHavePlanOnly ? 'c.plan_only' : 'NULL AS plan_only';
48
52
  return {
49
53
  // conversations
50
54
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -53,7 +57,8 @@ export function createSessionRepository(db) {
53
57
  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'`),
54
58
  runtimeSessionsSupportProviders,
55
59
  conversationsHaveMayCreateSessions,
56
- 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`),
60
+ conversationsHavePlanOnly,
61
+ 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}, ${conversationPlanOnlySelect}, 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`),
57
62
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
58
63
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
59
64
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
@@ -95,6 +100,25 @@ export function createSessionRepository(db) {
95
100
  getRuntimeSessionBySdkSessionId: db.prepare(`SELECT * FROM runtime_sessions WHERE sdk_session_id = ?`),
96
101
  getRuntimeSessionById: db.prepare(`SELECT * FROM runtime_sessions WHERE id = ?`),
97
102
  listRuntimeSessions: db.prepare(`SELECT rs.*, c.title AS conversation_title, c.updated_at AS conversation_updated_at FROM runtime_sessions rs LEFT JOIN conversations c ON c.id = rs.conversation_id ORDER BY rs.last_used_at DESC`),
103
+ // The session ids of archived conversations: the worker sweep at relay
104
+ // start stops what still runs for them. A runtime session id is unique
105
+ // (idx_runtime_sessions_sdk_session_id), but a conversation's own
106
+ // sdk_session_id is not: an id that a conversation in use (not
107
+ // archived, not deleted) names too is left out, so that
108
+ // conversation's worker is never stopped for an archived one.
109
+ listArchivedRuntimeSessionIds: db.prepare(`
110
+ SELECT DISTINCT rs.sdk_session_id AS sdk_session_id
111
+ FROM runtime_sessions rs
112
+ JOIN conversations c ON c.id = rs.conversation_id
113
+ WHERE c.archived = 1
114
+ AND NULLIF(rs.sdk_session_id, '') IS NOT NULL
115
+ AND NOT EXISTS (
116
+ SELECT 1 FROM conversations live_c
117
+ WHERE live_c.sdk_session_id = rs.sdk_session_id
118
+ AND COALESCE(live_c.archived, 0) = 0
119
+ AND live_c.status != 'deleted'
120
+ )
121
+ `),
98
122
  insertRuntimeSession: db.prepare(runtimeSessionsSupportProviders
99
123
  ? `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id, provider_type, provider_model) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)`
100
124
  : `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`),
@@ -6,6 +6,9 @@
6
6
  // environment, account, login state
7
7
  // POST /api/settings/claude-cloud { enabled?, defaultModel?, environmentId? }
8
8
  // POST /api/claude-cloud-session the worker's binding report
9
+ // GET /api/claude-cloud-session `?conversationId=` (or the sdk session
10
+ // id) → the stored binding a starting
11
+ // worker attaches to
9
12
  // GET /api/claude-cloud/repos the repositories New Chat may suggest
10
13
  // (`?refresh=1` reads them afresh)
11
14
  // GET /api/claude-cloud/branches `?repo=<owner/name or URL>` → its branches
@@ -77,6 +80,22 @@ export function registerClaudeCloudRoutes(app, deps = {}) {
77
80
  if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
78
81
  return res.json({ ok: true, cloud: result.cloud });
79
82
  });
83
+
84
+ // GET /api/claude-cloud-session?conversationId=<id> — a starting Claude
85
+ // Cloud worker asks which cloud session it is bound to and from which
86
+ // sequence to read on, so it opens the event stream before any delivery
87
+ // (a relaunched worker follows the turns the session starts by itself
88
+ // from its start). The id may be the conversation id or the sdk session
89
+ // id. `{ ok, sessionId, lastSequence }`, `sessionId: null` when there is
90
+ // no cloud session; 404 only for an unknown conversation.
91
+ app.get('/api/claude-cloud-session', auth, (req, res) => {
92
+ touchCli();
93
+ const raw = req.query?.conversationId ?? req.query?.sdkSessionId;
94
+ const id = Array.isArray(raw) ? raw[0] : raw;
95
+ const result = claudeCloudSessionService.describeStoredBinding(typeof id === 'string' ? id : '');
96
+ if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
97
+ return res.json({ ok: true, sessionId: result.sessionId, lastSequence: result.lastSequence });
98
+ });
80
99
  }
81
100
 
82
101
  if (claudeCloudLoginStatus) {