@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.
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
- package/CHANGELOG.md +42 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -2
- package/server/relay-heap-guard.mjs +338 -0
- package/server/relay-self-restart.mjs +33 -7
- package/server/relay-tools.md +2 -2
- package/server/routes/messages-routes.mjs +33 -4
- package/server/routes/sessions-routes.mjs +38 -24
- package/server/server-runtime.mjs +10 -0
- package/server/services/remote-relay-dispatcher.mjs +28 -3
- package/shared/remote-relay-contract.mjs +32 -10
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
197
|
+
signals.off('SIGINT', handleSigInt);
|
|
198
|
+
signals.off('SIGTERM', handleSigTerm);
|
|
173
199
|
}
|
|
174
200
|
}
|
|
175
201
|
}
|
package/server/relay-tools.md
CHANGED
|
@@ -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 {
|
|
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
|
-
&& !
|
|
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
|
-
&& !
|
|
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
|
-
&& !
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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'
|
|
6635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
887
|
-
|
|
888
|
-
|
|
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 =
|
|
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);
|