@addai/node 0.22.0 → 0.24.0

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.
@@ -52,6 +52,7 @@ const codex_binary_1 = require("./codex-binary");
52
52
  const diskguard_1 = require("./diskguard");
53
53
  const win_1 = require("./win");
54
54
  const events_1 = require("./events");
55
+ const mcp_headers_1 = require("./mcp-headers");
55
56
  /** Quote a string as a TOML basic string (double-quoted, escape backslash + quote). */
56
57
  function tomlString(s) {
57
58
  return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
@@ -60,6 +61,11 @@ function tomlString(s) {
60
61
  * Only the NAME goes in config.toml — the value rides on the child env, so
61
62
  * the token never lands on disk. */
62
63
  const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
64
+ /** Env var name carrying one HTTP header's value into the codex process.
65
+ * config.toml references it by name via env_http_headers, so the value
66
+ * itself never lands on disk. */
67
+ const headerEnvVar = (slug, header) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}` +
68
+ `_H_${header.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`;
63
69
  /** Build a config.toml that declares only the supplied MCP servers and
64
70
  * return the CODEX_HOME directory plus any env vars the child needs
65
71
  * (bearer tokens for remote servers). Caller is responsible for cleanup
@@ -74,12 +80,16 @@ function writeCodexHome(servers, workingDirectory) {
74
80
  // Remote MCP servers. Registry rows store command='http'|'sse' with
75
81
  // args=[url]; codex spells that `url = "..."`, NOT command/args. Writing
76
82
  // the row verbatim made codex try to exec a binary called `http`, so the
77
- // server never started and its tools silently vanished — which is how a
78
- // provider-ladder hop to codex dropped an entity's GitHub access without
79
- // any error anywhere. The claude path (install.ts) always handled this;
80
- // codex and grok did not.
81
- if (s.command === 'http' || s.command === 'sse') {
82
- const url = Array.isArray(s.args) && typeof s.args[0] === 'string' ? s.args[0] : null;
83
+ // server never started and its tools silently vanished.
84
+ //
85
+ // Credentials go through env_http_headers, which maps a header name to the
86
+ // NAME of an env var holding its value — so the whole credential set is
87
+ // forwarded and config.toml still never contains a secret. This adapter
88
+ // used to send only ONE credential as a bearer token, on the belief that
89
+ // codex could not send custom headers; it can, and a server needing more
90
+ // than one header (addai-drive carries three) was arriving half-authorised.
91
+ if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
92
+ const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
83
93
  if (!url) {
84
94
  console.error(`[codex] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
85
95
  continue;
@@ -87,22 +97,24 @@ function writeCodexHome(servers, workingDirectory) {
87
97
  const name = tomlString(s.slug).slice(1, -1);
88
98
  toml += `[mcp_servers.${name}]\n`;
89
99
  toml += `url = ${tomlString(url)}\n`;
90
- // codex reads the bearer from its OWN environment (--env is stdio-only),
91
- // so the value goes on the child env and only the var name in the file.
92
- const creds = Object.entries(s.env || {})
93
- .filter(([k, v]) => typeof v === 'string' && v.length > 0 && !k.startsWith('OAUTH_REFRESH'));
94
- const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
100
+ const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
101
+ // An OAuth access token is spelled as codex's native bearer rather than
102
+ // a hand-rolled Authorization header, so its OAuth handling still applies.
103
+ const bearer = headers.Authorization;
95
104
  if (bearer) {
105
+ delete headers.Authorization;
96
106
  const varName = bearerEnvVar(s.slug);
97
- extraEnv[varName] = String(bearer[1]);
107
+ extraEnv[varName] = bearer.replace(/^Bearer\s+/i, '');
98
108
  toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
99
- if (creds.length > 1) {
100
- // codex carries one bearer and no arbitrary headers. Anything that
101
- // authenticates with several custom headers is only partly wired,
102
- // and saying so beats a server that 401s for no visible reason.
103
- console.error(`[codex] MCP ${s.slug}: only ${bearer[0]} is forwarded as a bearer token; ` +
104
- `codex cannot send custom headers, so ${creds.length - 1} other credential ` +
105
- `field(s) were dropped`);
109
+ }
110
+ const entries = Object.entries(headers);
111
+ if (entries.length > 0) {
112
+ // Sub-table must follow the parent table's scalar keys.
113
+ toml += `[mcp_servers.${name}.env_http_headers]\n`;
114
+ for (const [header, value] of entries) {
115
+ const varName = headerEnvVar(s.slug, header);
116
+ extraEnv[varName] = value;
117
+ toml += `${tomlString(header)} = ${tomlString(varName)}\n`;
106
118
  }
107
119
  }
108
120
  toml += '\n';
@@ -114,7 +114,12 @@ async function handleRequest(req, res) {
114
114
  if (data == null)
115
115
  return;
116
116
  const inflight = (0, request_pump_1.inflightCount)();
117
- return send(res, 200, { ...data, inflight });
117
+ // The ceiling next to the count: "3 inflight" means nothing without it.
118
+ return send(res, 200, {
119
+ ...data,
120
+ inflight,
121
+ max_concurrent: (0, request_pump_1.maxConcurrentRuns)(),
122
+ });
118
123
  }
119
124
  // Recent requests (most recent first). ?limit=20 caps the count.
120
125
  if (path === '/requests' && method === 'GET') {
@@ -93,10 +93,28 @@ function dropBridges() {
93
93
  }
94
94
  bridges.clear();
95
95
  }
96
+ /**
97
+ * Start a connection attempt that CANNOT take the daemon down with it.
98
+ *
99
+ * `connect` is fire-and-forget. While the socket was gated on having a
100
+ * running desktop, most nodes returned early and never reached the pairing
101
+ * read or the WebSocket construction — so a throw there was unreachable in
102
+ * practice. Making the socket unconditional put every node on that path, and
103
+ * an unhandled rejection in Node terminates the process. At boot. In a loop.
104
+ * A relay that cannot connect must cost this node its push channel, nothing
105
+ * more.
106
+ */
107
+ function connectSafely() {
108
+ connect().catch(err => {
109
+ ws = null;
110
+ console.error('[relay] connect failed:', err?.message ?? String(err));
111
+ scheduleReconnect();
112
+ });
113
+ }
96
114
  function scheduleReconnect() {
97
115
  if (stopped)
98
116
  return;
99
- setTimeout(() => { void connect(); }, backoff);
117
+ setTimeout(connectSafely, backoff);
100
118
  backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
101
119
  }
102
120
  async function connect() {
@@ -215,7 +233,7 @@ function startRelayClient() {
215
233
  return;
216
234
  stopped = false;
217
235
  backoff = RECONNECT_MIN_MS;
218
- void connect();
236
+ connectSafely();
219
237
  }
220
238
  function stopRelayClient() {
221
239
  stopped = true;
@@ -26,6 +26,21 @@ export interface GeminiHandle {
26
26
  onEvent(cb: (e: RuntimeEvent) => void): void;
27
27
  done: Promise<number>;
28
28
  }
29
+ /**
30
+ * Write a workspace-scope `<cwd>/.gemini/settings.json` for THIS spawn.
31
+ *
32
+ * gemini-cli's settings hierarchy is workspace > user — a workspace
33
+ * settings file wins over `~/.gemini/settings.json` field-by-field, so
34
+ * we can override `mcpServers` (the field we care about) without
35
+ * touching the user's home dir.
36
+ *
37
+ * The OLD approach pointed HOME at a temp dir to suppress the host's
38
+ * `~/.gemini/settings.json`. That was destructive: gemini-cli shells out
39
+ * to git, npm, node, and those tools read `~/.gitconfig`, `~/.npmrc`,
40
+ * `~/.netrc` from HOME. Pointing HOME at an empty dir blew them all
41
+ * away → silent tool failures (git push without creds, etc.).
42
+ */
43
+ export declare function writeGeminiWorkspaceSettings(cwd: string, servers: GeminiMcpServer[]): string;
29
44
  /** gemini-cli session ids are v4 UUIDs (createSessionId → node randomUUID).
30
45
  * Only pass a plausibly-gemini id to `--resume`; a foreign id would just be
31
46
  * reported "Invalid session identifier" and the run degrades to fresh via the
@@ -44,6 +44,7 @@ var __importStar = (this && this.__importStar) || (function () {
44
44
  };
45
45
  })();
46
46
  Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.writeGeminiWorkspaceSettings = writeGeminiWorkspaceSettings;
47
48
  exports.isGeminiSessionId = isGeminiSessionId;
48
49
  exports.lineToEvents = lineToEvents;
49
50
  exports.spawnGemini = spawnGemini;
@@ -55,6 +56,7 @@ const gemini_binary_1 = require("./gemini-binary");
55
56
  const win_1 = require("./win");
56
57
  const events_1 = require("./events");
57
58
  const think_split_1 = require("./think-split");
59
+ const mcp_headers_1 = require("./mcp-headers");
58
60
  /**
59
61
  * Write a workspace-scope `<cwd>/.gemini/settings.json` for THIS spawn.
60
62
  *
@@ -76,6 +78,24 @@ function writeGeminiWorkspaceSettings(cwd, servers) {
76
78
  for (const s of servers) {
77
79
  if (!s.slug || !s.command)
78
80
  continue;
81
+ // Remote MCP servers. gemini's settings schema spells streamable HTTP as
82
+ // `httpUrl` and SSE as `url` + `type: "sse"`, both with a `headers` map.
83
+ // Writing the registry row verbatim made gemini try to exec a binary
84
+ // called `http`, so the server never started and its tools were simply
85
+ // absent from the run — no error, nothing in the DB.
86
+ if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
87
+ const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
88
+ if (!url) {
89
+ console.error(`[gemini] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
90
+ continue;
91
+ }
92
+ const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
93
+ const hasHeaders = Object.keys(headers).length > 0;
94
+ mcpServers[s.slug] = s.command === 'sse'
95
+ ? { url, type: 'sse', ...(hasHeaders ? { headers } : {}) }
96
+ : { httpUrl: url, ...(hasHeaders ? { headers } : {}) };
97
+ continue;
98
+ }
79
99
  // `cmd /c` wrapper for npm-shim commands on native Windows (no-op on POSIX).
80
100
  const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args : []);
81
101
  mcpServers[s.slug] = {
@@ -74,6 +74,7 @@ const grok_binary_1 = require("./grok-binary");
74
74
  const win_1 = require("./win");
75
75
  const events_1 = require("./events");
76
76
  const think_split_1 = require("./think-split");
77
+ const mcp_headers_1 = require("./mcp-headers");
77
78
  /** Real config dir for the logged-in account. GROK_HOME points here so the
78
79
  * entity uses the same account and token refresh works. */
79
80
  function realGrokHome() {
@@ -160,16 +161,30 @@ function writeProjectMcpConfig(workingDirectory, servers) {
160
161
  }
161
162
  const lines = [];
162
163
  for (const s of usable) {
163
- // Remote MCP servers (registry command='http'|'sse', args=[url]) have no
164
- // verified grok config spelling, and writing the row verbatim is worse
165
- // than skipping: grok would try to exec a binary called `http`, the
166
- // server would never start, and its tools would vanish with no error
167
- // the exact silent failure that cost an entity its GitHub access after a
168
- // provider-ladder hop. Skip it, and say so loudly enough to be findable.
169
- if (s.command === 'http' || s.command === 'sse') {
170
- console.error(`[grok] MCP ${s.slug}: remote ${s.command} servers are not supported by the grok ` +
171
- `adapter skipping. Its tools will be ABSENT from this run. Use claude (or codex, ` +
172
- `which supports url + bearer_token_env_var) for entities that depend on it.`);
164
+ // Remote MCP servers. xAI documents [mcp_servers.<name>] with `url` and a
165
+ // `headers` inline table, so grok now gets them properly instead of the
166
+ // skip this used to do a skip that was safe (better than emitting
167
+ // `command = "http"`, which grok would try to exec) but meant an entity
168
+ // running on grok silently had none of its remote servers.
169
+ //
170
+ // Folder trust still gates repo-local config.toml servers, so the --trust
171
+ // launch flag remains load-bearing for these entries to start at all.
172
+ if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
173
+ const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
174
+ if (!url) {
175
+ console.error(`[grok] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
176
+ continue;
177
+ }
178
+ lines.push(`[mcp_servers.${tomlStr(s.slug)}]`);
179
+ lines.push(`url = ${tomlStr(url)}`);
180
+ const headers = Object.entries((0, mcp_headers_1.remoteMcpHeaders)(s.env));
181
+ if (headers.length > 0) {
182
+ const kv = headers.map(([k, v]) => `${tomlStr(k)} = ${tomlStr(v)}`).join(', ');
183
+ lines.push(`headers = { ${kv} }`);
184
+ }
185
+ lines.push('enabled = true');
186
+ lines.push('startup_timeout_sec = 30');
187
+ lines.push('');
173
188
  continue;
174
189
  }
175
190
  // `cmd /c` wrapper for npm-shim commands on native Windows (no-op on POSIX).
package/dist/heartbeat.js CHANGED
@@ -53,6 +53,12 @@ async function tick() {
53
53
  }
54
54
  if (res && typeof res.auto_update === 'boolean')
55
55
  autoUpdateArmed = res.auto_update;
56
+ // How many runs this node may hold at once. Same rationale as auto_update:
57
+ // one number on the node's own row, and the daemon is already talking to
58
+ // the server every 30s. Absent field = older server = leave the pump on
59
+ // whatever it already had (its own default of 4).
60
+ if (res && res.max_concurrent_runs != null)
61
+ (0, request_pump_1.setMaxConcurrent)(res.max_concurrent_runs);
56
62
  }
57
63
  catch (err) {
58
64
  if (err instanceof supabase_client_1.RpcError && err.status === 401) {
package/dist/install.d.ts CHANGED
@@ -42,6 +42,7 @@ export interface InstallResult {
42
42
  /** Cleanup callback — removes everything this install wrote. */
43
43
  dispose: () => Promise<void>;
44
44
  }
45
+ export declare function writeMcpConfig(cwd: string, mcps: ResolvedMcp[]): string | null;
45
46
  /**
46
47
  * Run install side-effects against `cwd` and return paths to clean up
47
48
  * later. Safe to call with an empty set — returns a no-op result.
package/dist/install.js CHANGED
@@ -47,12 +47,14 @@ var __importStar = (this && this.__importStar) || (function () {
47
47
  })();
48
48
  Object.defineProperty(exports, "__esModule", { value: true });
49
49
  exports.LEGACY_MCP_CONFIG_FILENAME = exports.MCP_CONFIG_FILENAME = void 0;
50
+ exports.writeMcpConfig = writeMcpConfig;
50
51
  exports.install = install;
51
52
  const fs = __importStar(require("fs"));
52
53
  const path = __importStar(require("path"));
53
54
  const supabase_client_1 = require("./supabase-client");
54
55
  const store_1 = require("./store");
55
56
  const win_1 = require("./win");
57
+ const mcp_headers_1 = require("./mcp-headers");
56
58
  /** Hidden per-session MCP config written next to the agent's working dir. */
57
59
  exports.MCP_CONFIG_FILENAME = '.ainode.mcp.json';
58
60
  /** Pre-rename filename; removed when found so agents don't read a stale copy. */
@@ -111,27 +113,19 @@ function writeMcpConfig(cwd, mcps) {
111
113
  if (!m.slug || !m.command)
112
114
  continue;
113
115
  // Remote MCP servers: registry rows use command='http' (or 'sse') with
114
- // args=[url]. An OAuth bearer token rides in the credential as
115
- // OAUTH_ACCESS_TOKEN and becomes the Authorization header this is how
116
- // headless sessions use account-authenticated remote MCPs (e.g. the
117
- // official Higgsfield server) without an interactive OAuth dance.
118
- if (m.command === 'http' || m.command === 'sse') {
119
- const url = Array.isArray(m.args) && typeof m.args[0] === 'string' ? m.args[0] : null;
120
- if (!url)
116
+ // args=[url]. Credentials ride as request headers see mcp-headers.ts for
117
+ // the naming rule and why OAUTH_* keys other than the access token are
118
+ // withheld. This is how headless sessions use account-authenticated remote
119
+ // MCPs (e.g. the official Higgsfield server) without an interactive OAuth
120
+ // dance.
121
+ if ((0, mcp_headers_1.isRemoteMcp)(m.command)) {
122
+ const url = (0, mcp_headers_1.remoteMcpUrl)(m.args);
123
+ if (!url) {
124
+ console.error(`[install] MCP ${m.slug}: ${m.command} server has no URL — skipped`);
121
125
  continue;
122
- const entry = { type: m.command, url };
123
- const headers = {};
124
- const token = m.env?.OAUTH_ACCESS_TOKEN;
125
- if (token)
126
- headers.Authorization = `Bearer ${token}`;
127
- // Other env keys become literal headers (so remote MCPs with custom
128
- // header auth work), but OAUTH_* keys are token bookkeeping (refresh
129
- // token, expiry) — never send them as request headers.
130
- for (const [k, v] of Object.entries(m.env || {})) {
131
- if (!v || k.startsWith('OAUTH_'))
132
- continue;
133
- headers[k.replace(/_/g, '-')] = v;
134
126
  }
127
+ const entry = { type: m.command, url };
128
+ const headers = (0, mcp_headers_1.remoteMcpHeaders)(m.env);
135
129
  if (Object.keys(headers).length > 0)
136
130
  entry.headers = headers;
137
131
  servers[m.slug] = entry;
@@ -39,6 +39,13 @@ export interface KimiHandle {
39
39
  onActivity(cb: () => void): void;
40
40
  done: Promise<number>;
41
41
  }
42
+ /** Write a sandbox $HOME with an empty .kimi dir so kimi's default
43
+ * `~/.kimi/mcp.json` resolves to nothing. Caller is responsible for
44
+ * cleanup (we drop it under os.tmpdir() so the OS reaps it). */
45
+ export declare function writeKimiSandboxHome(servers: KimiMcpServer[]): {
46
+ homeDir: string;
47
+ mcpConfigPath: string;
48
+ };
42
49
  /** Parse one stream-json line into 0+ RuntimeEvents. Kimi's shapes are
43
50
  * similar in spirit to claude's: assistant text deltas, tool calls,
44
51
  * turn completion, and session metadata. Anything we don't recognise
@@ -42,6 +42,7 @@ var __importStar = (this && this.__importStar) || (function () {
42
42
  };
43
43
  })();
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.writeKimiSandboxHome = writeKimiSandboxHome;
45
46
  exports.lineToEvents = lineToEvents;
46
47
  exports.spawnKimi = spawnKimi;
47
48
  const child_process_1 = require("child_process");
@@ -52,6 +53,7 @@ const kimi_binary_1 = require("./kimi-binary");
52
53
  const win_1 = require("./win");
53
54
  const events_1 = require("./events");
54
55
  const think_split_1 = require("./think-split");
56
+ const mcp_headers_1 = require("./mcp-headers");
55
57
  /** Write a sandbox $HOME with an empty .kimi dir so kimi's default
56
58
  * `~/.kimi/mcp.json` resolves to nothing. Caller is responsible for
57
59
  * cleanup (we drop it under os.tmpdir() so the OS reaps it). */
@@ -62,6 +64,24 @@ function writeKimiSandboxHome(servers) {
62
64
  for (const s of servers) {
63
65
  if (!s.slug || !s.command)
64
66
  continue;
67
+ // Remote MCP servers. kimi's mcp.json takes `url` and infers the transport
68
+ // from url-vs-command, so no `transport` key is needed for streamable HTTP;
69
+ // SSE is stated explicitly. Headers carry the credential set. Writing the
70
+ // registry row verbatim made kimi try to exec a binary called `http`.
71
+ if ((0, mcp_headers_1.isRemoteMcp)(s.command)) {
72
+ const url = (0, mcp_headers_1.remoteMcpUrl)(s.args);
73
+ if (!url) {
74
+ console.error(`[kimi] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
75
+ continue;
76
+ }
77
+ const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
78
+ mcpConfig.mcpServers[s.slug] = {
79
+ url,
80
+ ...(s.command === 'sse' ? { transport: 'sse' } : {}),
81
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
82
+ };
83
+ continue;
84
+ }
65
85
  // `cmd /c` wrapper for npm-shim commands on native Windows (no-op on POSIX).
66
86
  const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args : []);
67
87
  mcpConfig.mcpServers[s.slug] = {
@@ -0,0 +1,15 @@
1
+ /** True when a registry row describes a remote MCP server rather than a
2
+ * local process to spawn. */
3
+ export declare function isRemoteMcp(command: string): boolean;
4
+ /** The endpoint a remote row points at. Returns null for a malformed row so
5
+ * callers can skip it loudly rather than emit a broken config entry. */
6
+ export declare function remoteMcpUrl(args?: string[] | null): string | null;
7
+ /**
8
+ * Convert a registry env map into the HTTP headers the remote server expects.
9
+ *
10
+ * Underscores become hyphens (ENTITY_API_KEY -> ENTITY-API-KEY), matching the
11
+ * deployed github-mcp server. OAUTH_ACCESS_TOKEN becomes an Authorization
12
+ * bearer; every OTHER OAUTH_* key is token bookkeeping (refresh token, expiry)
13
+ * and must never be sent as a header.
14
+ */
15
+ export declare function remoteMcpHeaders(env?: Record<string, string> | null): Record<string, string>;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ // Shared handling for remote (http/sse) MCP registry rows.
3
+ //
4
+ // Registry rows store remote servers as command='http'|'sse' with args=[url]
5
+ // and the credential set in env. Each agent CLI spells the config differently,
6
+ // but all five make the same three decisions first — is this row remote, what
7
+ // URL does it point at, and what headers does its env map become. That lived
8
+ // inline in install.ts (the claude path) and the other four adapters each got
9
+ // it wrong in their own way, so it lives here now and they all call it.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.isRemoteMcp = isRemoteMcp;
12
+ exports.remoteMcpUrl = remoteMcpUrl;
13
+ exports.remoteMcpHeaders = remoteMcpHeaders;
14
+ /** True when a registry row describes a remote MCP server rather than a
15
+ * local process to spawn. */
16
+ function isRemoteMcp(command) {
17
+ return command === 'http' || command === 'sse';
18
+ }
19
+ /** The endpoint a remote row points at. Returns null for a malformed row so
20
+ * callers can skip it loudly rather than emit a broken config entry. */
21
+ function remoteMcpUrl(args) {
22
+ if (!Array.isArray(args))
23
+ return null;
24
+ const first = args[0];
25
+ return typeof first === 'string' && first.length > 0 ? first : null;
26
+ }
27
+ /**
28
+ * Convert a registry env map into the HTTP headers the remote server expects.
29
+ *
30
+ * Underscores become hyphens (ENTITY_API_KEY -> ENTITY-API-KEY), matching the
31
+ * deployed github-mcp server. OAUTH_ACCESS_TOKEN becomes an Authorization
32
+ * bearer; every OTHER OAUTH_* key is token bookkeeping (refresh token, expiry)
33
+ * and must never be sent as a header.
34
+ */
35
+ function remoteMcpHeaders(env) {
36
+ const headers = {};
37
+ for (const [key, value] of Object.entries(env || {})) {
38
+ if (typeof value !== 'string' || value.length === 0)
39
+ continue;
40
+ if (key === 'OAUTH_ACCESS_TOKEN') {
41
+ headers.Authorization = `Bearer ${value}`;
42
+ continue;
43
+ }
44
+ if (key.startsWith('OAUTH_'))
45
+ continue;
46
+ headers[key.replace(/_/g, '-')] = value;
47
+ }
48
+ return headers;
49
+ }
@@ -1,3 +1,14 @@
1
+ export declare const DEFAULT_MAX_CONCURRENT = 4;
2
+ /** Clamp whatever the server sent into something the pump can act on. A null,
3
+ * a string, a 0 or a 10_000 must not be able to wedge the pump shut or uncap
4
+ * it — the ceiling matches the DB's own 1..32 check constraint. */
5
+ export declare function clampMaxConcurrent(value: unknown): number;
6
+ /** Called by the heartbeat when the node's row says a different number.
7
+ * Lowering it never interrupts a run already in flight — those finish; the
8
+ * pump simply stops picking up new work until it is back under the line. */
9
+ export declare function setMaxConcurrent(value: unknown): void;
10
+ /** The node's current ceiling. Exported for /stats and the TUI. */
11
+ export declare function maxConcurrentRuns(): number;
1
12
  export declare function inflightCount(): number;
2
13
  export declare function activeRequestIdList(): string[];
3
14
  export declare function start(): void;
@@ -4,6 +4,10 @@
4
4
  // from spawning unlimited Claude processes when a burst of requests
5
5
  // arrives.
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.DEFAULT_MAX_CONCURRENT = void 0;
8
+ exports.clampMaxConcurrent = clampMaxConcurrent;
9
+ exports.setMaxConcurrent = setMaxConcurrent;
10
+ exports.maxConcurrentRuns = maxConcurrentRuns;
7
11
  exports.inflightCount = inflightCount;
8
12
  exports.activeRequestIdList = activeRequestIdList;
9
13
  exports.start = start;
@@ -25,7 +29,51 @@ const diskguard_1 = require("./diskguard");
25
29
  // ~16x cheaper at steady state. Successful pickups still re-tick
26
30
  // immediately so bursts drain fast.
27
31
  const POLL_INTERVAL_MS = 2_000;
28
- const MAX_CONCURRENT = 4;
32
+ // How many runs this node holds at once. This used to be `const MAX_CONCURRENT
33
+ // = 4` — one number, compiled in, the same for a 64-core box and a Mac mini.
34
+ // It is now the node's own setting (entity_runtimes.max_concurrent_runs),
35
+ // pushed in by the heartbeat that already talks to the server every 30s.
36
+ //
37
+ // The default is still 4, and specifically 4 when the server says NOTHING: a
38
+ // server without the migration must read as "the behaviour you had yesterday",
39
+ // never as 0 (a node that accepts no work at all) or unbounded.
40
+ exports.DEFAULT_MAX_CONCURRENT = 4;
41
+ const MAX_MAX_CONCURRENT = 32;
42
+ let maxConcurrent = exports.DEFAULT_MAX_CONCURRENT;
43
+ /** Clamp whatever the server sent into something the pump can act on. A null,
44
+ * a string, a 0 or a 10_000 must not be able to wedge the pump shut or uncap
45
+ * it — the ceiling matches the DB's own 1..32 check constraint. */
46
+ function clampMaxConcurrent(value) {
47
+ // Absent is NOT zero. `Number(null)` and `Number('')` are both 0, which the
48
+ // clamp below would happily read as "one slot" — so a server that said
49
+ // nothing would quietly throttle the node to a single run. Anything that
50
+ // isn't a real number-shaped value means "no answer", which means: keep
51
+ // doing what you were doing.
52
+ if (typeof value !== 'number' && typeof value !== 'string')
53
+ return exports.DEFAULT_MAX_CONCURRENT;
54
+ if (typeof value === 'string' && value.trim() === '')
55
+ return exports.DEFAULT_MAX_CONCURRENT;
56
+ const n = typeof value === 'number' ? value : Number(value);
57
+ if (!Number.isFinite(n))
58
+ return exports.DEFAULT_MAX_CONCURRENT;
59
+ return Math.min(MAX_MAX_CONCURRENT, Math.max(1, Math.floor(n)));
60
+ }
61
+ /** Called by the heartbeat when the node's row says a different number.
62
+ * Lowering it never interrupts a run already in flight — those finish; the
63
+ * pump simply stops picking up new work until it is back under the line. */
64
+ function setMaxConcurrent(value) {
65
+ const next = clampMaxConcurrent(value);
66
+ if (next === maxConcurrent)
67
+ return;
68
+ console.log(`[request-pump] runs at once: ${maxConcurrent} -> ${next}`);
69
+ maxConcurrent = next;
70
+ // Raising the cap should take effect NOW, not at the next 2s poll — the
71
+ // usual reason someone raises it is that work is queued up behind it.
72
+ if (!stopped && inflight < maxConcurrent)
73
+ queueMicrotask(() => { void tick(); });
74
+ }
75
+ /** The node's current ceiling. Exported for /stats and the TUI. */
76
+ function maxConcurrentRuns() { return maxConcurrent; }
29
77
  // Anti-leak backstop ONLY — how long the pump will hold a concurrency slot
30
78
  // for a run whose promise never resolves (e.g. a spawn whose onExit is
31
79
  // lost). This does NOT kill the agent and is NOT an execution limit: it
@@ -72,7 +120,7 @@ function activeRequestIdList() { return [...activeRequestIds]; }
72
120
  async function tick() {
73
121
  if (stopped)
74
122
  return;
75
- if (inflight >= MAX_CONCURRENT)
123
+ if (inflight >= maxConcurrent)
76
124
  return;
77
125
  // Honour the backoff window — skip this tick if we recently failed.
78
126
  if (Date.now() < nextPickAllowedAt)
@@ -215,7 +263,7 @@ async function tick() {
215
263
  // Re-tick immediately so back-to-back requests don't wait for the
216
264
  // next interval. If there's nothing to pick up the next call is a
217
265
  // cheap no-op.
218
- if (!stopped && inflight < MAX_CONCURRENT) {
266
+ if (!stopped && inflight < maxConcurrent) {
219
267
  queueMicrotask(() => { void tick(); });
220
268
  }
221
269
  }
@@ -20,6 +20,8 @@ export interface DashboardState {
20
20
  pid: number | null;
21
21
  startedAt: number | null;
22
22
  inflight: number;
23
+ /** The node's ceiling on concurrent runs — the denominator for `inflight`. */
24
+ maxConcurrent: number;
23
25
  paired: boolean;
24
26
  viewerMode: boolean;
25
27
  /** First press of 'd' asks; the second one does it. */
@@ -84,7 +84,7 @@ function headerLines(st) {
84
84
  const up = st.startedAt ? (0, render_1.fmtDuration)(st.now - st.startedAt) : '—';
85
85
  const daemon = st.viewerMode
86
86
  ? `${(0, render_1.cyan)('⏺')} Viewer ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)} ${(0, render_1.dim)('· another process owns this node')}`
87
- : `${(0, render_1.green)('⏺')} Running ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)} ${(0, render_1.dim)(`up ${up}`)} ${(0, render_1.dim)(`${st.inflight} in flight`)}`;
87
+ : `${(0, render_1.green)('⏺')} Running ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)} ${(0, render_1.dim)(`up ${up}`)} ${(0, render_1.dim)(`${st.inflight}/${st.maxConcurrent} in flight`)}`;
88
88
  const chip = st.offline ? ` ${(0, render_1.yellow)('⚠ Offline · retrying')}` : '';
89
89
  return [first, daemon + chip, autostartLine(st)];
90
90
  }
package/dist/tui/run.js CHANGED
@@ -195,7 +195,7 @@ async function runDashboard(opts) {
195
195
  const state = {
196
196
  self: null, stats: null, recent: [], sel: 0, nowSelId: null, nowRows: 0, spin: 0,
197
197
  pid: opts.pid, startedAt: opts.startedAt,
198
- inflight: 0, paired: (0, store_1.isPaired)(), viewerMode: opts.viewerMode,
198
+ inflight: 0, maxConcurrent: request_pump_1.DEFAULT_MAX_CONCURRENT, paired: (0, store_1.isPaired)(), viewerMode: opts.viewerMode,
199
199
  offline: false, now: Date.now(), version: opts.version,
200
200
  logCount: 0,
201
201
  // Read on the first poll rather than here — the first frame must paint
@@ -229,6 +229,7 @@ async function runDashboard(opts) {
229
229
  // The daemon's own numbers don't come from an RPC.
230
230
  setInterval(() => {
231
231
  state.inflight = (0, request_pump_1.inflightCount)();
232
+ state.maxConcurrent = (0, request_pump_1.maxConcurrentRuns)();
232
233
  state.logCount = logs.count();
233
234
  }, 1000).unref();
234
235
  await ui.run();
package/dist/win.d.ts CHANGED
@@ -35,13 +35,37 @@ export declare function resolveCliInvocation(bin: string, args: string[]): CliIn
35
35
  * note: no trailing `%`). */
36
36
  declare function resolveNpmShimScript(shimPath: string): string | null;
37
37
  /**
38
- * Kill an agent child process. POSIX: SIGINT (graceful — CLIs flush and
39
- * exit). win32: `taskkill /T /F` — Node's SIGINT emulation is a hard
38
+ * Kill an agent child process AND everything it spawned.
39
+ *
40
+ * win32: `taskkill /T /F` — Node's SIGINT emulation is a hard
40
41
  * TerminateProcess on the one process anyway, and /T also reaps the MCP
41
42
  * server grandchildren that would otherwise be orphaned.
43
+ *
44
+ * POSIX used to be one line — `proc.kill('SIGINT')` — which is neither a
45
+ * tree nor a guarantee, and both halves of that bit us. On 2026-08-12 a
46
+ * chatflows stop killed grok's direct child (the run reported
47
+ * `session_end exitCode -1`, i.e. died by signal) while the process it had
48
+ * forked carried on for another 49 seconds on the inherited stdout pipe,
49
+ * finished its turn, and posted a real message into someone's DM 22 seconds
50
+ * after the user pressed stop. So:
51
+ *
52
+ * 1. Signal the whole tree, not just the handle. We do not spawn agents
53
+ * `detached`, so there is no process group to signal — walking `ps` is
54
+ * the portable way to find the descendants, and it also catches stdio
55
+ * MCP servers the agent started (the same ones /T reaps on Windows).
56
+ * 2. Escalate. SIGINT first, because an agent CLI flushes its session
57
+ * file on it; SIGKILL KILL_ESCALATE_MS later for anything still there.
58
+ * A CLI is entitled to treat SIGINT as "interrupt the current input"
59
+ * rather than "exit" — being polite once and never following up is how
60
+ * a stop button turns into a suggestion.
61
+ *
62
+ * The escalation re-walks the tree: a process that ignored SIGINT may have
63
+ * forked since. The timer is unref'd so it never holds the daemon open.
42
64
  */
43
65
  export declare function killProcessTree(proc: {
44
66
  pid?: number | undefined;
67
+ exitCode?: number | null;
68
+ signalCode?: NodeJS.Signals | null;
45
69
  kill(signal?: NodeJS.Signals): boolean | void;
46
70
  }): void;
47
71
  /**
package/dist/win.js CHANGED
@@ -244,11 +244,77 @@ function resolveNpmShimScript(shimPath) {
244
244
  }
245
245
  return null;
246
246
  }
247
+ /** How long a POSIX agent gets to honour SIGINT before we stop asking. */
248
+ const KILL_ESCALATE_MS = 3000;
249
+ /** One `ps` snapshot as (pid, ppid) pairs. Empty on any failure — callers
250
+ * then fall back to signalling just the process they were handed, which is
251
+ * the old behaviour and never worse than it. */
252
+ function posixProcessTable() {
253
+ try {
254
+ const out = (0, child_process_1.execFileSync)('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8', timeout: 5000 });
255
+ return out.split('\n')
256
+ .map(line => line.trim().split(/\s+/))
257
+ .filter(parts => parts.length === 2)
258
+ .map(([a, b]) => ({ pid: Number(a), ppid: Number(b) }))
259
+ .filter(r => Number.isInteger(r.pid) && Number.isInteger(r.ppid) && r.pid > 0);
260
+ }
261
+ catch {
262
+ return [];
263
+ }
264
+ }
265
+ /** Every descendant of `root`, breadth-first (so the deepest come last). */
266
+ function posixDescendants(root) {
267
+ const byParent = new Map();
268
+ for (const { pid, ppid } of posixProcessTable()) {
269
+ if (pid === ppid)
270
+ continue; // pid 1 parents itself on some kernels
271
+ const kids = byParent.get(ppid);
272
+ if (kids)
273
+ kids.push(pid);
274
+ else
275
+ byParent.set(ppid, [pid]);
276
+ }
277
+ const out = [];
278
+ const seen = new Set([root]);
279
+ const queue = [root];
280
+ while (queue.length > 0) {
281
+ for (const kid of byParent.get(queue.shift()) ?? []) {
282
+ if (seen.has(kid))
283
+ continue;
284
+ seen.add(kid);
285
+ out.push(kid);
286
+ queue.push(kid);
287
+ }
288
+ }
289
+ return out;
290
+ }
247
291
  /**
248
- * Kill an agent child process. POSIX: SIGINT (graceful — CLIs flush and
249
- * exit). win32: `taskkill /T /F` — Node's SIGINT emulation is a hard
292
+ * Kill an agent child process AND everything it spawned.
293
+ *
294
+ * win32: `taskkill /T /F` — Node's SIGINT emulation is a hard
250
295
  * TerminateProcess on the one process anyway, and /T also reaps the MCP
251
296
  * server grandchildren that would otherwise be orphaned.
297
+ *
298
+ * POSIX used to be one line — `proc.kill('SIGINT')` — which is neither a
299
+ * tree nor a guarantee, and both halves of that bit us. On 2026-08-12 a
300
+ * chatflows stop killed grok's direct child (the run reported
301
+ * `session_end exitCode -1`, i.e. died by signal) while the process it had
302
+ * forked carried on for another 49 seconds on the inherited stdout pipe,
303
+ * finished its turn, and posted a real message into someone's DM 22 seconds
304
+ * after the user pressed stop. So:
305
+ *
306
+ * 1. Signal the whole tree, not just the handle. We do not spawn agents
307
+ * `detached`, so there is no process group to signal — walking `ps` is
308
+ * the portable way to find the descendants, and it also catches stdio
309
+ * MCP servers the agent started (the same ones /T reaps on Windows).
310
+ * 2. Escalate. SIGINT first, because an agent CLI flushes its session
311
+ * file on it; SIGKILL KILL_ESCALATE_MS later for anything still there.
312
+ * A CLI is entitled to treat SIGINT as "interrupt the current input"
313
+ * rather than "exit" — being polite once and never following up is how
314
+ * a stop button turns into a suggestion.
315
+ *
316
+ * The escalation re-walks the tree: a process that ignored SIGINT may have
317
+ * forked since. The timer is unref'd so it never holds the daemon open.
252
318
  */
253
319
  function killProcessTree(proc) {
254
320
  if (exports.IS_WINDOWS && proc.pid) {
@@ -261,10 +327,33 @@ function killProcessTree(proc) {
261
327
  }
262
328
  catch { /* fall through to plain kill */ }
263
329
  }
264
- try {
265
- proc.kill('SIGINT');
330
+ const pid = proc.pid;
331
+ // Already reaped: the pid is no longer ours and could belong to something
332
+ // else by now. proc.kill() is safe (Node no-ops it); process.kill() is not.
333
+ if (!pid || proc.exitCode !== null && proc.exitCode !== undefined
334
+ || proc.signalCode !== null && proc.signalCode !== undefined) {
335
+ try {
336
+ proc.kill('SIGINT');
337
+ }
338
+ catch { /* already gone */ }
339
+ return;
266
340
  }
267
- catch { /* already gone */ }
341
+ const signalTree = (signal) => {
342
+ // Deepest first: a parent that is about to die cannot usefully re-fork,
343
+ // but a live parent handed the signal first might.
344
+ for (const child of posixDescendants(pid).reverse()) {
345
+ try {
346
+ process.kill(child, signal);
347
+ }
348
+ catch { /* gone already */ }
349
+ }
350
+ try {
351
+ proc.kill(signal);
352
+ }
353
+ catch { /* already gone */ }
354
+ };
355
+ signalTree('SIGINT');
356
+ setTimeout(() => signalTree('SIGKILL'), KILL_ESCALATE_MS).unref();
268
357
  }
269
358
  /**
270
359
  * Rewrite a stdio MCP server command for the platform. On native Windows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
5
  "license": "MIT",
6
6
  "keywords": [