@oar-sh/oar 0.9.18 → 0.9.19

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.
@@ -1,3 +1,5 @@
1
+ import { relayRuntimeNodeFlags, relayRuntimeNodeFlagsOrNone } from "../../../../server/relay-heap-guard.mjs";
2
+
1
3
  export function createManagedServerLifecycle({
2
4
  api,
3
5
  dbg,
@@ -16,6 +18,8 @@ export function createManagedServerLifecycle({
16
18
  relayPort = 3333,
17
19
  nodeBin,
18
20
  serverStartTimeoutMs,
21
+ // The heap limit and heap snapshot flags every relay runtime starts with.
22
+ nodeFlags = relayRuntimeNodeFlags,
19
23
  }) {
20
24
  let managedServerProc = null;
21
25
  let managedServerStdoutFd = null;
@@ -301,7 +305,15 @@ export function createManagedServerLifecycle({
301
305
  if (startupWorkspaceRoot) {
302
306
  serverEnv.COPILOT_WORKSPACE_ROOT = startupWorkspaceRoot;
303
307
  }
304
- managedServerProc = spawn(nodeBin, serverArgs, {
308
+ // The runtime runs in this process (--supervised): its Node flags go here.
309
+ const { flags } = relayRuntimeNodeFlagsOrNone(nodeFlags, {
310
+ env: serverEnv,
311
+ execArgv: [],
312
+ serverDir,
313
+ fsImpl: fs,
314
+ logger: { log: (line) => dbg(line) },
315
+ });
316
+ managedServerProc = spawn(nodeBin, [...flags, ...serverArgs], {
305
317
  cwd: serverDir,
306
318
  env: serverEnv,
307
319
  stdio: ["ignore", managedServerStdoutFd, managedServerStderrFd],
package/CHANGELOG.md CHANGED
@@ -3,6 +3,48 @@
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.19] — 2026-10-27
7
+
8
+ Highlights: a relay that runs out of memory is restarted instead of staying
9
+ down. Its runtime starts with a heap limit, set with `heapLimitMb` in
10
+ `config.json`, and writes one heap snapshot when it nears that limit; a
11
+ snapshot holds the relay's auth token and API keys, so never share one as it
12
+ is. Agent sessions can use the providers a relay update added without a
13
+ restart of their own, and Copilot refuses another provider's model id before
14
+ it creates anything.
15
+
16
+ ### Fixed
17
+
18
+ - **A relay that runs out of memory is restarted.** A relay runtime that ran
19
+ out of memory ended the relay for good. Its supervisor took the
20
+ out-of-memory abort for a clean stop and exited 0, which systemd does not
21
+ restart. The runtime is now relaunched like any crash. It starts with a heap
22
+ limit (60 % of RAM, 2048–8192 MB, set with `heapLimitMb` in `config.json`)
23
+ and writes one heap snapshot to `diagnostics` in the data directory when it
24
+ nears that limit. A heap snapshot holds the relay's auth token and API keys:
25
+ never share one as it is. The status log shows its memory every 10 minutes
26
+ and when the heap passes 80 % of the limit.
27
+ - **`relay_tool` takes any provider id.** Agent sessions started before a
28
+ relay update refused the providers the update added (such as `deepseek` and
29
+ `claude-api`) in `relay_tool`'s `create_session` and `send`. The tool now
30
+ takes any provider id; the relay checks it against its current list and
31
+ names that list when it refuses one.
32
+ - **Copilot refuses another provider's model id before creating anything.** A
33
+ Copilot session given another provider's model id (such as `claude-opus-5-5`
34
+ for Copilot's `claude-opus-5.5`) was created and then refused with
35
+ `CURSOR_MODEL_REQUIRES_NEW_CONVERSATION`. Copilot now refuses such an id
36
+ with `COPILOT_MODEL_UNAVAILABLE` and the close match before anything is
37
+ created, and a first prompt refused after `create_session` made the session
38
+ names that session.
39
+
40
+ ### Upgrade notes
41
+
42
+ - **Agent sessions started before 0.9.19 keep their provider list:** their
43
+ workers keep running through the update, with the `relay_tool` they
44
+ started with. That list is the one 0.9.19 has, so nothing changes now; it
45
+ lacks the providers a later update adds until the session's worker
46
+ restarts, for example after **☠️ Kill session** in the `⋯` menu.
47
+
6
48
  ## [0.9.18] — 2026-10-26
7
49
 
8
50
  Highlights: Claude chats can run on a Claude Console API key. Claude API is
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.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.18" 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.19"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.19"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.19" 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.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.18" 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.19"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.19"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.19" 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.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.18" 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.19"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.19"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.19" 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.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.18" 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.19"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.19"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.19" 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.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.18" 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.19"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.19"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.19" 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>
@@ -118,11 +118,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
118
118
  <table>
119
119
  <tr>
120
120
  <td width="62%" valign="top">
121
- <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.18" 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>
121
+ <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.19"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.19"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.19" 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>
122
122
  <br><sub>Send a message while the agent works, and it steers the running turn.</sub>
123
123
  </td>
124
124
  <td width="38%" valign="top">
125
- <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.18" 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>
125
+ <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.19"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.19"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.19" 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>
126
126
  <br><sub>What each plan has left, in one place.</sub>
127
127
  </td>
128
128
  </tr>
@@ -139,7 +139,7 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
139
139
  - **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)).
140
140
  - **On your phone**: install OAR as an app and get push notifications ([Notifications and the app](docs/web-ui.md#notifications-and-the-app)).
141
141
 
142
- 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.18](CHANGELOG.md#0918--2026-10-26) as the latest.
142
+ 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.19](CHANGELOG.md#0919--2026-10-27) as the latest.
143
143
 
144
144
  ## Remote access
145
145
 
@@ -190,7 +190,7 @@ flowchart LR
190
190
 
191
191
  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.
192
192
 
193
- <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.18" 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>
193
+ <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.19"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.19"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.19" 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>
194
194
 
195
195
  How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
196
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.18",
3
+ "version": "0.9.19",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -11,7 +11,6 @@ import {
11
11
  REMOTE_RELAY_ACTIONS,
12
12
  REMOTE_RELAY_IF_BUSY,
13
13
  REMOTE_RELAY_MODES,
14
- REMOTE_RELAY_PROVIDERS,
15
14
  REMOTE_RELAY_SESSION_SCOPES,
16
15
  REMOTE_RELAY_THINKING_DISPLAYS,
17
16
  } from '../../shared/remote-relay-contract.mjs';
@@ -61,7 +60,8 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
61
60
  before: text('before'),
62
61
  include_activity: z.boolean().optional().describe(FIELDS.include_activity.description),
63
62
  max_chars: number('max_chars'),
64
- provider: choice('provider', REMOTE_RELAY_PROVIDERS),
63
+ // Free text: the worker outlives relay updates, the relay checks the id.
64
+ provider: text('provider'),
65
65
  model: text('model'),
66
66
  cwd: text('cwd'),
67
67
  repo: text('repo'),
@@ -0,0 +1,338 @@
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
+ function freeBytes(dir, fsImpl) {
145
+ try {
146
+ if (typeof fsImpl.statfsSync !== 'function') return null;
147
+ const stats = fsImpl.statfsSync(dir);
148
+ return Number(stats.bavail) * Number(stats.bsize);
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /** A path for the log: `~` for the home folder, so a log line names no user. */
155
+ export function displayPath(target, homeDir = os.homedir()) {
156
+ const resolved = path.resolve(String(target || ''));
157
+ const home = String(homeDir || '').trim() ? path.resolve(String(homeDir)) : '';
158
+ if (!home) return resolved;
159
+ const relative = path.relative(home, resolved);
160
+ if (relative === '') return '~';
161
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return resolved;
162
+ return `~${path.sep}${relative}`;
163
+ }
164
+
165
+ function countSnapshots(dir, fsImpl) {
166
+ try {
167
+ return fsImpl.readdirSync(dir).filter((name) => name.endsWith('.heapsnapshot')).length;
168
+ } catch {
169
+ return 0;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The Node flags every relay runtime starts with: a known heap limit, and one
175
+ * heap snapshot near it, written to `<data dir>/diagnostics`.
176
+ *
177
+ * The heap limit comes from execArgv or NODE_OPTIONS when the operator set
178
+ * one there (nothing added), else config.json's heapLimitMb, else
179
+ * defaultHeapLimitMb; neither of the last two goes above the memory the host
180
+ * gives the relay (availableMemory). The diagnostics folder is made 0700 (a
181
+ * snapshot holds the auth token and provider keys); a --diagnostic-dir or
182
+ * --heapsnapshot-near-heap-limit the operator set is kept as it is. When the
183
+ * folder cannot be made, or its drive has less than twice the heap limit
184
+ * free (the database lives there too), no snapshot is asked for. Says what
185
+ * it chose on `logger`, with the home folder as `~`.
186
+ */
187
+ export function relayRuntimeNodeFlags({
188
+ env = process.env,
189
+ execArgv = process.execArgv,
190
+ serverDir,
191
+ totalMem = os.totalmem(),
192
+ constrainedMem = process.constrainedMemory?.(),
193
+ heapSizeLimit = v8.getHeapStatistics().heap_size_limit,
194
+ fsImpl = fs,
195
+ platform = process.platform,
196
+ homeDir = os.homedir(),
197
+ logger = console,
198
+ } = {}) {
199
+ const log = (line) => logger?.log?.(line);
200
+ const operatorArgs = [...Array.from(execArgv || []), ...splitNodeOptions(env?.NODE_OPTIONS)];
201
+ const memory = availableMemory({ totalMem, constrainedMem });
202
+ const memoryMb = Math.floor(memory.bytes / MB / 64) * 64;
203
+ const memoryText = `${(memory.bytes / 1024 ** 3).toFixed(1)} GB ${memory.label}`;
204
+ const flags = [];
205
+
206
+ let heapLimitMb;
207
+ let source;
208
+ const operator = operatorHeapLimit({ execArgv, nodeOptions: env?.NODE_OPTIONS });
209
+ if (operator?.mb) {
210
+ heapLimitMb = operator.mb;
211
+ source = `from ${operator.source}`;
212
+ } else if (operator?.percentage) {
213
+ heapLimitMb = Math.floor(memory.bytes * operator.percentage / 100 / MB);
214
+ source = `${operator.percentage}% of ${memoryText}, from ${operator.source}`;
215
+ } else {
216
+ const configured = configuredHeapLimit({ configPath: resolveConfigPath(env, serverDir), fsImpl });
217
+ if (configured?.invalid !== undefined) {
218
+ log(`[relay] config ${HEAP_LIMIT_CONFIG_KEY}=${JSON.stringify(configured.invalid)} ignored: expected a whole number of MB, at least ${MIN_HEAP_LIMIT_MB}`);
219
+ }
220
+ if (configured?.mb) {
221
+ heapLimitMb = configured.mb;
222
+ source = `from config ${HEAP_LIMIT_CONFIG_KEY}`;
223
+ if (memoryMb >= MIN_HEAP_LIMIT_MB && heapLimitMb > memoryMb) {
224
+ log(`[relay] config ${HEAP_LIMIT_CONFIG_KEY}=${heapLimitMb} is more than the ${memoryText}; using ${memoryMb} MB`);
225
+ heapLimitMb = memoryMb;
226
+ }
227
+ } else {
228
+ heapLimitMb = defaultHeapLimitMb({ totalMem: memory.bytes, nodeDefaultMb: nodeDefaultOldSpaceMb(heapSizeLimit) });
229
+ if (memoryMb >= MIN_HEAP_LIMIT_MB) heapLimitMb = Math.min(heapLimitMb, memoryMb);
230
+ source = `default: ${Math.round(DEFAULT_RAM_SHARE * 100)}% of ${memoryText}`;
231
+ }
232
+ flags.push(`--max-old-space-size=${heapLimitMb}`);
233
+ }
234
+ log(`[relay] heap limit ${heapLimitMb} MB (${source})`);
235
+
236
+ // The operator's --diagnostic-dir is theirs: checked, never made or changed.
237
+ const operatorDir = lastFlagValue(operatorArgs, DIAGNOSTIC_DIR_FLAG);
238
+ const diagnosticDir = operatorDir
239
+ ? path.resolve(operatorDir)
240
+ : path.join(resolveDataDir({ env, configPath: resolveConfigPath(env, serverDir), serverDir, fsImpl }), 'diagnostics');
241
+ const shownDir = displayPath(diagnosticDir, homeDir);
242
+ let snapshot = false;
243
+ try {
244
+ if (!operatorDir) {
245
+ fsImpl.mkdirSync(diagnosticDir, { recursive: true, mode: 0o700 });
246
+ // mkdir leaves a folder that was there as it was.
247
+ if (platform !== 'win32') fsImpl.chmodSync?.(diagnosticDir, 0o700);
248
+ }
249
+ const free = freeBytes(diagnosticDir, fsImpl);
250
+ if (operatorArgs.some((arg) => SNAPSHOT_FLAG.test(arg))) {
251
+ snapshot = true;
252
+ } else if (free !== null && free < 2 * heapLimitMb * MB) {
253
+ log(`[relay] no heap snapshot near the limit: ${shownDir} has ${Math.floor(free / MB)} MB free, under twice the heap limit`);
254
+ } else {
255
+ flags.push('--heapsnapshot-near-heap-limit=1');
256
+ snapshot = true;
257
+ }
258
+ if (!operatorDir) flags.push(`--diagnostic-dir=${diagnosticDir}`);
259
+ if (snapshot) {
260
+ log(`[relay] heap snapshots near the limit go to ${shownDir} (${countSnapshots(diagnosticDir, fsImpl)} already there)`);
261
+ }
262
+ } catch (error) {
263
+ log(`[relay] no heap snapshot near the limit: cannot prepare ${shownDir} (${error?.code || error?.message || error})`);
264
+ }
265
+
266
+ return { flags, heapLimitMb, source, diagnosticDir, snapshot };
267
+ }
268
+
269
+ /**
270
+ * relayRuntimeNodeFlags' flags, or none when it fails: a runtime must start
271
+ * even when its heap guard cannot be worked out.
272
+ */
273
+ export function relayRuntimeNodeFlagsOrNone(nodeFlags, options = {}) {
274
+ try {
275
+ return nodeFlags(options);
276
+ } catch (error) {
277
+ options.logger?.log?.(`[relay] starting the runtime without heap flags: ${error?.message || error}`);
278
+ return { flags: [] };
279
+ }
280
+ }
281
+
282
+ function toMb(bytes) {
283
+ return Math.round((Number(bytes) || 0) / MB);
284
+ }
285
+
286
+ /** `[relay] memory rss=<MB> heapUsed=<MB> heapLimit=<MB>` */
287
+ export function formatMemoryLine({ rss, heapUsed, heapLimit }) {
288
+ return `[relay] memory rss=${toMb(rss)} heapUsed=${toMb(heapUsed)} heapLimit=${toMb(heapLimit)}`;
289
+ }
290
+
291
+ /**
292
+ * Says once when heapUsed crosses `threshold` of the heap limit, and again
293
+ * only after it has been back under it. `check` returns whether it said so.
294
+ */
295
+ export function createHeapWatch({ threshold = 0.8, logger = console } = {}) {
296
+ let above = false;
297
+ return {
298
+ check({ heapUsed, heapLimit }) {
299
+ const over = Number(heapLimit) > 0 && Number(heapUsed) >= threshold * Number(heapLimit);
300
+ const crossed = over && !above;
301
+ above = over;
302
+ if (crossed) {
303
+ logger?.log?.(`[relay] memory high: heapUsed=${toMb(heapUsed)} is over ${Math.round(threshold * 100)}% of heapLimit=${toMb(heapLimit)}`);
304
+ }
305
+ return crossed;
306
+ },
307
+ };
308
+ }
309
+
310
+ /**
311
+ * The runtime's memory in the status log: a line now and every 10 minutes,
312
+ * and a silent check every minute that says when heapUsed crosses 80 % of
313
+ * the limit. Both timers are unref'd; returns them for runtimeTimers.
314
+ */
315
+ export function startRelayMemoryLog({
316
+ logger = console,
317
+ memoryUsage = () => process.memoryUsage(),
318
+ heapLimit = () => v8.getHeapStatistics().heap_size_limit,
319
+ logIntervalMs = 10 * 60 * 1000,
320
+ watchIntervalMs = 60 * 1000,
321
+ } = {}) {
322
+ const watch = createHeapWatch({ logger });
323
+ const sample = () => {
324
+ const { rss, heapUsed } = memoryUsage();
325
+ return { rss, heapUsed, heapLimit: heapLimit() };
326
+ };
327
+ const logLine = () => {
328
+ const now = sample();
329
+ logger?.log?.(formatMemoryLine(now));
330
+ watch.check(now);
331
+ };
332
+ logLine();
333
+ const log = setInterval(logLine, logIntervalMs);
334
+ log.unref?.();
335
+ const check = setInterval(() => watch.check(sample()), watchIntervalMs);
336
+ check.unref?.();
337
+ return { log, check };
338
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn } 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';
4
5
 
5
6
  // Role selection travels on argv, never in the environment. The runtime's env is
6
7
  // inherited by tmux worker sessions and by the Copilot CLI it launches, and the
@@ -34,8 +35,11 @@ export function spawnRelayRuntime({
34
35
  detached = false,
35
36
  windowsHide = true,
36
37
  logger = console,
38
+ // The heap limit and heap snapshot flags (relay-heap-guard.mjs).
39
+ nodeFlags = relayRuntimeNodeFlags,
37
40
  } = {}) {
38
- const child = spawnImpl(process.execPath, [...execArgv, scriptPath, RELAY_RUNTIME_FLAG, ...args], {
41
+ const { flags } = relayRuntimeNodeFlagsOrNone(nodeFlags, { env, execArgv, serverDir: path.dirname(String(scriptPath || '')), logger });
42
+ const child = spawnImpl(process.execPath, [...flags, ...execArgv, scriptPath, RELAY_RUNTIME_FLAG, ...args], {
39
43
  cwd,
40
44
  env,
41
45
  detached,
@@ -84,8 +88,14 @@ export async function runDirectRelaySupervisor({
84
88
  exitImpl = (code) => process.exit(code),
85
89
  logger = console,
86
90
  installSignalHandlers = true,
91
+ signals = process,
92
+ nodeFlags = relayRuntimeNodeFlags,
87
93
  restartDelayMs = 500,
88
94
  maxCrashRestarts = 3,
95
+ // A runtime that ran this long before it crashed starts the count afresh:
96
+ // crashes days apart are not a crash loop.
97
+ crashCountResetMs = 10 * 60 * 1000,
98
+ now = Date.now,
89
99
  } = {}) {
90
100
  let runtimeProc = null;
91
101
  let shuttingDown = false;
@@ -102,13 +112,20 @@ export async function runDirectRelaySupervisor({
102
112
  const handleSigTerm = () => requestShutdown('SIGTERM', 0);
103
113
 
104
114
  if (installSignalHandlers) {
105
- process.on('SIGINT', handleSigInt);
106
- process.on('SIGTERM', handleSigTerm);
115
+ signals.on('SIGINT', handleSigInt);
116
+ signals.on('SIGTERM', handleSigTerm);
107
117
  }
108
118
 
109
119
  try {
110
120
  let crashCount = 0;
111
121
  while (true) {
122
+ // A Ctrl-C reaches the runtime (same process group) before this
123
+ // supervisor's handler can run: the runtime's signal exit looks like a
124
+ // crash, and the shutdown arrives during the delay. Never relaunch then.
125
+ if (shuttingDown) {
126
+ return await exitImpl(shutdownExitCode);
127
+ }
128
+ const startedAt = now();
112
129
  runtimeProc = spawnRelayRuntime({
113
130
  env,
114
131
  cwd,
@@ -119,6 +136,7 @@ export async function runDirectRelaySupervisor({
119
136
  stdio,
120
137
  detached: false,
121
138
  logger,
139
+ nodeFlags,
122
140
  });
123
141
  if (runtimeProc.stdout?.on) {
124
142
  runtimeProc.stdout.on('data', (chunk) => process.stdout.write(chunk));
@@ -139,7 +157,13 @@ export async function runDirectRelaySupervisor({
139
157
  return await exitImpl(1);
140
158
  }
141
159
 
142
- const exitCode = Number.isInteger(Number(result.code)) ? Number(result.code) : null;
160
+ // A runtime killed by a signal (an out-of-memory abort is SIGABRT, the
161
+ // kernel's OOM killer SIGKILL) exits with code null: a crash. Number(null)
162
+ // is 0, which once ended the supervisor as if the runtime stopped cleanly.
163
+ const exitCode = result.code !== null && result.code !== undefined && Number.isInteger(Number(result.code))
164
+ ? Number(result.code)
165
+ : null;
166
+ const signal = exitCode === null ? (result.signal || null) : null;
143
167
  if (exitCode === RELAY_RESTART_EXIT_CODE) {
144
168
  logger?.log?.(`[relay] runtime requested restart; relaunching ${path.basename(String(scriptPath || ''))}...`);
145
169
  crashCount = 0;
@@ -156,8 +180,10 @@ export async function runDirectRelaySupervisor({
156
180
  return await exitImpl(RELAY_STOP_EXIT_CODE);
157
181
  }
158
182
 
183
+ if (now() - startedAt >= crashCountResetMs) crashCount = 0;
159
184
  const attempt = crashCount + 1;
160
- logger?.error?.(`[relay] runtime crashed with exit code ${exitCode ?? 'null'} (crash ${attempt}/${maxCrashRestarts}).`);
185
+ const how = signal ? `signal ${signal}` : `exit code ${exitCode ?? 'null'}`;
186
+ logger?.error?.(`[relay] runtime crashed with ${how} (crash ${attempt}/${maxCrashRestarts}).`);
161
187
  if (attempt >= maxCrashRestarts) {
162
188
  logger?.error?.('[relay] crash limit reached; supervisor stopping.');
163
189
  return await exitImpl(1);
@@ -168,8 +194,8 @@ export async function runDirectRelaySupervisor({
168
194
 
169
195
  } finally {
170
196
  if (installSignalHandlers) {
171
- process.off('SIGINT', handleSigInt);
172
- process.off('SIGTERM', handleSigTerm);
197
+ signals.off('SIGINT', handleSigInt);
198
+ signals.off('SIGTERM', handleSigTerm);
173
199
  }
174
200
  }
175
201
  }
@@ -39,12 +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". 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. 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
45
  - `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `send` that names another mode or comes from a caller in another mode: a `send` without "mode" passes the caller's own mode.
46
46
  - Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
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.
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. 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
48
  - A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
49
49
  - Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
50
50
  - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
@@ -56,7 +56,11 @@ import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../servic
56
56
  import { taskKindOf } from '../services/session-activity-tracker.mjs';
57
57
  import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
58
58
  import { isLiveResend } from '../services/steer-resend.mjs';
59
- import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
59
+ import {
60
+ checkRequestedProviderModel,
61
+ resolveProviderModelSelection,
62
+ unknownProviderModelText,
63
+ } from '../services/provider-model-selection.mjs';
60
64
  import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
61
65
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
62
66
  import {
@@ -5019,6 +5023,13 @@ export function registerMessagesRoutes(app, deps) {
5019
5023
  code: 'OPENAI_MODEL_REQUIRES_NEW_CONVERSATION',
5020
5024
  });
5021
5025
  }
5026
+ // A Copilot conversation is bound too, for the model-id guards below only:
5027
+ // Copilot resells Claude and GPT models under ids of its own
5028
+ // ("claude-opus-5.5"), so a Claude-form id another provider lists
5029
+ // ("claude-opus-5-5") is a model Copilot lacks, answered by the Copilot
5030
+ // check further down, never a switch to Cursor, Grok or DeepSeek. (It is
5031
+ // not in runtimeBoundToExplicitProvider: that one also picks the catalog.)
5032
+ const runtimeBoundForModelGuards = runtimeBoundToExplicitProvider || runtimeProviderType === 'github';
5022
5033
  // Cursor conversations are bootstrap-created only, so an existing
5023
5034
  // github/openai/claude conversation cannot cross to the Cursor provider
5024
5035
  // mid-conversation. Cursor settings are read lazily so the hot path stays
@@ -5028,7 +5039,7 @@ export function registerMessagesRoutes(app, deps) {
5028
5039
  let requestTargetsCursor = requestedProviderType === 'cursor';
5029
5040
  if (
5030
5041
  !requestTargetsCursor
5031
- && !runtimeBoundToExplicitProvider
5042
+ && !runtimeBoundForModelGuards
5032
5043
  && requestedOpenAIModel
5033
5044
  && !requestedModelAvailableInGitHub
5034
5045
  ) {
@@ -5053,7 +5064,7 @@ export function registerMessagesRoutes(app, deps) {
5053
5064
  let requestTargetsGrok = requestedProviderType === 'grok';
5054
5065
  if (
5055
5066
  !requestTargetsGrok
5056
- && !runtimeBoundToExplicitProvider
5067
+ && !runtimeBoundForModelGuards
5057
5068
  && requestedOpenAIModel
5058
5069
  && !requestedModelAvailableInGitHub
5059
5070
  ) {
@@ -5079,7 +5090,7 @@ export function registerMessagesRoutes(app, deps) {
5079
5090
  let requestTargetsDeepSeek = requestedProviderType === 'deepseek';
5080
5091
  if (
5081
5092
  !requestTargetsDeepSeek
5082
- && !runtimeBoundToExplicitProvider
5093
+ && !runtimeBoundForModelGuards
5083
5094
  && requestedOpenAIModel
5084
5095
  && !requestedModelAvailableInGitHub
5085
5096
  ) {
@@ -5397,6 +5408,24 @@ export function registerMessagesRoutes(app, deps) {
5397
5408
  error: deepseekModel ? null : 'DeepSeek model is not configured',
5398
5409
  }
5399
5410
  : githubModelResolution)))));
5411
+ // A model Copilot does not list is named with its close matches, as the
5412
+ // bootstrap names it: "claude-opus-5-5" (the Claude form) gets "did you
5413
+ // mean 'claude-opus-5.5'?". Refused before anything is stored.
5414
+ if (!modelResolution.ok && modelResolution === githubModelResolution && explicitModelGiven) {
5415
+ const copilotCheck = checkRequestedProviderModel({
5416
+ requestedModel: requestedOpenAIModel,
5417
+ provider: 'github',
5418
+ models: Array.isArray(githubModelResolution.available) ? githubModelResolution.available : [],
5419
+ });
5420
+ if (copilotCheck.status === 'unknown') {
5421
+ return res.status(400).json({
5422
+ error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
5423
+ code: 'COPILOT_MODEL_UNAVAILABLE',
5424
+ supportedModels: copilotCheck.models,
5425
+ ...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
5426
+ });
5427
+ }
5428
+ }
5400
5429
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
5401
5430
  const requestedModel = String(modelResolution.model || '').trim();
5402
5431
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -501,15 +501,34 @@ export function parseGrokSettingsUpdateRequest(body = {}) {
501
501
  };
502
502
  }
503
503
 
504
- export function buildModelCatalogWithClaudeProvider(modelState = {}, claudeSettings = {}) {
505
- const configured = claudeSettings?.enabled === true;
506
- const model = String(claudeSettings?.model || '').trim();
507
- if (!configured || !model) return { ...modelState };
508
- const baseModels = new Set(
504
+ /**
505
+ * The lower-cased ids Copilot itself lists in `modelState`: those an earlier
506
+ * provider layer did not add. A layer tags every id it adds, so an id with
507
+ * tags but none of them Copilot's is another provider's ("claude-opus-5-5"
508
+ * from the Claude layer), not one Copilot serves too.
509
+ */
510
+ function copilotListedModelIds(modelState = {}) {
511
+ const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
512
+ ? modelState.providersByModel
513
+ : {};
514
+ return new Set(
509
515
  (Array.isArray(modelState?.models) ? modelState.models : [])
516
+ .filter((value) => {
517
+ const id = String(value || '').trim();
518
+ const providers = providersByModel[id] || providersByModel[id.toLowerCase()];
519
+ return !Array.isArray(providers) || providers.length === 0
520
+ || providers.some((provider) => String(provider || '').trim().toLowerCase() === 'github-copilot');
521
+ })
510
522
  .map((value) => String(value || '').trim().toLowerCase())
511
523
  .filter((value) => value && value.toLowerCase() !== 'auto'),
512
524
  );
525
+ }
526
+
527
+ export function buildModelCatalogWithClaudeProvider(modelState = {}, claudeSettings = {}) {
528
+ const configured = claudeSettings?.enabled === true;
529
+ const model = String(claudeSettings?.model || '').trim();
530
+ if (!configured || !model) return { ...modelState };
531
+ const baseModels = copilotListedModelIds(modelState);
513
532
  const claudeModels = Array.isArray(claudeSettings?.models)
514
533
  ? claudeSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
515
534
  : [];
@@ -605,11 +624,7 @@ export function buildModelCatalogWithOpenAIProvider(modelState = {}, openAISetti
605
624
  const configured = openAISettings?.enabled === true;
606
625
  const model = String(openAISettings?.model || '').trim();
607
626
  if (!configured || !model) return { ...modelState };
608
- const baseModels = new Set(
609
- (Array.isArray(modelState?.models) ? modelState.models : [])
610
- .map((value) => String(value || '').trim().toLowerCase())
611
- .filter((value) => value && value.toLowerCase() !== 'auto'),
612
- );
627
+ const baseModels = copilotListedModelIds(modelState);
613
628
  const openAIModels = Array.isArray(openAISettings?.models)
614
629
  ? openAISettings.models.map((value) => String(value || '').trim()).filter(Boolean)
615
630
  : [];
@@ -659,11 +674,7 @@ export function buildModelCatalogWithCursorProvider(modelState = {}, cursorSetti
659
674
  const configured = cursorSettings?.enabled === true;
660
675
  const model = String(cursorSettings?.model || '').trim();
661
676
  if (!configured || !model) return { ...modelState };
662
- const baseModels = new Set(
663
- (Array.isArray(modelState?.models) ? modelState.models : [])
664
- .map((value) => String(value || '').trim().toLowerCase())
665
- .filter((value) => value && value.toLowerCase() !== 'auto'),
666
- );
677
+ const baseModels = copilotListedModelIds(modelState);
667
678
  const cursorModelList = Array.isArray(cursorSettings?.models)
668
679
  ? cursorSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
669
680
  : [];
@@ -748,11 +759,7 @@ export function buildModelCatalogWithGrokProvider(modelState = {}, grokSettings
748
759
  const configured = grokSettings?.enabled === true;
749
760
  const model = String(grokSettings?.model || '').trim();
750
761
  if (!configured || !model) return { ...modelState };
751
- const baseModels = new Set(
752
- (Array.isArray(modelState?.models) ? modelState.models : [])
753
- .map((value) => String(value || '').trim().toLowerCase())
754
- .filter((value) => value && value.toLowerCase() !== 'auto'),
755
- );
762
+ const baseModels = copilotListedModelIds(modelState);
756
763
  const grokModelList = Array.isArray(grokSettings?.models)
757
764
  ? grokSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
758
765
  : [];
@@ -6600,6 +6607,7 @@ export function registerSessionsRoutes(app, deps) {
6600
6607
  // lookup below knows exact base ids only and would fall back too.
6601
6608
  let copilotBoundModel = '';
6602
6609
  let copilotVariantEffort = '';
6610
+ let copilotRefusal = null;
6603
6611
  if (
6604
6612
  remoteRequest.remote
6605
6613
  && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
@@ -6625,20 +6633,23 @@ export function registerSessionsRoutes(app, deps) {
6625
6633
  effortsFor: (base) => reasoningByModel[base] || reasoningByModel[String(base).toLowerCase()] || null,
6626
6634
  });
6627
6635
  // Another provider's model is answered by the checks below ("requires
6628
- // the Claude provider"), not as an unknown one.
6636
+ // the Claude provider"), not as an unknown one. One that several other
6637
+ // providers list ("claude-opus-5-5": Claude and Cursor) passes those
6638
+ // checks and is refused as unknown after them.
6629
6639
  const otherProviders = mergedModels.some((modelId) => String(modelId || '').trim().toLowerCase() === requestedBootstrapModel.toLowerCase());
6630
6640
  if (copilotCheck.status === 'known' && copilotCheck.model && !isAutoModelId(copilotCheck.model)) {
6631
6641
  copilotBoundModel = copilotCheck.base || copilotCheck.model;
6632
6642
  copilotVariantEffort = copilotCheck.effort || '';
6633
6643
  }
6634
- if (copilotCheck.status === 'unknown' && !otherProviders) {
6635
- return res.status(400).json({
6644
+ if (copilotCheck.status === 'unknown') {
6645
+ copilotRefusal = {
6636
6646
  ok: false,
6637
6647
  error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
6638
6648
  code: 'COPILOT_MODEL_UNAVAILABLE',
6639
6649
  supportedModels: copilotCheck.models,
6640
6650
  ...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
6641
- });
6651
+ };
6652
+ if (!otherProviders) return res.status(400).json(copilotRefusal);
6642
6653
  }
6643
6654
  }
6644
6655
  if (useOpenAIProvider && openAISettings?.configured !== true) {
@@ -6798,6 +6809,9 @@ export function registerSessionsRoutes(app, deps) {
6798
6809
  });
6799
6810
  }
6800
6811
  }
6812
+ // No single other provider claims it: a Copilot session with a model
6813
+ // Copilot does not list would be refused at its first send.
6814
+ if (copilotRefusal) return res.status(400).json(copilotRefusal);
6801
6815
 
6802
6816
  const bootstrapProviderType = useClaudeCloudProvider
6803
6817
  ? 'claude-cloud'
@@ -141,6 +141,7 @@ import { createClaudeSessionRootResolver } from './services/claude-session-root-
141
141
  import { createCursorSessionRootResolver } from './services/cursor-session-root-service.mjs';
142
142
  import { createGitChangesService } from './services/git-changes-service.mjs';
143
143
  import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
144
+ import { startRelayMemoryLog } from './relay-heap-guard.mjs';
144
145
  import { adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
145
146
  import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
146
147
  import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
@@ -1949,6 +1950,8 @@ const runtimeTimers = {
1949
1950
  sharedViewerPrune: null,
1950
1951
  restoredTaskSettle: null,
1951
1952
  claudeApiCapClock: null,
1953
+ memoryLog: null,
1954
+ memoryWatch: null,
1952
1955
  };
1953
1956
 
1954
1957
  function normalizeRemotePath(value) {
@@ -7856,6 +7859,13 @@ runtimeTimers.questionExpiry = setInterval(expirePendingQuestions, 10_000);
7856
7859
  expirePendingQuestions();
7857
7860
  runtimeTimers.sharedViewerPrune = setInterval(pruneSharedViewerPresence, 5_000);
7858
7861
  if (typeof runtimeTimers.sharedViewerPrune.unref === 'function') runtimeTimers.sharedViewerPrune.unref();
7862
+ // Memory in the status log: now, every 10 minutes, and once per crossing of
7863
+ // 80 % of the heap limit (relay-heap-guard.mjs).
7864
+ {
7865
+ const memoryTimers = startRelayMemoryLog({ logger: console });
7866
+ runtimeTimers.memoryLog = memoryTimers.log;
7867
+ runtimeTimers.memoryWatch = memoryTimers.check;
7868
+ }
7859
7869
  const sweptUploads = sweepUnreferencedUploadBlobs();
7860
7870
  if (sweptUploads > 0) {
7861
7871
  console.log(`${runtimeLogPrefix()}Reclaimed ${sweptUploads} unreferenced upload(s)`);
@@ -2386,10 +2386,29 @@ export function createRemoteRelayDispatcher({
2386
2386
  // From here the new session's queued turn counts toward the cap itself.
2387
2387
  ctx.onPromptQueued?.();
2388
2388
  } catch (error) {
2389
- if (!choice.effort || !isEffortRejection(error)) throw error;
2390
2389
  // The session exists by now: say so, or the agent starts a second one.
2390
+ const remoteCode = toText(error?.remoteBody?.code);
2391
+ if (!choice.effort || !isEffortRejection(error)) {
2392
+ // The remote's own 4xx is passed on, anything else is a 502, as
2393
+ // mapRemoteError answers them.
2394
+ const code = toText(error?.code);
2395
+ const remoteStatus = Number(error?.status) || 0;
2396
+ const status = error instanceof DispatchFailure
2397
+ ? error.status
2398
+ : (code.startsWith(REMOTE_RELAY_ERROR_CODES.httpPrefix) && remoteStatus >= 400 && remoteStatus < 500 ? remoteStatus : 502);
2399
+ const supportedModels = Array.isArray(error?.remoteBody?.supportedModels) ? error.remoteBody.supportedModels.slice(0, 50) : null;
2400
+ throw new DispatchFailure(status, code || `${REMOTE_RELAY_ERROR_CODES.httpPrefix}${status}`,
2401
+ `${toText(error?.message).replace(/[.\s]+$/, '') || `Relay "${ctx.relay.name}" refused the first prompt`}. The session ${session} was created, but the prompt was not queued.`,
2402
+ {
2403
+ ...(isPlainObject(error?.extra) ? error.extra : {}),
2404
+ session,
2405
+ ...(remoteCode ? { remoteCode } : {}),
2406
+ ...(supportedModels ? { supportedModels } : {}),
2407
+ openUrl: remoteConversationUrl(ctx.relay.url, session) || null,
2408
+ note: 'Use send with this session (update_session to change its model); do not create another session.',
2409
+ });
2410
+ }
2391
2411
  const supported = supportedEffortsOf(error);
2392
- const remoteCode = toText(error.remoteBody?.code);
2393
2412
  throw new DispatchFailure(400, toText(error.code) || `${REMOTE_RELAY_ERROR_CODES.httpPrefix}400`,
2394
2413
  `${toText(error.message) || `Relay "${ctx.relay.name}" refused effort "${choice.effort}"`}. The session was created, but the prompt was not queued.`,
2395
2414
  {
@@ -3966,7 +3985,13 @@ export function createRemoteRelayDispatcher({
3966
3985
  // wait_seconds is cut to this relay's "longest wait" setting, for every
3967
3986
  // target: the waiting is done here, by polling the target.
3968
3987
  const { maxWaitSeconds } = agentSessions();
3969
- const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action }, { maxWaitSeconds });
3988
+ // The server must always pass the provider lists: without them the
3989
+ // contract checks a provider's shape only (the worker's path).
3990
+ const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action }, {
3991
+ maxWaitSeconds,
3992
+ providers: REMOTE_RELAY_PROVIDERS,
3993
+ sendProviders: REMOTE_RELAY_SEND_PROVIDERS,
3994
+ });
3970
3995
  const result = await runValidated({ conversationId, req, signal }, validation);
3971
3996
  // The arguments the action does not take, named on the answer (a call
3972
3997
  // that came through a worker's tool had them named there already).
@@ -175,6 +175,17 @@ export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
175
175
  // xhigh, max, ultracode, ...), so the contract only checks the shape of the
176
176
  // value: the remote relay decides whether its model takes it.
177
177
  export const REMOTE_RELAY_EFFORT_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
178
+ // A worker can outlive a relay update, so its tool checks a provider's shape
179
+ // only; the relay checks it against the list it has now.
180
+ export const REMOTE_RELAY_PROVIDER_ID_PATTERN = REMOTE_RELAY_EFFORT_PATTERN;
181
+
182
+ /** The provider field's description, with the lists a relay has. */
183
+ export function remoteRelayProviderDescription(providers = REMOTE_RELAY_PROVIDERS, sendProviders = REMOTE_RELAY_SEND_PROVIDERS) {
184
+ return `create_session: provider, one of: ${providers.join(', ')} (relay_info lists what the target relay offers; `
185
+ + 'default: the same provider as this session). '
186
+ + `send to a Claude session: one of ${sendProviders.join(', ')} (the subscription or the API key) switches its billing from the next turn, `
187
+ + 'together with this message; the other billing must be set up there. Omit it to keep the session\'s billing.';
188
+ }
178
189
 
179
190
  export const REMOTE_RELAY_RESULT_STATUSES = Object.freeze([
180
191
  'queued',
@@ -460,10 +471,7 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
460
471
  },
461
472
  provider: {
462
473
  type: 'string',
463
- enum: [...REMOTE_RELAY_PROVIDERS],
464
- description: 'create_session: provider (default: the same provider as this session). '
465
- + 'send to a Claude session: "claude" (the subscription) or "claude-api" (the API key) switches its billing from the next turn, '
466
- + 'together with this message; the other billing must be set up there. Omit it to keep the session\'s billing.',
474
+ description: remoteRelayProviderDescription(),
467
475
  },
468
476
  model: {
469
477
  type: 'string',
@@ -759,6 +767,10 @@ export function normalizeRemoteRelayRepo(value) {
759
767
  * does not know the setting) wait_seconds is clamped to the ceiling only.
760
768
  * `waitLimitedFrom` is set on the result when the relay's setting cut the wait.
761
769
  *
770
+ * `providers` / `sendProviders` are the relay's own lists. Without them (an
771
+ * adapter in a worker, which may be older than the relay) `provider` is
772
+ * checked for its shape only; the relay passes them and refuses an unknown one.
773
+ *
762
774
  * An argument the action does not take is never dropped in silence:
763
775
  * create_session and update_session refuse it (nothing is created or
764
776
  * changed), every other action lists it in `ignored` on the result, which the
@@ -777,7 +789,7 @@ export function validateRemoteRelayToolInput(input = {}, options = {}) {
777
789
  return notTaken.length ? { ...result, ignored: notTaken } : result;
778
790
  }
779
791
 
780
- function validateToolInput(input, { maxWaitSeconds, now } = {}) {
792
+ function validateToolInput(input, { maxWaitSeconds, now, providers, sendProviders } = {}) {
781
793
  const action = toText(input?.action).toLowerCase();
782
794
  if (!REMOTE_RELAY_ACTIONS.includes(action)) {
783
795
  return invalid(`action must be one of: ${REMOTE_RELAY_ACTIONS.join(', ')}`);
@@ -823,6 +835,16 @@ function validateToolInput(input, { maxWaitSeconds, now } = {}) {
823
835
  args[key] = value;
824
836
  return { ok: true };
825
837
  };
838
+ const optionalProvider = (allowed, refusal) => {
839
+ const value = toText(input?.provider).toLowerCase();
840
+ if (!value) return { ok: true };
841
+ if (!REMOTE_RELAY_PROVIDER_ID_PATTERN.test(value)) {
842
+ return invalid('provider must be a provider id such as claude or openai (letters, digits and dashes, at most 32 characters)');
843
+ }
844
+ if (Array.isArray(allowed) && !allowed.includes(value)) return invalid(refusal(value, allowed.join(', ')));
845
+ args.provider = value;
846
+ return { ok: true };
847
+ };
826
848
  const optionalText = (key, max = 500) => {
827
849
  const value = toText(input?.[key]);
828
850
  if (value) args[key] = value.slice(0, max);
@@ -883,12 +905,12 @@ function validateToolInput(input, { maxWaitSeconds, now } = {}) {
883
905
  const busyCheck = optionalEnum('if_busy', REMOTE_RELAY_IF_BUSY);
884
906
  if (!busyCheck.ok) return busyCheck;
885
907
  args.if_busy = args.if_busy || 'queue';
886
- const providerCheck = optionalEnum('provider', REMOTE_RELAY_SEND_PROVIDERS);
887
- if (!providerCheck.ok) {
888
- return invalid('send takes provider "claude" (the subscription) or "claude-api" (the API key), for a Claude session only');
889
- }
908
+ const providerCheck = optionalProvider(sendProviders, (value, list) =>
909
+ `provider "${value}" can't be used with send; send takes one of: ${list} (the Claude session's subscription or API key)`);
910
+ if (!providerCheck.ok) return providerCheck;
890
911
  } else {
891
- const providerCheck = optionalEnum('provider', REMOTE_RELAY_PROVIDERS);
912
+ const providerCheck = optionalProvider(providers, (value, list) =>
913
+ `provider "${value}" is not a provider here; this relay offers: ${list}`);
892
914
  if (!providerCheck.ok) return providerCheck;
893
915
  optionalText('cwd', 1000);
894
916
  optionalText('title', 200);