@oar-sh/oar 0.9.18 → 0.9.20

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 (120) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
  3. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  4. package/CHANGELOG.md +359 -0
  5. package/README.md +10 -10
  6. package/bin/oar.js +391 -97
  7. package/package.json +4 -2
  8. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -2
  11. package/server/claude-worker/claude-session-process.mjs +3 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  13. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  14. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  15. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  17. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  18. package/server/db-schema.mjs +18 -0
  19. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  21. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  22. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  23. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  24. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  25. package/server/migrations/0005-remote-relays.mjs +5 -7
  26. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  27. package/server/migrations/0007-claude-cloud.mjs +5 -7
  28. package/server/migrations/0008-message-pins.mjs +5 -7
  29. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  30. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  31. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  33. package/server/migrations/0013-previews.mjs +5 -7
  34. package/server/migrations/0014-conversation-groups.mjs +5 -7
  35. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  36. package/server/migrations/0016-share-access-log.mjs +5 -7
  37. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  38. package/server/migrations/0018-background-task-rows.mjs +5 -7
  39. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  40. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  41. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  42. package/server/migrations/0022-relay-schedules.mjs +5 -7
  43. package/server/migrations/0023-relay-asks.mjs +5 -7
  44. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  45. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  46. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  47. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  48. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  49. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  50. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  51. package/server/migrations/0031-queue-provider.mjs +5 -7
  52. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  53. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  54. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  55. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  56. package/server/migrations/0036-token-usage.mjs +267 -0
  57. package/server/public/app/about-view.mjs +87 -3
  58. package/server/public/app/api-client.js +2 -1
  59. package/server/public/app/bootstrap.js +55 -2
  60. package/server/public/app/composer-control-state.mjs +12 -2
  61. package/server/public/app/context-usage-view.mjs +16 -0
  62. package/server/public/app/conversation-view.js +17 -2
  63. package/server/public/app/inbox-ui.js +32 -0
  64. package/server/public/app/inbox-view.mjs +141 -5
  65. package/server/public/app/plan-board-shared.mjs +49 -0
  66. package/server/public/app/plan-usage-view.mjs +1 -1
  67. package/server/public/app/relay-board-inline.mjs +4 -1
  68. package/server/public/app/relay-board-view.js +38 -10
  69. package/server/public/app/relay-event-view.mjs +4 -0
  70. package/server/public/app/socket-handlers.js +10 -2
  71. package/server/public/index.html +14 -0
  72. package/server/public/sw.js +8 -0
  73. package/server/relay-heap-guard.mjs +351 -0
  74. package/server/relay-self-restart.mjs +116 -9
  75. package/server/relay-tools.md +5 -4
  76. package/server/repositories/message-repository.mjs +31 -1
  77. package/server/repositories/remote-relay-repository.mjs +10 -0
  78. package/server/repositories/session-repository.mjs +6 -1
  79. package/server/routes/messages-routes.mjs +120 -4
  80. package/server/routes/relay-ask-routes.mjs +138 -8
  81. package/server/routes/relay-board-routes.mjs +55 -40
  82. package/server/routes/sessions-routes.mjs +141 -31
  83. package/server/server-runtime.mjs +142 -11
  84. package/server/services/about-service.mjs +59 -0
  85. package/server/services/cache-rebuild-service.mjs +7 -0
  86. package/server/services/conversation-preferences-service.mjs +31 -0
  87. package/server/services/database-file.mjs +413 -0
  88. package/server/services/db-backup-service.mjs +809 -0
  89. package/server/services/deepseek-usage-import.mjs +6 -9
  90. package/server/services/delete-archive-service.mjs +3 -0
  91. package/server/services/earlier-database-service.mjs +69 -126
  92. package/server/services/oar-cli-helpers.mjs +214 -12
  93. package/server/services/oar-cli-menu.mjs +1 -0
  94. package/server/services/oar-state-migration-service.mjs +33 -13
  95. package/server/services/package-data-rescue.mjs +4 -2
  96. package/server/services/plan-usage-claude-api.mjs +14 -15
  97. package/server/services/plan-usage-contract.mjs +6 -2
  98. package/server/services/plan-usage-deepseek.mjs +1 -1
  99. package/server/services/plan-usage-tokens.mjs +248 -0
  100. package/server/services/push-dispatch-service.mjs +134 -9
  101. package/server/services/relay-cli-launcher-service.mjs +3 -0
  102. package/server/services/relay-event-service.mjs +30 -0
  103. package/server/services/relay-question-context.mjs +11 -0
  104. package/server/services/relay-restart-picture.mjs +67 -22
  105. package/server/services/remote-inbox-watch-service.mjs +204 -0
  106. package/server/services/remote-relay-caller-context.mjs +80 -7
  107. package/server/services/remote-relay-dispatcher.mjs +68 -8
  108. package/server/services/restart-resume-service.mjs +5 -2
  109. package/server/services/session-worker-launch-service.mjs +12 -3
  110. package/server/services/session-worker-stop-service.mjs +27 -6
  111. package/server/services/token-usage-service.mjs +430 -0
  112. package/server/services/update-install-service.mjs +13 -0
  113. package/server/services/usage-limit-pause-service.mjs +8 -1
  114. package/server/services/worker-scope.mjs +77 -0
  115. package/shared/plan-board-actions.mjs +130 -0
  116. package/shared/plan-only.mjs +43 -0
  117. package/shared/relay-asks.mjs +3 -0
  118. package/shared/relay-events.mjs +38 -4
  119. package/shared/remote-relay-contract.mjs +98 -21
  120. package/shared/turn-token-usage.mjs +144 -0
