@addai/node 0.23.0 → 0.24.1
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/dist/claude-print.d.ts +14 -0
- package/dist/claude-print.js +37 -1
- package/dist/control-server.js +6 -1
- package/dist/grok-spawn.js +26 -12
- package/dist/heartbeat.js +6 -0
- package/dist/mcp-headers.d.ts +41 -0
- package/dist/mcp-headers.js +52 -0
- package/dist/request-pump.d.ts +11 -0
- package/dist/request-pump.js +51 -3
- package/dist/tui/dashboard.d.ts +2 -0
- package/dist/tui/dashboard.js +1 -1
- package/dist/tui/run.js +2 -1
- package/dist/win.d.ts +26 -2
- package/dist/win.js +94 -5
- package/package.json +1 -1
package/dist/claude-print.d.ts
CHANGED
|
@@ -13,6 +13,20 @@ export interface ClaudePrintInput {
|
|
|
13
13
|
/** Restrict to a specific set of tools (claude only). null/undefined = inherit. */
|
|
14
14
|
allowedTools?: string[];
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The text to emit for one flushed block, given what came before.
|
|
18
|
+
*
|
|
19
|
+
* The final reply is rebuilt with string_agg(delta, '') — an empty join,
|
|
20
|
+
* because deltas are partial TOKENS and any separator between them would
|
|
21
|
+
* corrupt words mid-word. Correct within a block, wrong between them: the
|
|
22
|
+
* entity narrates, calls a tool, narrates again, and the two blocks own no
|
|
23
|
+
* whitespace of their own, so they arrive glued as "the chat tool.Let me".
|
|
24
|
+
* The boundary is known here and nowhere else, so it is marked here.
|
|
25
|
+
*/
|
|
26
|
+
export declare function blockDelta(text: string, opts: {
|
|
27
|
+
afterTool: boolean;
|
|
28
|
+
hadTextBefore: boolean;
|
|
29
|
+
}): string;
|
|
16
30
|
export interface ClaudePrintHandle {
|
|
17
31
|
pid: number | undefined;
|
|
18
32
|
sessionId: string | null;
|
package/dist/claude-print.js
CHANGED
|
@@ -5,11 +5,29 @@
|
|
|
5
5
|
// our normalised RuntimeEvent shape, and resolves when the process exits.
|
|
6
6
|
// No PTY needed — Claude detects non-TTY and runs headless.
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.blockDelta = blockDelta;
|
|
8
9
|
exports.lineToEvents = lineToEvents;
|
|
9
10
|
exports.spawnClaudePrint = spawnClaudePrint;
|
|
10
11
|
const child_process_1 = require("child_process");
|
|
11
12
|
const claude_binary_1 = require("./claude-binary");
|
|
12
13
|
const win_1 = require("./win");
|
|
14
|
+
/**
|
|
15
|
+
* The text to emit for one flushed block, given what came before.
|
|
16
|
+
*
|
|
17
|
+
* The final reply is rebuilt with string_agg(delta, '') — an empty join,
|
|
18
|
+
* because deltas are partial TOKENS and any separator between them would
|
|
19
|
+
* corrupt words mid-word. Correct within a block, wrong between them: the
|
|
20
|
+
* entity narrates, calls a tool, narrates again, and the two blocks own no
|
|
21
|
+
* whitespace of their own, so they arrive glued as "the chat tool.Let me".
|
|
22
|
+
* The boundary is known here and nowhere else, so it is marked here.
|
|
23
|
+
*/
|
|
24
|
+
function blockDelta(text, opts) {
|
|
25
|
+
if (!opts.afterTool || !opts.hadTextBefore || !text.trim())
|
|
26
|
+
return text;
|
|
27
|
+
// Drop leading whitespace the model may already have supplied so a break
|
|
28
|
+
// is exactly one blank line, never two.
|
|
29
|
+
return `\n\n${text.replace(/^\s+/, '')}`;
|
|
30
|
+
}
|
|
13
31
|
/**
|
|
14
32
|
* Parse one stream-json line into 0+ RuntimeEvents.
|
|
15
33
|
*
|
|
@@ -190,6 +208,16 @@ function spawnClaudePrint(input) {
|
|
|
190
208
|
let textBuf = '';
|
|
191
209
|
let flushTimer = null;
|
|
192
210
|
let sawDeltaThisMsg = false;
|
|
211
|
+
// Has any answer text been emitted for this run yet? The final reply is
|
|
212
|
+
// rebuilt by string_agg(delta, '') — an empty join, because deltas are
|
|
213
|
+
// partial TOKENS and any separator between them would corrupt words. That
|
|
214
|
+
// is right within a block and wrong between them: the model narrates, calls
|
|
215
|
+
// a tool, narrates again, and the two blocks arrive as separate messages
|
|
216
|
+
// with no whitespace of their own. Glued together they read
|
|
217
|
+
// "load the chat tool.Let me confirm". So the boundary is marked here,
|
|
218
|
+
// where it is actually known, rather than guessed at in SQL.
|
|
219
|
+
let emittedAnyText = false;
|
|
220
|
+
let blockBreakPending = false;
|
|
193
221
|
// Thinking deltas (extended thinking) — coalesced lazily into 'thinking'
|
|
194
222
|
// events; the chat relay renders them as a collapsed section.
|
|
195
223
|
const THINK_FLUSH_MS = 700;
|
|
@@ -212,8 +240,11 @@ function spawnClaudePrint(input) {
|
|
|
212
240
|
flushTimer = null;
|
|
213
241
|
}
|
|
214
242
|
if (textBuf) {
|
|
215
|
-
|
|
243
|
+
let t = textBuf;
|
|
216
244
|
textBuf = '';
|
|
245
|
+
t = blockDelta(t, { afterTool: blockBreakPending, hadTextBefore: emittedAnyText });
|
|
246
|
+
blockBreakPending = false;
|
|
247
|
+
emittedAnyText = true;
|
|
217
248
|
emit({ type: 'assistant_text', delta: t });
|
|
218
249
|
}
|
|
219
250
|
};
|
|
@@ -258,6 +289,11 @@ function spawnClaudePrint(input) {
|
|
|
258
289
|
// was seen for this message, in which case we keep the block (never lose text).
|
|
259
290
|
if (partialStream && (type === 'assistant' || type === 'user' || type === 'result'))
|
|
260
291
|
flushText();
|
|
292
|
+
// A tool result means the entity stopped talking to go and do something.
|
|
293
|
+
// Whatever it says next is a new paragraph, not a continuation of the
|
|
294
|
+
// sentence it was halfway through.
|
|
295
|
+
if (type === 'user')
|
|
296
|
+
blockBreakPending = true;
|
|
261
297
|
const suppressText = partialStream && sawDeltaThisMsg && type === 'assistant';
|
|
262
298
|
for (const ev of lineToEvents(parsed, suppressText))
|
|
263
299
|
emit(ev);
|
package/dist/control-server.js
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/dist/grok-spawn.js
CHANGED
|
@@ -161,11 +161,24 @@ function writeProjectMcpConfig(workingDirectory, servers) {
|
|
|
161
161
|
}
|
|
162
162
|
const lines = [];
|
|
163
163
|
for (const s of usable) {
|
|
164
|
-
// Remote MCP servers
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
164
|
+
// Remote MCP servers are BRIDGED through mcp-remote, because grok cannot
|
|
165
|
+
// speak HTTP MCP itself.
|
|
166
|
+
//
|
|
167
|
+
// Its bundled README documents `url` + a `headers` inline table, and this
|
|
168
|
+
// code used to emit exactly that. Measured against grok 1.0.3, that entry
|
|
169
|
+
// is SILENTLY IGNORED: in one run grok loaded ten stdio servers and skipped
|
|
170
|
+
// both url-based ones — ours and a pre-existing third-party entry — with no
|
|
171
|
+
// error for either. The docs describe a transport the binary does not have.
|
|
172
|
+
//
|
|
173
|
+
// That is why entities on grok lost every Connection the moment the
|
|
174
|
+
// registry moved from `npx` to `http`: the servers were simply not there,
|
|
175
|
+
// and grok reported it to the model as "(auth required)".
|
|
176
|
+
//
|
|
177
|
+
// mcp-remote is the standard stdio<->HTTP shim (grok's own README reaches
|
|
178
|
+
// for it in its Linear example) and it is verified working here. The cost
|
|
179
|
+
// is an npx start per remote server on grok runs only — the very cost the
|
|
180
|
+
// HTTP move removed for every other agent. Revisit when grok ships real
|
|
181
|
+
// remote-MCP support.
|
|
169
182
|
//
|
|
170
183
|
// Folder trust still gates repo-local config.toml servers, so the --trust
|
|
171
184
|
// launch flag remains load-bearing for these entries to start at all.
|
|
@@ -175,15 +188,16 @@ function writeProjectMcpConfig(workingDirectory, servers) {
|
|
|
175
188
|
console.error(`[grok] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
|
|
176
189
|
continue;
|
|
177
190
|
}
|
|
191
|
+
// The bridge itself is shared — see AGENTS_WITHOUT_HTTP_MCP for which
|
|
192
|
+
// agents need it and the evidence for each.
|
|
193
|
+
const bridge = (0, mcp_headers_1.remoteMcpBridge)(url, s.env);
|
|
194
|
+
const bridged = (0, win_1.wrapMcpCommandForPlatform)(bridge.command, bridge.args);
|
|
178
195
|
lines.push(`[mcp_servers.${tomlStr(s.slug)}]`);
|
|
179
|
-
lines.push(`
|
|
180
|
-
|
|
181
|
-
if (headers.length > 0) {
|
|
182
|
-
const kv = headers.map(([k, v]) => `${tomlStr(k)} = ${tomlStr(v)}`).join(', ');
|
|
183
|
-
lines.push(`headers = { ${kv} }`);
|
|
184
|
-
}
|
|
196
|
+
lines.push(`command = ${tomlStr(bridged.command)}`);
|
|
197
|
+
lines.push(`args = [${bridged.args.map(tomlStr).join(', ')}]`);
|
|
185
198
|
lines.push('enabled = true');
|
|
186
|
-
|
|
199
|
+
// The first run on a machine downloads mcp-remote, which 30s does not cover.
|
|
200
|
+
lines.push('startup_timeout_sec = 90');
|
|
187
201
|
lines.push('');
|
|
188
202
|
continue;
|
|
189
203
|
}
|
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/mcp-headers.d.ts
CHANGED
|
@@ -13,3 +13,44 @@ export declare function remoteMcpUrl(args?: string[] | null): string | null;
|
|
|
13
13
|
* and must never be sent as a header.
|
|
14
14
|
*/
|
|
15
15
|
export declare function remoteMcpHeaders(env?: Record<string, string> | null): Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Which agents can actually open an HTTP MCP connection, measured against the
|
|
18
|
+
* real binaries rather than their documentation.
|
|
19
|
+
*
|
|
20
|
+
* claude YES — proven in production; entities use these every day.
|
|
21
|
+
* codex YES — `codex mcp list` shows a `url` entry under Url, enabled.
|
|
22
|
+
* kimi YES — `kimi mcp list` prints "<slug> (http): <url>".
|
|
23
|
+
* gemini ? — UNVERIFIED. The CLI on hand cannot authenticate at all
|
|
24
|
+
* ("no longer supported for Gemini Code Assist for
|
|
25
|
+
* individuals"), so its httpUrl handling was never observed
|
|
26
|
+
* connecting. No entity runs gemini today. If one ever does,
|
|
27
|
+
* verify before trusting it — this exact assumption is what
|
|
28
|
+
* cost us grok.
|
|
29
|
+
* grok NO — grok 1.0.3 SILENTLY IGNORES url-based entries. In one run it
|
|
30
|
+
* loaded ten stdio servers and skipped every remote one, ours
|
|
31
|
+
* and a third party's, with no error, while its own bundled
|
|
32
|
+
* README documents `url` + `headers`. The docs describe a
|
|
33
|
+
* transport the binary does not have.
|
|
34
|
+
*
|
|
35
|
+
* An agent listed here gets its remote servers bridged instead — see
|
|
36
|
+
* remoteMcpBridge. Add to this set only on evidence from the binary; the whole
|
|
37
|
+
* point is that "the docs say it works" is not evidence.
|
|
38
|
+
*/
|
|
39
|
+
export declare const AGENTS_WITHOUT_HTTP_MCP: Set<string>;
|
|
40
|
+
export declare function agentNeedsMcpBridge(agent: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Turn a remote MCP server into a STDIO command, for agents that cannot speak
|
|
43
|
+
* HTTP MCP themselves.
|
|
44
|
+
*
|
|
45
|
+
* mcp-remote is the standard stdio<->HTTP shim, and the one grok's own README
|
|
46
|
+
* reaches for in its Linear example. Verified end to end against grok 1.0.3:
|
|
47
|
+
* the bridged server's tools appear and are callable.
|
|
48
|
+
*
|
|
49
|
+
* The cost is an npx start per remote server — exactly the cost moving to HTTP
|
|
50
|
+
* removed — so this is a fallback for the agents that need it, never the
|
|
51
|
+
* default path.
|
|
52
|
+
*/
|
|
53
|
+
export declare function remoteMcpBridge(url: string, env?: Record<string, string> | null): {
|
|
54
|
+
command: string;
|
|
55
|
+
args: string[];
|
|
56
|
+
};
|
package/dist/mcp-headers.js
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
// inline in install.ts (the claude path) and the other four adapters each got
|
|
9
9
|
// it wrong in their own way, so it lives here now and they all call it.
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.AGENTS_WITHOUT_HTTP_MCP = void 0;
|
|
11
12
|
exports.isRemoteMcp = isRemoteMcp;
|
|
12
13
|
exports.remoteMcpUrl = remoteMcpUrl;
|
|
13
14
|
exports.remoteMcpHeaders = remoteMcpHeaders;
|
|
15
|
+
exports.agentNeedsMcpBridge = agentNeedsMcpBridge;
|
|
16
|
+
exports.remoteMcpBridge = remoteMcpBridge;
|
|
14
17
|
/** True when a registry row describes a remote MCP server rather than a
|
|
15
18
|
* local process to spawn. */
|
|
16
19
|
function isRemoteMcp(command) {
|
|
@@ -47,3 +50,52 @@ function remoteMcpHeaders(env) {
|
|
|
47
50
|
}
|
|
48
51
|
return headers;
|
|
49
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Which agents can actually open an HTTP MCP connection, measured against the
|
|
55
|
+
* real binaries rather than their documentation.
|
|
56
|
+
*
|
|
57
|
+
* claude YES — proven in production; entities use these every day.
|
|
58
|
+
* codex YES — `codex mcp list` shows a `url` entry under Url, enabled.
|
|
59
|
+
* kimi YES — `kimi mcp list` prints "<slug> (http): <url>".
|
|
60
|
+
* gemini ? — UNVERIFIED. The CLI on hand cannot authenticate at all
|
|
61
|
+
* ("no longer supported for Gemini Code Assist for
|
|
62
|
+
* individuals"), so its httpUrl handling was never observed
|
|
63
|
+
* connecting. No entity runs gemini today. If one ever does,
|
|
64
|
+
* verify before trusting it — this exact assumption is what
|
|
65
|
+
* cost us grok.
|
|
66
|
+
* grok NO — grok 1.0.3 SILENTLY IGNORES url-based entries. In one run it
|
|
67
|
+
* loaded ten stdio servers and skipped every remote one, ours
|
|
68
|
+
* and a third party's, with no error, while its own bundled
|
|
69
|
+
* README documents `url` + `headers`. The docs describe a
|
|
70
|
+
* transport the binary does not have.
|
|
71
|
+
*
|
|
72
|
+
* An agent listed here gets its remote servers bridged instead — see
|
|
73
|
+
* remoteMcpBridge. Add to this set only on evidence from the binary; the whole
|
|
74
|
+
* point is that "the docs say it works" is not evidence.
|
|
75
|
+
*/
|
|
76
|
+
exports.AGENTS_WITHOUT_HTTP_MCP = new Set(['grok']);
|
|
77
|
+
function agentNeedsMcpBridge(agent) {
|
|
78
|
+
return exports.AGENTS_WITHOUT_HTTP_MCP.has(agent);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Turn a remote MCP server into a STDIO command, for agents that cannot speak
|
|
82
|
+
* HTTP MCP themselves.
|
|
83
|
+
*
|
|
84
|
+
* mcp-remote is the standard stdio<->HTTP shim, and the one grok's own README
|
|
85
|
+
* reaches for in its Linear example. Verified end to end against grok 1.0.3:
|
|
86
|
+
* the bridged server's tools appear and are callable.
|
|
87
|
+
*
|
|
88
|
+
* The cost is an npx start per remote server — exactly the cost moving to HTTP
|
|
89
|
+
* removed — so this is a fallback for the agents that need it, never the
|
|
90
|
+
* default path.
|
|
91
|
+
*/
|
|
92
|
+
function remoteMcpBridge(url, env) {
|
|
93
|
+
const args = ['-y', 'mcp-remote', url];
|
|
94
|
+
for (const [key, value] of Object.entries(remoteMcpHeaders(env))) {
|
|
95
|
+
// Spawned without a shell, so "K:V" needs no quoting of its own.
|
|
96
|
+
args.push('--header', `${key}:${value}`);
|
|
97
|
+
}
|
|
98
|
+
// Our servers are streamable HTTP; skip the SSE fallback probing.
|
|
99
|
+
args.push('--transport', 'http-only');
|
|
100
|
+
return { command: 'npx', args };
|
|
101
|
+
}
|
package/dist/request-pump.d.ts
CHANGED
|
@@ -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;
|
package/dist/request-pump.js
CHANGED
|
@@ -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
|
|
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 >=
|
|
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 <
|
|
266
|
+
if (!stopped && inflight < maxConcurrent) {
|
|
219
267
|
queueMicrotask(() => { void tick(); });
|
|
220
268
|
}
|
|
221
269
|
}
|
package/dist/tui/dashboard.d.ts
CHANGED
|
@@ -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. */
|
package/dist/tui/dashboard.js
CHANGED
|
@@ -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
|
|
39
|
-
*
|
|
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
|
|
249
|
-
*
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.24.1",
|
|
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": [
|