@@ -0,0 +1,351 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import v8 from 'v8';
5
+
6
+ import { resolveDataDir } from './services/data-dir-resolution.mjs';
7
+
8
+ // The config.json key an operator sets to pin the runtime's heap (MB). A key
9
+ // and not an env var: a shell's NODE_OPTIONS is lost on the next relaunch by
10
+ // systemd, an autostart launcher or the CLI extension; config.json is read by
11
+ // every one of them.
12
+ export const HEAP_LIMIT_CONFIG_KEY = 'heapLimitMb';
13
+ export const MIN_HEAP_LIMIT_MB = 512;
14
+ const DEFAULT_FLOOR_MB = 2048;
15
+ const DEFAULT_CAP_MB = 8192;
16
+ const DEFAULT_RAM_SHARE = 0.6;
17
+ // v8's heap_size_limit is old space plus the young generation, while
18
+ // --max-old-space-size sets old space only: compare like with like. Node 24
19
+ // reserves 3 × 64 MB semi-spaces (old space 2048 → heap_size_limit 2240);
20
+ // older Nodes reserve less, which only sets this floor a little low.
21
+ const YOUNG_GENERATION_ALLOWANCE_MB = 192;
22
+ const MB = 1024 * 1024;
23
+
24
+ const HEAP_FLAG = /^--max[-_]old[-_]space[-_]size(?:=(.*))?$/;
25
+ // Node 24.11+: the old space as a percentage of the memory Node sees.
26
+ const HEAP_PERCENTAGE_FLAG = /^--max[-_]old[-_]space[-_]size[-_]percentage(?:=(.*))?$/;
27
+ const SNAPSHOT_FLAG = /^--heapsnapshot[-_]near[-_]heap[-_]limit(?:=|$)/;
28
+ const DIAGNOSTIC_DIR_FLAG = /^--diagnostic[-_]dir(?:=(.*))?$/;
29
+
30
+ /**
31
+ * NODE_OPTIONS as Node splits it (ParseNodeOptionsEnvVar): on spaces, except
32
+ * inside double quotes, which are taken off; inside them a backslash escapes
33
+ * the next character. A single quote is an ordinary character.
34
+ */
35
+ export function splitNodeOptions(value) {
36
+ const args = [];
37
+ let current = '';
38
+ let inString = false;
39
+ const text = String(value || '');
40
+ for (let index = 0; index < text.length; index += 1) {
41
+ let char = text[index];
42
+ if (char === '\\' && inString && index + 1 < text.length) {
43
+ index += 1;
44
+ char = text[index];
45
+ } else if (char === ' ' && !inString) {
46
+ if (current) args.push(current);
47
+ current = '';
48
+ continue;
49
+ } else if (char === '"') {
50
+ inString = !inString;
51
+ continue;
52
+ }
53
+ current += char;
54
+ }
55
+ if (current) args.push(current);
56
+ return args;
57
+ }
58
+
59
+ // The value the last `flag` in an arg list sets (`=V` or V as the next arg),
60
+ // as in Node; undefined when none sets it.
61
+ function lastFlagValue(args, flag) {
62
+ let found;
63
+ for (let index = 0; index < args.length; index += 1) {
64
+ const match = flag.exec(String(args[index]));
65
+ if (match) found = match[1] !== undefined ? match[1] : String(args[index + 1] ?? '');
66
+ }
67
+ return found;
68
+ }
69
+
70
+ function positiveNumber(value) {
71
+ const number = Number.parseFloat(String(value ?? ''));
72
+ return Number.isFinite(number) && number > 0 ? number : null;
73
+ }
74
+
75
+ /**
76
+ * The heap limit the operator set in execArgv or NODE_OPTIONS: `{ mb }` for
77
+ * --max-old-space-size, `{ percentage }` for --max-old-space-size-percentage,
78
+ * with its `source`; null when neither sets one. As in Node, a percentage
79
+ * wins over a size wherever each is set, and execArgv over NODE_OPTIONS.
80
+ */
81
+ export function operatorHeapLimit({ execArgv = [], nodeOptions = '' } = {}) {
82
+ const sources = [['execArgv', Array.from(execArgv || [])], ['NODE_OPTIONS', splitNodeOptions(nodeOptions)]];
83
+ for (const [source, args] of sources) {
84
+ const percentage = positiveNumber(lastFlagValue(args, HEAP_PERCENTAGE_FLAG));
85
+ if (percentage) return { percentage, source };
86
+ }
87
+ for (const [source, args] of sources) {
88
+ const mb = positiveNumber(lastFlagValue(args, HEAP_FLAG));
89
+ if (mb) return { mb: Math.floor(mb), source };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * The memory this host gives the relay: a container's or cgroup's limit
96
+ * (process.constrainedMemory) when it is set and under the RAM, else the RAM.
97
+ * Without a limit it reports 0, nothing, or a huge number: ignored.
98
+ */
99
+ export function availableMemory({ totalMem = os.totalmem(), constrainedMem = process.constrainedMemory?.() } = {}) {
100
+ const total = Number(totalMem) || 0;
101
+ const constrained = Number(constrainedMem);
102
+ if (Number.isFinite(constrained) && constrained > 0 && constrained < total) {
103
+ return { bytes: constrained, label: 'memory limit' };
104
+ }
105
+ return { bytes: total, label: 'RAM' };
106
+ }
107
+
108
+ /**
109
+ * 60 % of RAM in whole 64 MB steps, between 2048 and 8192 MB, and never under
110
+ * Node's own default (`nodeDefaultMb`, old space only). 60 % leaves room for
111
+ * the CLI workers and for writing a heap snapshot: a heap that fills RAM ends
112
+ * in the kernel's OOM killer, which leaves neither a snapshot nor a log line.
113
+ */
114
+ export function defaultHeapLimitMb({ totalMem = os.totalmem(), nodeDefaultMb = 0 } = {}) {
115
+ const share = Math.floor((Number(totalMem) || 0) * DEFAULT_RAM_SHARE / MB / 64) * 64;
116
+ const clamped = Math.min(DEFAULT_CAP_MB, Math.max(DEFAULT_FLOOR_MB, share));
117
+ return Math.max(clamped, Math.floor(Number(nodeDefaultMb) || 0));
118
+ }
119
+
120
+ /** Node's default old-space limit, from this process's v8 heap limit. */
121
+ export function nodeDefaultOldSpaceMb(heapSizeLimit = v8.getHeapStatistics().heap_size_limit) {
122
+ return Math.max(0, Math.floor(Number(heapSizeLimit) / MB) - YOUNG_GENERATION_ALLOWANCE_MB);
123
+ }
124
+
125
+ function resolveConfigPath(env, serverDir) {
126
+ const fromEnv = String(env?.COPILOT_WEB_RELAY_CONFIG || '').trim();
127
+ return fromEnv ? path.resolve(fromEnv) : path.join(String(serverDir || ''), 'config.json');
128
+ }
129
+
130
+ /**
131
+ * config.json's heapLimitMb: `{ mb }`, `{ invalid }` for a value that is not a
132
+ * whole number of at least MIN_HEAP_LIMIT_MB, or null when it sets none. An
133
+ * unreadable config sets none: the runtime reports config problems itself.
134
+ */
135
+ export function configuredHeapLimit({ configPath, fsImpl = fs } = {}) {
136
+ let parsed = null;
137
+ try { parsed = JSON.parse(fsImpl.readFileSync(configPath, 'utf8')); } catch { return null; }
138
+ const value = parsed?.[HEAP_LIMIT_CONFIG_KEY];
139
+ if (value === undefined || value === null || value === '') return null;
140
+ const mb = Number(value);
141
+ return Number.isInteger(mb) && mb >= MIN_HEAP_LIMIT_MB ? { mb } : { invalid: value };
142
+ }
143
+
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) {
147
+ try {
148
+ if (typeof fsImpl.statfsSync !== 'function') return null;
149
+ const stats = fsImpl.statfsSync(dir);
150
+ return Number(stats.bavail) * Number(stats.bsize);
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
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
+
167
+ /** A path for the log: `~` for the home folder, so a log line names no user. */
168
+ export function displayPath(target, homeDir = os.homedir()) {
169
+ const resolved = path.resolve(String(target || ''));
170
+ const home = String(homeDir || '').trim() ? path.resolve(String(homeDir)) : '';
171
+ if (!home) return resolved;
172
+ const relative = path.relative(home, resolved);
173
+ if (relative === '') return '~';
174
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return resolved;
175
+ return `~${path.sep}${relative}`;
176
+ }
177
+
178
+ function countSnapshots(dir, fsImpl) {
179
+ try {
180
+ return fsImpl.readdirSync(dir).filter((name) => name.endsWith('.heapsnapshot')).length;
181
+ } catch {
182
+ return 0;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * The Node flags every relay runtime starts with: a known heap limit, and one
188
+ * heap snapshot near it, written to `<data dir>/diagnostics`.
189
+ *
190
+ * The heap limit comes from execArgv or NODE_OPTIONS when the operator set
191
+ * one there (nothing added), else config.json's heapLimitMb, else
192
+ * defaultHeapLimitMb; neither of the last two goes above the memory the host
193
+ * gives the relay (availableMemory). The diagnostics folder is made 0700 (a
194
+ * snapshot holds the auth token and provider keys); a --diagnostic-dir or
195
+ * --heapsnapshot-near-heap-limit the operator set is kept as it is. When the
196
+ * folder cannot be made, or its drive has less than twice the heap limit
197
+ * free (the database lives there too), no snapshot is asked for. Says what
198
+ * it chose on `logger`, with the home folder as `~`.
199
+ */
200
+ export function relayRuntimeNodeFlags({
201
+ env = process.env,
202
+ execArgv = process.execArgv,
203
+ serverDir,
204
+ totalMem = os.totalmem(),
205
+ constrainedMem = process.constrainedMemory?.(),
206
+ heapSizeLimit = v8.getHeapStatistics().heap_size_limit,
207
+ fsImpl = fs,
208
+ platform = process.platform,
209
+ homeDir = os.homedir(),
210
+ logger = console,
211
+ } = {}) {
212
+ const log = (line) => logger?.log?.(line);
213
+ const operatorArgs = [...Array.from(execArgv || []), ...splitNodeOptions(env?.NODE_OPTIONS)];
214
+ const memory = availableMemory({ totalMem, constrainedMem });
215
+ const memoryMb = Math.floor(memory.bytes / MB / 64) * 64;
216
+ const memoryText = `${(memory.bytes / 1024 ** 3).toFixed(1)} GB ${memory.label}`;
217
+ const flags = [];
218
+
219
+ let heapLimitMb;
220
+ let source;
221
+ const operator = operatorHeapLimit({ execArgv, nodeOptions: env?.NODE_OPTIONS });
222
+ if (operator?.mb) {
223
+ heapLimitMb = operator.mb;
224
+ source = `from ${operator.source}`;
225
+ } else if (operator?.percentage) {
226
+ heapLimitMb = Math.floor(memory.bytes * operator.percentage / 100 / MB);
227
+ source = `${operator.percentage}% of ${memoryText}, from ${operator.source}`;
228
+ } else {
229
+ const configured = configuredHeapLimit({ configPath: resolveConfigPath(env, serverDir), fsImpl });
230
+ if (configured?.invalid !== undefined) {
231
+ log(`[relay] config ${HEAP_LIMIT_CONFIG_KEY}=${JSON.stringify(configured.invalid)} ignored: expected a whole number of MB, at least ${MIN_HEAP_LIMIT_MB}`);
232
+ }
233
+ if (configured?.mb) {
234
+ heapLimitMb = configured.mb;
235
+ source = `from config ${HEAP_LIMIT_CONFIG_KEY}`;
236
+ if (memoryMb >= MIN_HEAP_LIMIT_MB && heapLimitMb > memoryMb) {
237
+ log(`[relay] config ${HEAP_LIMIT_CONFIG_KEY}=${heapLimitMb} is more than the ${memoryText}; using ${memoryMb} MB`);
238
+ heapLimitMb = memoryMb;
239
+ }
240
+ } else {
241
+ heapLimitMb = defaultHeapLimitMb({ totalMem: memory.bytes, nodeDefaultMb: nodeDefaultOldSpaceMb(heapSizeLimit) });
242
+ if (memoryMb >= MIN_HEAP_LIMIT_MB) heapLimitMb = Math.min(heapLimitMb, memoryMb);
243
+ source = `default: ${Math.round(DEFAULT_RAM_SHARE * 100)}% of ${memoryText}`;
244
+ }
245
+ flags.push(`--max-old-space-size=${heapLimitMb}`);
246
+ }
247
+ log(`[relay] heap limit ${heapLimitMb} MB (${source})`);
248
+
249
+ // The operator's --diagnostic-dir is theirs: checked, never made or changed.
250
+ const operatorDir = lastFlagValue(operatorArgs, DIAGNOSTIC_DIR_FLAG);
251
+ const diagnosticDir = operatorDir
252
+ ? path.resolve(operatorDir)
253
+ : path.join(resolveDataDir({ env, configPath: resolveConfigPath(env, serverDir), serverDir, fsImpl }), 'diagnostics');
254
+ const shownDir = displayPath(diagnosticDir, homeDir);
255
+ let snapshot = false;
256
+ try {
257
+ if (!operatorDir) {
258
+ fsImpl.mkdirSync(diagnosticDir, { recursive: true, mode: 0o700 });
259
+ // mkdir leaves a folder that was there as it was.
260
+ if (platform !== 'win32') fsImpl.chmodSync?.(diagnosticDir, 0o700);
261
+ }
262
+ const free = freeBytes(diagnosticDir, fsImpl);
263
+ if (operatorArgs.some((arg) => SNAPSHOT_FLAG.test(arg))) {
264
+ snapshot = true;
265
+ } else if (free !== null && free < 2 * heapLimitMb * MB) {
266
+ log(`[relay] no heap snapshot near the limit: ${shownDir} has ${Math.floor(free / MB)} MB free, under twice the heap limit`);
267
+ } else {
268
+ flags.push('--heapsnapshot-near-heap-limit=1');
269
+ snapshot = true;
270
+ }
271
+ if (!operatorDir) flags.push(`--diagnostic-dir=${diagnosticDir}`);
272
+ if (snapshot) {
273
+ log(`[relay] heap snapshots near the limit go to ${shownDir} (${countSnapshots(diagnosticDir, fsImpl)} already there)`);
274
+ }
275
+ } catch (error) {
276
+ log(`[relay] no heap snapshot near the limit: cannot prepare ${shownDir} (${error?.code || error?.message || error})`);
277
+ }
278
+
279
+ return { flags, heapLimitMb, source, diagnosticDir, snapshot };
280
+ }
281
+
282
+ /**
283
+ * relayRuntimeNodeFlags' flags, or none when it fails: a runtime must start
284
+ * even when its heap guard cannot be worked out.
285
+ */
286
+ export function relayRuntimeNodeFlagsOrNone(nodeFlags, options = {}) {
287
+ try {
288
+ return nodeFlags(options);
289
+ } catch (error) {
290
+ options.logger?.log?.(`[relay] starting the runtime without heap flags: ${error?.message || error}`);
291
+ return { flags: [] };
292
+ }
293
+ }
294
+
295
+ function toMb(bytes) {
296
+ return Math.round((Number(bytes) || 0) / MB);
297
+ }
298
+
299
+ /** `[relay] memory rss=<MB> heapUsed=<MB> heapLimit=<MB>` */
300
+ export function formatMemoryLine({ rss, heapUsed, heapLimit }) {
301
+ return `[relay] memory rss=${toMb(rss)} heapUsed=${toMb(heapUsed)} heapLimit=${toMb(heapLimit)}`;
302
+ }
303
+
304
+ /**
305
+ * Says once when heapUsed crosses `threshold` of the heap limit, and again
306
+ * only after it has been back under it. `check` returns whether it said so.
307
+ */
308
+ export function createHeapWatch({ threshold = 0.8, logger = console } = {}) {
309
+ let above = false;
310
+ return {
311
+ check({ heapUsed, heapLimit }) {
312
+ const over = Number(heapLimit) > 0 && Number(heapUsed) >= threshold * Number(heapLimit);
313
+ const crossed = over && !above;
314
+ above = over;
315
+ if (crossed) {
316
+ logger?.log?.(`[relay] memory high: heapUsed=${toMb(heapUsed)} is over ${Math.round(threshold * 100)}% of heapLimit=${toMb(heapLimit)}`);
317
+ }
318
+ return crossed;
319
+ },
320
+ };
321
+ }
322
+
323
+ /**
324
+ * The runtime's memory in the status log: a line now and every 10 minutes,
325
+ * and a silent check every minute that says when heapUsed crosses 80 % of
326
+ * the limit. Both timers are unref'd; returns them for runtimeTimers.
327
+ */
328
+ export function startRelayMemoryLog({
329
+ logger = console,
330
+ memoryUsage = () => process.memoryUsage(),
331
+ heapLimit = () => v8.getHeapStatistics().heap_size_limit,
332
+ logIntervalMs = 10 * 60 * 1000,
333
+ watchIntervalMs = 60 * 1000,
334
+ } = {}) {
335
+ const watch = createHeapWatch({ logger });
336
+ const sample = () => {
337
+ const { rss, heapUsed } = memoryUsage();
338
+ return { rss, heapUsed, heapLimit: heapLimit() };
339
+ };
340
+ const logLine = () => {
341
+ const now = sample();
342
+ logger?.log?.(formatMemoryLine(now));
343
+ watch.check(now);
344
+ };
345
+ logLine();
346
+ const log = setInterval(logLine, logIntervalMs);
347
+ log.unref?.();
348
+ const check = setInterval(() => watch.check(sample()), watchIntervalMs);
349
+ check.unref?.();
350
+ return { log, check };
351
+ }
@@ -1,6 +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
+ import { relayRuntimeNodeFlags, relayRuntimeNodeFlagsOrNone } from './relay-heap-guard.mjs';
5
+ import { detectRelayHosting } from './services/relay-restart-picture.mjs';
4
6
 
5
7
  // Role selection travels on argv, never in the environment. The runtime's env is
6
8
  // inherited by tmux worker sessions and by the Copilot CLI it launches, and the
@@ -34,8 +36,11 @@ export function spawnRelayRuntime({
34
36
  detached = false,
35
37
  windowsHide = true,
36
38
  logger = console,
39
+ // The heap limit and heap snapshot flags (relay-heap-guard.mjs).
40
+ nodeFlags = relayRuntimeNodeFlags,
37
41
  } = {}) {
38
- const child = spawnImpl(process.execPath, [...execArgv, scriptPath, RELAY_RUNTIME_FLAG, ...args], {
42
+ const { flags } = relayRuntimeNodeFlagsOrNone(nodeFlags, { env, execArgv, serverDir: path.dirname(String(scriptPath || '')), logger });
43
+ const child = spawnImpl(process.execPath, [...flags, ...execArgv, scriptPath, RELAY_RUNTIME_FLAG, ...args], {
39
44
  cwd,
40
45
  env,
41
46
  detached,
@@ -72,6 +77,64 @@ function waitForChildExit(child) {
72
77
  });
73
78
  }
74
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
+
75
138
  export async function runDirectRelaySupervisor({
76
139
  scriptPath,
77
140
  args = [],
@@ -84,31 +147,54 @@ export async function runDirectRelaySupervisor({
84
147
  exitImpl = (code) => process.exit(code),
85
148
  logger = console,
86
149
  installSignalHandlers = true,
150
+ signals = process,
151
+ nodeFlags = relayRuntimeNodeFlags,
87
152
  restartDelayMs = 500,
88
153
  maxCrashRestarts = 3,
154
+ // A runtime that ran this long before it crashed starts the count afresh:
155
+ // crashes days apart are not a crash loop.
156
+ crashCountResetMs = 10 * 60 * 1000,
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,
89
165
  } = {}) {
90
166
  let runtimeProc = null;
91
167
  let shuttingDown = false;
92
168
  let shutdownExitCode = 0;
169
+ let shutdownArrived;
170
+ const shutdownRequested = new Promise((resolve) => { shutdownArrived = resolve; });
93
171
 
94
172
  const requestShutdown = (signal = 'SIGTERM', exitCode = 0) => {
95
173
  if (shuttingDown) return;
96
174
  shuttingDown = true;
97
175
  shutdownExitCode = exitCode;
98
176
  stopChildProcess(runtimeProc, { signal });
177
+ shutdownArrived();
99
178
  };
100
179
 
101
180
  const handleSigInt = () => requestShutdown('SIGINT', 0);
102
181
  const handleSigTerm = () => requestShutdown('SIGTERM', 0);
103
182
 
104
183
  if (installSignalHandlers) {
105
- process.on('SIGINT', handleSigInt);
106
- process.on('SIGTERM', handleSigTerm);
184
+ signals.on('SIGINT', handleSigInt);
185
+ signals.on('SIGTERM', handleSigTerm);
107
186
  }
108
187
 
109
188
  try {
110
189
  let crashCount = 0;
111
190
  while (true) {
191
+ // A Ctrl-C reaches the runtime (same process group) before this
192
+ // supervisor's handler can run: the runtime's signal exit looks like a
193
+ // crash, and the shutdown arrives during the delay. Never relaunch then.
194
+ if (shuttingDown) {
195
+ return await exitImpl(shutdownExitCode);
196
+ }
197
+ const startedAt = now();
112
198
  runtimeProc = spawnRelayRuntime({
113
199
  env,
114
200
  cwd,
@@ -119,6 +205,7 @@ export async function runDirectRelaySupervisor({
119
205
  stdio,
120
206
  detached: false,
121
207
  logger,
208
+ nodeFlags,
122
209
  });
123
210
  if (runtimeProc.stdout?.on) {
124
211
  runtimeProc.stdout.on('data', (chunk) => process.stdout.write(chunk));
@@ -139,10 +226,28 @@ export async function runDirectRelaySupervisor({
139
226
  return await exitImpl(1);
140
227
  }
141
228
 
142
- const exitCode = Number.isInteger(Number(result.code)) ? Number(result.code) : null;
229
+ // A runtime killed by a signal (an out-of-memory abort is SIGABRT, the
230
+ // kernel's OOM killer SIGKILL) exits with code null: a crash. Number(null)
231
+ // is 0, which once ended the supervisor as if the runtime stopped cleanly.
232
+ const exitCode = result.code !== null && result.code !== undefined && Number.isInteger(Number(result.code))
233
+ ? Number(result.code)
234
+ : null;
235
+ const signal = exitCode === null ? (result.signal || null) : null;
143
236
  if (exitCode === RELAY_RESTART_EXIT_CODE) {
144
- logger?.log?.(`[relay] runtime requested restart; relaunching ${path.basename(String(scriptPath || ''))}...`);
145
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 || ''))}...`);
146
251
  await delay(restartDelayMs);
147
252
  continue;
148
253
  }
@@ -156,8 +261,10 @@ export async function runDirectRelaySupervisor({
156
261
  return await exitImpl(RELAY_STOP_EXIT_CODE);
157
262
  }
158
263
 
264
+ if (now() - startedAt >= crashCountResetMs) crashCount = 0;
159
265
  const attempt = crashCount + 1;
160
- logger?.error?.(`[relay] runtime crashed with exit code ${exitCode ?? 'null'} (crash ${attempt}/${maxCrashRestarts}).`);
266
+ const how = signal ? `signal ${signal}` : `exit code ${exitCode ?? 'null'}`;
267
+ logger?.error?.(`[relay] runtime crashed with ${how} (crash ${attempt}/${maxCrashRestarts}).`);
161
268
  if (attempt >= maxCrashRestarts) {
162
269
  logger?.error?.('[relay] crash limit reached; supervisor stopping.');
163
270
  return await exitImpl(1);
@@ -168,8 +275,8 @@ export async function runDirectRelaySupervisor({
168
275
 
169
276
  } finally {
170
277
  if (installSignalHandlers) {
171
- process.off('SIGINT', handleSigInt);
172
- process.off('SIGTERM', handleSigTerm);
278
+ signals.off('SIGINT', handleSigInt);
279
+ signals.off('SIGTERM', handleSigTerm);
173
280
  }
174
281
  }
175
282
  }
@@ -39,19 +39,20 @@ 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". 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
+ - `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.
46
+ - "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
47
  - 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
- - 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.
48
+ - 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
49
  - 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.
49
50
  - 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).
50
51
  - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
51
52
  - 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
53
  - `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
54
 
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.
55
+ 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
56
 
56
57
  Each wake-up costs you a turn; these `subscribe` options cut them (questions and errors always come at once):
57
58
  - "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