@addai/node 0.24.0 → 0.25.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.
- package/dist/capabilities.d.ts +23 -0
- package/dist/capabilities.js +48 -0
- package/dist/claude-print.d.ts +14 -0
- package/dist/claude-print.js +37 -1
- package/dist/codex-spawn.js +19 -31
- package/dist/command-runner.js +35 -2
- package/dist/desktop/creds.d.ts +15 -0
- package/dist/desktop/creds.js +28 -0
- package/dist/desktop/manager.d.ts +19 -0
- package/dist/desktop/manager.js +87 -0
- package/dist/desktop/relay-client.js +29 -5
- package/dist/flows/engine-loader.d.ts +8 -0
- package/dist/flows/engine-loader.js +40 -0
- package/dist/flows/host.d.ts +40 -0
- package/dist/flows/host.js +125 -0
- package/dist/flows/pump.d.ts +31 -0
- package/dist/flows/pump.js +264 -0
- package/dist/flows/run.d.ts +7 -0
- package/dist/flows/run.js +219 -0
- package/dist/gemini-spawn.js +54 -2
- package/dist/grok-spawn.js +10 -25
- package/dist/heartbeat.js +24 -0
- package/dist/index.js +22 -4
- package/dist/mcp-headers.d.ts +47 -0
- package/dist/mcp-headers.js +58 -0
- package/package.json +2 -1
package/dist/capabilities.d.ts
CHANGED
|
@@ -20,6 +20,23 @@ interface BinaryProbe extends AuthShape {
|
|
|
20
20
|
/** All remote login methods (primary strategy first, then alternates). */
|
|
21
21
|
login_methods?: string[];
|
|
22
22
|
}
|
|
23
|
+
interface FlowsCapability {
|
|
24
|
+
/** Whether this machine is claiming flow runs. Mirrors the server's switch;
|
|
25
|
+
* reported back so the Studio can show agreement rather than intent. */
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
/** Version of the shared engine this daemon bundles. */
|
|
28
|
+
engine_version: string | null;
|
|
29
|
+
/** 'isolated-vm' or the weaker 'vm' fallback, stated plainly. */
|
|
30
|
+
sandbox: {
|
|
31
|
+
kind: string;
|
|
32
|
+
isolated: boolean;
|
|
33
|
+
note: string | null;
|
|
34
|
+
};
|
|
35
|
+
/** Flow runs in flight, and the ceiling. Its own pool — separate from
|
|
36
|
+
* agent runs on purpose, so a flows burst cannot starve a chat. */
|
|
37
|
+
running: number;
|
|
38
|
+
max_concurrent: number;
|
|
39
|
+
}
|
|
23
40
|
interface CapabilitiesShape {
|
|
24
41
|
/** Version of @addai/node running. Lets the studio / CodeFlows
|
|
25
42
|
* UIs show "outdated daemon" hints and helps us debug field issues
|
|
@@ -49,6 +66,12 @@ interface CapabilitiesShape {
|
|
|
49
66
|
* installed. Studio renders an install card in place of the create
|
|
50
67
|
* button when this is null. */
|
|
51
68
|
container_engine?: EngineInfo | null;
|
|
69
|
+
/** What this machine can do about +Ai Flows, and what it is doing right now.
|
|
70
|
+
* The Studio's Flows tab is built entirely out of this — whether the pump
|
|
71
|
+
* is on, how much room is left, and crucially which sandbox the engine
|
|
72
|
+
* resolved, because a UI that implies isolated-vm when the weaker `vm`
|
|
73
|
+
* fallback is running is worse than one that says nothing. */
|
|
74
|
+
flows?: FlowsCapability;
|
|
52
75
|
/** CPU / memory / disk / runs at the moment of this probe. The AiNode page
|
|
53
76
|
* has read this key since it was written; nothing produced it until now. */
|
|
54
77
|
machine?: MachineSample;
|
package/dist/capabilities.js
CHANGED
|
@@ -56,6 +56,8 @@ const harness_registry_1 = require("./harness-registry");
|
|
|
56
56
|
const autostart_1 = require("./autostart");
|
|
57
57
|
const auto_update_1 = require("./auto-update");
|
|
58
58
|
const machine_metrics_1 = require("./machine-metrics");
|
|
59
|
+
const pump_1 = require("./flows/pump");
|
|
60
|
+
const engine_loader_1 = require("./flows/engine-loader");
|
|
59
61
|
function readDaemonVersion() {
|
|
60
62
|
try {
|
|
61
63
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -368,6 +370,51 @@ async function probeGit() {
|
|
|
368
370
|
authed: true,
|
|
369
371
|
};
|
|
370
372
|
}
|
|
373
|
+
/** Describe this machine's flows support.
|
|
374
|
+
*
|
|
375
|
+
* Deliberately does NOT force the engine to load. The engine is ESM with a
|
|
376
|
+
* top-level probe of the optional native sandbox, and loading it on every
|
|
377
|
+
* heartbeat — on machines that never run a flow — would be a cost paid by
|
|
378
|
+
* everyone for the benefit of a few. Once flows are on and the engine is
|
|
379
|
+
* loaded, the real sandbox is reported; until then it says so honestly
|
|
380
|
+
* rather than guessing.
|
|
381
|
+
*
|
|
382
|
+
* Never throws: a capabilities probe that could fail would take the whole
|
|
383
|
+
* heartbeat with it, and the machine would read offline because it could not
|
|
384
|
+
* describe its flows support.
|
|
385
|
+
*/
|
|
386
|
+
async function probeFlows() {
|
|
387
|
+
let engineVersion = null;
|
|
388
|
+
try {
|
|
389
|
+
// JSON, so plain require is safe here — this is not the ESM graph.
|
|
390
|
+
engineVersion = require('@addai/node-flows/package.json').version ?? null;
|
|
391
|
+
}
|
|
392
|
+
catch { /* not installed; reported as null below */ }
|
|
393
|
+
let sandbox = {
|
|
394
|
+
kind: 'not-loaded',
|
|
395
|
+
isolated: false,
|
|
396
|
+
note: 'The engine loads when flows are switched on for this machine.',
|
|
397
|
+
};
|
|
398
|
+
if ((0, engine_loader_1.engineLoaded)()) {
|
|
399
|
+
try {
|
|
400
|
+
const { sandboxAvailability } = await (0, engine_loader_1.loadEngine)();
|
|
401
|
+
sandbox = sandboxAvailability();
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
sandbox = {
|
|
405
|
+
kind: 'unavailable', isolated: false,
|
|
406
|
+
note: `The flows engine failed to load: ${err.message}`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
enabled: (0, pump_1.flowsEnabled)(),
|
|
412
|
+
engine_version: engineVersion,
|
|
413
|
+
sandbox,
|
|
414
|
+
running: (0, pump_1.inflightFlowCount)(),
|
|
415
|
+
max_concurrent: (0, pump_1.maxConcurrentFlows)(),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
371
418
|
async function probeCapabilities() {
|
|
372
419
|
// Binary lookups cache misses; a CLI installed mid-daemon-life (by the
|
|
373
420
|
// command runner or by hand) must show up on the next probe, not after
|
|
@@ -403,6 +450,7 @@ async function probeCapabilities() {
|
|
|
403
450
|
daemon_version: readDaemonVersion(),
|
|
404
451
|
autostart: (0, autostart_1.status)(),
|
|
405
452
|
container_engine: containerEngine,
|
|
453
|
+
flows: await probeFlows(),
|
|
406
454
|
auto_update: (0, auto_update_1.autoUpdateState)(),
|
|
407
455
|
machine: (0, machine_metrics_1.sampleMachine)(),
|
|
408
456
|
claude: deco('claude', claude),
|
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/codex-spawn.js
CHANGED
|
@@ -52,7 +52,6 @@ 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");
|
|
56
55
|
/** Quote a string as a TOML basic string (double-quoted, escape backslash + quote). */
|
|
57
56
|
function tomlString(s) {
|
|
58
57
|
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
|
|
@@ -61,11 +60,6 @@ function tomlString(s) {
|
|
|
61
60
|
* Only the NAME goes in config.toml — the value rides on the child env, so
|
|
62
61
|
* the token never lands on disk. */
|
|
63
62
|
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()}`;
|
|
69
63
|
/** Build a config.toml that declares only the supplied MCP servers and
|
|
70
64
|
* return the CODEX_HOME directory plus any env vars the child needs
|
|
71
65
|
* (bearer tokens for remote servers). Caller is responsible for cleanup
|
|
@@ -80,16 +74,12 @@ function writeCodexHome(servers, workingDirectory) {
|
|
|
80
74
|
// Remote MCP servers. Registry rows store command='http'|'sse' with
|
|
81
75
|
// args=[url]; codex spells that `url = "..."`, NOT command/args. Writing
|
|
82
76
|
// the row verbatim made codex try to exec a binary called `http`, so the
|
|
83
|
-
// server never started and its tools silently vanished
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
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);
|
|
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;
|
|
93
83
|
if (!url) {
|
|
94
84
|
console.error(`[codex] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
|
|
95
85
|
continue;
|
|
@@ -97,24 +87,22 @@ function writeCodexHome(servers, workingDirectory) {
|
|
|
97
87
|
const name = tomlString(s.slug).slice(1, -1);
|
|
98
88
|
toml += `[mcp_servers.${name}]\n`;
|
|
99
89
|
toml += `url = ${tomlString(url)}\n`;
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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];
|
|
104
95
|
if (bearer) {
|
|
105
|
-
delete headers.Authorization;
|
|
106
96
|
const varName = bearerEnvVar(s.slug);
|
|
107
|
-
extraEnv[varName] = bearer
|
|
97
|
+
extraEnv[varName] = String(bearer[1]);
|
|
108
98
|
toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
extraEnv[varName] = value;
|
|
117
|
-
toml += `${tomlString(header)} = ${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`);
|
|
118
106
|
}
|
|
119
107
|
}
|
|
120
108
|
toml += '\n';
|
package/dist/command-runner.js
CHANGED
|
@@ -577,6 +577,7 @@ async function runSetAutostart(cmd) {
|
|
|
577
577
|
const DESKTOP_KINDS = [
|
|
578
578
|
'desktop_create', 'desktop_start', 'desktop_stop',
|
|
579
579
|
'desktop_delete', 'desktop_rebuild', 'desktop_sync_logins',
|
|
580
|
+
'desktop_exec',
|
|
580
581
|
];
|
|
581
582
|
/** Setup scripts run as a FILE, never as a `sh -c` string: a script with
|
|
582
583
|
* quotes or newlines must not need escaping to survive. */
|
|
@@ -592,7 +593,12 @@ async function runDesktopCommand(cmd) {
|
|
|
592
593
|
}
|
|
593
594
|
// force: a user who just installed Docker should not have to restart the
|
|
594
595
|
// daemon for their first desktop to build.
|
|
595
|
-
|
|
596
|
+
// Re-detect the engine for the RARE lifecycle commands: someone who has
|
|
597
|
+
// just installed Docker should not have to restart the daemon for a create
|
|
598
|
+
// or a start to find it. desktop_exec is the opposite — an entity driving a
|
|
599
|
+
// remote desktop makes dozens of these, and `docker info` costs seconds on
|
|
600
|
+
// Docker Desktop (measured: 4.2s on a Mac), so it uses the cached engine.
|
|
601
|
+
const provider = await (0, docker_1.getProvider)(cmd.kind !== 'desktop_exec');
|
|
596
602
|
if (!provider) {
|
|
597
603
|
const why = 'No container engine on this machine. Install Docker Desktop or Podman, then try again.';
|
|
598
604
|
await update(cmd.id, 'failed', undefined, why);
|
|
@@ -650,11 +656,18 @@ async function runDesktopCommand(cmd) {
|
|
|
650
656
|
}
|
|
651
657
|
break;
|
|
652
658
|
}
|
|
653
|
-
case 'desktop_start':
|
|
659
|
+
case 'desktop_start': {
|
|
660
|
+
// Someone pressed start, so this one fails loudly rather than
|
|
661
|
+
// deferring quietly the way autostart does. They are owed an answer
|
|
662
|
+
// they can act on: which limit, and where to change it.
|
|
663
|
+
const room = await (0, manager_1.desktopStartAllowed)(await (0, manager_1.listDesktops)(), row.id);
|
|
664
|
+
if (!room.allowed)
|
|
665
|
+
throw new Error(room.reason);
|
|
654
666
|
await (0, manager_1.setStatus)(row.id, { status: 'starting' });
|
|
655
667
|
await provider.start(row);
|
|
656
668
|
await (0, manager_1.setStatus)(row.id, { status: 'running', status_message: null });
|
|
657
669
|
break;
|
|
670
|
+
}
|
|
658
671
|
case 'desktop_stop':
|
|
659
672
|
await provider.stop(row);
|
|
660
673
|
await (0, manager_1.setStatus)(row.id, { status: 'stopped', status_message: null });
|
|
@@ -671,6 +684,26 @@ async function runDesktopCommand(cmd) {
|
|
|
671
684
|
case 'desktop_sync_logins':
|
|
672
685
|
await (0, creds_1.syncCredentials)(provider, row, onLog);
|
|
673
686
|
break;
|
|
687
|
+
case 'desktop_exec': {
|
|
688
|
+
// An entity on ANOTHER machine driving this desktop. The MCP used to
|
|
689
|
+
// run `docker exec` locally, so an entity could only ever touch a
|
|
690
|
+
// desktop that happened to live on the node it was dispatched to.
|
|
691
|
+
// This is that same exec, arriving over the wire.
|
|
692
|
+
const argv = Array.isArray(cmd.input?.argv) ? cmd.input.argv : null;
|
|
693
|
+
if (!argv || argv.length === 0)
|
|
694
|
+
throw new Error('desktop_exec needs an argv array');
|
|
695
|
+
const timeoutMs = Math.min(Math.max(Number(cmd.input?.timeout_ms) || 60_000, 1_000), 10 * 60_000);
|
|
696
|
+
const res = await (0, creds_1.execInDesktopCapture)(provider, row, argv, timeoutMs);
|
|
697
|
+
// stdout is base64 because it is frequently a PNG. A non-zero exit is
|
|
698
|
+
// reported as a completed command carrying that code — the command
|
|
699
|
+
// ran; what it returned is the entity's problem to read.
|
|
700
|
+
await update(cmd.id, 'completed', {
|
|
701
|
+
stdout_b64: res.stdout.toString('base64'),
|
|
702
|
+
stderr: res.stderr.slice(0, 8000),
|
|
703
|
+
exit_code: res.exitCode,
|
|
704
|
+
});
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
674
707
|
}
|
|
675
708
|
await update(cmd.id, 'completed', { log });
|
|
676
709
|
}
|
package/dist/desktop/creds.d.ts
CHANGED
|
@@ -5,3 +5,18 @@ export declare function stripMcpServers(raw: string): string;
|
|
|
5
5
|
/** Run a command inside a desktop, streaming output to onLog. */
|
|
6
6
|
export declare function execInDesktop(provider: DesktopProvider, row: DesktopRow, cmd: string[], onLog: (c: string) => void): Promise<void>;
|
|
7
7
|
export declare function syncCredentials(provider: DesktopProvider, row: DesktopRow, onLog: (c: string) => void): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Run a command inside a desktop and CAPTURE its output.
|
|
10
|
+
*
|
|
11
|
+
* execInDesktop streams to a log, which is right for an install and useless
|
|
12
|
+
* for a screenshot: the caller needs the bytes back, exactly, and a PNG does
|
|
13
|
+
* not survive being treated as UTF-8. This is the remote arm of the desktop
|
|
14
|
+
* MCP — an entity running on one machine driving a desktop on another — so
|
|
15
|
+
* the result has to cross the wire intact and a non-zero exit is a RESULT,
|
|
16
|
+
* not a transport failure.
|
|
17
|
+
*/
|
|
18
|
+
export declare function execInDesktopCapture(provider: DesktopProvider, row: DesktopRow, cmd: string[], timeoutMs?: number): Promise<{
|
|
19
|
+
stdout: Buffer;
|
|
20
|
+
stderr: string;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}>;
|
package/dist/desktop/creds.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.stripMcpServers = stripMcpServers;
|
|
37
37
|
exports.execInDesktop = execInDesktop;
|
|
38
38
|
exports.syncCredentials = syncCredentials;
|
|
39
|
+
exports.execInDesktopCapture = execInDesktopCapture;
|
|
39
40
|
// Harness logins are COPIED into a desktop, never bind-mounted.
|
|
40
41
|
//
|
|
41
42
|
// Bind-mounting ~/.claude would hand the box write access to every transcript
|
|
@@ -132,3 +133,30 @@ async function syncCredentials(provider, row, onLog) {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Run a command inside a desktop and CAPTURE its output.
|
|
138
|
+
*
|
|
139
|
+
* execInDesktop streams to a log, which is right for an install and useless
|
|
140
|
+
* for a screenshot: the caller needs the bytes back, exactly, and a PNG does
|
|
141
|
+
* not survive being treated as UTF-8. This is the remote arm of the desktop
|
|
142
|
+
* MCP — an entity running on one machine driving a desktop on another — so
|
|
143
|
+
* the result has to cross the wire intact and a non-zero exit is a RESULT,
|
|
144
|
+
* not a transport failure.
|
|
145
|
+
*/
|
|
146
|
+
function execInDesktopCapture(provider, row, cmd, timeoutMs = 60_000) {
|
|
147
|
+
const args = (0, provider_1.buildExecArgs)(row, cmd, { cwd: '/work', env: {}, tty: false });
|
|
148
|
+
const inv = (0, win_1.resolveCliInvocation)(provider.id, args);
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const child = (0, child_process_1.spawn)(inv.file, inv.args, {
|
|
151
|
+
env: process.env, windowsHide: true, timeout: timeoutMs,
|
|
152
|
+
});
|
|
153
|
+
const out = [];
|
|
154
|
+
let err = '';
|
|
155
|
+
child.stdout?.on('data', (b) => out.push(b));
|
|
156
|
+
child.stderr?.on('data', (b) => { err += b.toString('utf8'); });
|
|
157
|
+
child.on('error', reject);
|
|
158
|
+
child.on('close', code => resolve({
|
|
159
|
+
stdout: Buffer.concat(out), stderr: err, exitCode: code ?? -1,
|
|
160
|
+
}));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -24,6 +24,25 @@ export declare function ensureDirs(desktopId: string): {
|
|
|
24
24
|
confDir: string;
|
|
25
25
|
workDir: string;
|
|
26
26
|
};
|
|
27
|
+
export declare const DEFAULT_MAX_CONCURRENT_DESKTOPS = 2;
|
|
28
|
+
export declare function clampMaxDesktops(value: unknown): number;
|
|
29
|
+
/** Pushed in by the heartbeat. Lowering it never stops a running desktop —
|
|
30
|
+
* killing someone's live session to satisfy a number they just typed would
|
|
31
|
+
* be a worse surprise than being one over the cap for a while. */
|
|
32
|
+
export declare function setMaxConcurrentDesktops(value: unknown): void;
|
|
33
|
+
export declare function maxConcurrentDesktops(): number;
|
|
34
|
+
/** How many desktops are running right now, asked of the engine rather than
|
|
35
|
+
* tracked in a counter. A counter would drift the moment somebody stopped a
|
|
36
|
+
* container by hand, and drift upward means refusing to start anything. */
|
|
37
|
+
export declare function runningDesktopCount(rows: DesktopRow[]): Promise<number>;
|
|
38
|
+
/** Whether one more may start. Returns the reason when it may not, so the
|
|
39
|
+
* caller can say something better than "failed". */
|
|
40
|
+
export declare function desktopStartAllowed(rows: DesktopRow[], excludeId?: string): Promise<{
|
|
41
|
+
allowed: true;
|
|
42
|
+
} | {
|
|
43
|
+
allowed: false;
|
|
44
|
+
reason: string;
|
|
45
|
+
}>;
|
|
27
46
|
export declare function startDesktopManager(): void;
|
|
28
47
|
export declare function stopDesktopManager(): void;
|
|
29
48
|
/** Bring a desktop up and wait for it, for the run path. Returns the row when
|
package/dist/desktop/manager.js
CHANGED
|
@@ -33,11 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_MAX_CONCURRENT_DESKTOPS = void 0;
|
|
36
37
|
exports.reconcileAction = reconcileAction;
|
|
37
38
|
exports.listDesktops = listDesktops;
|
|
38
39
|
exports.setStatus = setStatus;
|
|
39
40
|
exports.allocateVncPort = allocateVncPort;
|
|
40
41
|
exports.ensureDirs = ensureDirs;
|
|
42
|
+
exports.clampMaxDesktops = clampMaxDesktops;
|
|
43
|
+
exports.setMaxConcurrentDesktops = setMaxConcurrentDesktops;
|
|
44
|
+
exports.maxConcurrentDesktops = maxConcurrentDesktops;
|
|
45
|
+
exports.runningDesktopCount = runningDesktopCount;
|
|
46
|
+
exports.desktopStartAllowed = desktopStartAllowed;
|
|
41
47
|
exports.startDesktopManager = startDesktopManager;
|
|
42
48
|
exports.stopDesktopManager = stopDesktopManager;
|
|
43
49
|
exports.ensureRunning = ensureRunning;
|
|
@@ -118,6 +124,70 @@ function ensureDirs(desktopId) {
|
|
|
118
124
|
fs.mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
119
125
|
return { confDir, workDir };
|
|
120
126
|
}
|
|
127
|
+
// How many desktops may be RUNNING on this machine at once.
|
|
128
|
+
//
|
|
129
|
+
// There was never a ceiling here, and the failure it allows is not subtle:
|
|
130
|
+
// four containers each sized for a browser will take a Mac mini down, and
|
|
131
|
+
// when they do they take the agent runs and the flow runs with them. Its own
|
|
132
|
+
// pool, like flows and agent runs, because the three compete for the same box
|
|
133
|
+
// but answer to different people.
|
|
134
|
+
//
|
|
135
|
+
// Absent means "keep what you had", never 0 — a server that has not had the
|
|
136
|
+
// migration must not be able to stop every desktop on every machine.
|
|
137
|
+
exports.DEFAULT_MAX_CONCURRENT_DESKTOPS = 2;
|
|
138
|
+
const MAX_MAX_CONCURRENT_DESKTOPS = 16;
|
|
139
|
+
let maxDesktops = exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
|
|
140
|
+
function clampMaxDesktops(value) {
|
|
141
|
+
if (typeof value !== 'number' && typeof value !== 'string')
|
|
142
|
+
return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
|
|
143
|
+
if (typeof value === 'string' && value.trim() === '')
|
|
144
|
+
return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
|
|
145
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
146
|
+
if (!Number.isFinite(n))
|
|
147
|
+
return exports.DEFAULT_MAX_CONCURRENT_DESKTOPS;
|
|
148
|
+
return Math.min(MAX_MAX_CONCURRENT_DESKTOPS, Math.max(1, Math.floor(n)));
|
|
149
|
+
}
|
|
150
|
+
/** Pushed in by the heartbeat. Lowering it never stops a running desktop —
|
|
151
|
+
* killing someone's live session to satisfy a number they just typed would
|
|
152
|
+
* be a worse surprise than being one over the cap for a while. */
|
|
153
|
+
function setMaxConcurrentDesktops(value) {
|
|
154
|
+
const next = clampMaxDesktops(value);
|
|
155
|
+
if (next === maxDesktops)
|
|
156
|
+
return;
|
|
157
|
+
console.log(`[desktops] desktops at once: ${maxDesktops} -> ${next}`);
|
|
158
|
+
maxDesktops = next;
|
|
159
|
+
}
|
|
160
|
+
function maxConcurrentDesktops() { return maxDesktops; }
|
|
161
|
+
/** How many desktops are running right now, asked of the engine rather than
|
|
162
|
+
* tracked in a counter. A counter would drift the moment somebody stopped a
|
|
163
|
+
* container by hand, and drift upward means refusing to start anything. */
|
|
164
|
+
async function runningDesktopCount(rows) {
|
|
165
|
+
const provider = await (0, docker_1.getProvider)();
|
|
166
|
+
if (!provider)
|
|
167
|
+
return 0;
|
|
168
|
+
let count = 0;
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
try {
|
|
171
|
+
const actual = await provider.inspect(row);
|
|
172
|
+
if (actual?.running)
|
|
173
|
+
count++;
|
|
174
|
+
}
|
|
175
|
+
catch { /* unknown state counts as not running; the reconcile will fix it */ }
|
|
176
|
+
}
|
|
177
|
+
return count;
|
|
178
|
+
}
|
|
179
|
+
/** Whether one more may start. Returns the reason when it may not, so the
|
|
180
|
+
* caller can say something better than "failed". */
|
|
181
|
+
async function desktopStartAllowed(rows, excludeId) {
|
|
182
|
+
const running = await runningDesktopCount(rows.filter(r => r.id !== excludeId));
|
|
183
|
+
if (running < maxDesktops)
|
|
184
|
+
return { allowed: true };
|
|
185
|
+
return {
|
|
186
|
+
allowed: false,
|
|
187
|
+
reason: `This machine is already running ${running} desktop${running === 1 ? '' : 's'}, `
|
|
188
|
+
+ `its limit. Stop one, or raise the limit on the machine's page.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
121
191
|
let timer = null;
|
|
122
192
|
let inflight = false;
|
|
123
193
|
async function tick() {
|
|
@@ -136,6 +206,15 @@ async function tick() {
|
|
|
136
206
|
continue;
|
|
137
207
|
try {
|
|
138
208
|
if (action === 'start') {
|
|
209
|
+
// Autostart is the quiet path to blowing the cap: a machine that
|
|
210
|
+
// reboots would bring every desktop back at once regardless of what
|
|
211
|
+
// the box can take. Over the line, it defers and says so rather than
|
|
212
|
+
// failing — nothing is wrong with the desktop, there is just no room.
|
|
213
|
+
const room = await desktopStartAllowed(rows, row.id);
|
|
214
|
+
if (!room.allowed) {
|
|
215
|
+
await setStatus(row.id, { status: 'stopped', status_message: room.reason });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
139
218
|
await provider.start(row);
|
|
140
219
|
await setStatus(row.id, { status: 'running', status_message: null });
|
|
141
220
|
}
|
|
@@ -196,6 +275,14 @@ async function ensureRunning(desktopId, timeoutMs = 60_000) {
|
|
|
196
275
|
return row;
|
|
197
276
|
if (!actual)
|
|
198
277
|
return null; // never created / pruned: cannot start
|
|
278
|
+
// At the cap, the run falls back to the host rather than starting an
|
|
279
|
+
// eleventh container. Returning null is how this function already says
|
|
280
|
+
// "could not start it" and the caller already handles it.
|
|
281
|
+
const room = await desktopStartAllowed(rows, row.id);
|
|
282
|
+
if (!room.allowed) {
|
|
283
|
+
console.warn(`[desktops] not starting ${row.id} for a run — ${room.reason}`);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
199
286
|
const deadline = Date.now() + timeoutMs;
|
|
200
287
|
try {
|
|
201
288
|
await provider.start(row);
|
|
@@ -164,10 +164,24 @@ async function connect() {
|
|
|
164
164
|
catch {
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
167
|
+
// Every way a bridge can fail used to fail SILENTLY: the viewer's socket
|
|
168
|
+
// stayed open, noVNC went on reporting 'connected', and the last frame sat
|
|
169
|
+
// there looking live while every keystroke was dropped on the floor. A
|
|
170
|
+
// frozen picture you cannot tell from a working one is the worst possible
|
|
171
|
+
// failure, so each of these now says so and the viewer is disconnected.
|
|
172
|
+
const gone = (viewerId, reason) => {
|
|
173
|
+
bridges.get(viewerId)?.destroy();
|
|
174
|
+
bridges.delete(viewerId);
|
|
175
|
+
if (sock.readyState === ws_1.default.OPEN) {
|
|
176
|
+
sock.send(JSON.stringify({ type: 'gone', viewerId, reason }));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
167
179
|
if (msg.type === 'attach') {
|
|
168
180
|
const row = (await (0, manager_1.listDesktops)()).find(d => d.id === msg.desktopId);
|
|
169
|
-
if (!row?.vnc_port)
|
|
181
|
+
if (!row?.vnc_port) {
|
|
182
|
+
gone(msg.viewerId, 'desktop is not running');
|
|
170
183
|
return;
|
|
184
|
+
}
|
|
171
185
|
// Loopback only - the container published its RFB on 127.0.0.1 and this
|
|
172
186
|
// process is the only thing on the machine that reaches it.
|
|
173
187
|
const tcp = net.connect(row.vnc_port, '127.0.0.1');
|
|
@@ -178,15 +192,25 @@ async function connect() {
|
|
|
178
192
|
}));
|
|
179
193
|
}
|
|
180
194
|
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
// Refused connection (container up, x11vnc not listening) and a bridge
|
|
196
|
+
// that dies mid-stream are different words but the same outcome for the
|
|
197
|
+
// person watching: there is no screen any more, and they must be told.
|
|
198
|
+
tcp.on('error', () => gone(msg.viewerId, 'lost the desktop connection'));
|
|
199
|
+
tcp.on('close', () => gone(msg.viewerId, 'the desktop closed the connection'));
|
|
184
200
|
bridges.get(msg.viewerId)?.destroy();
|
|
185
201
|
bridges.set(msg.viewerId, tcp);
|
|
186
202
|
return;
|
|
187
203
|
}
|
|
188
204
|
if (msg.type === 'data' && msg.b64) {
|
|
189
|
-
bridges.get(msg.viewerId)
|
|
205
|
+
const bridge = bridges.get(msg.viewerId);
|
|
206
|
+
// Input for a bridge that no longer exists. Dropping it quietly is how
|
|
207
|
+
// "take control does nothing" happens: the pointer never moves and the
|
|
208
|
+
// viewer has no idea why.
|
|
209
|
+
if (!bridge) {
|
|
210
|
+
gone(msg.viewerId, 'no longer connected to the desktop');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
bridge.write(Buffer.from(msg.b64, 'base64'));
|
|
190
214
|
return;
|
|
191
215
|
}
|
|
192
216
|
if (msg.type === 'wake') {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type EngineModule = typeof import('@addai/node-flows');
|
|
2
|
+
/** Load the engine once and reuse it. The promise is cached rather than the
|
|
3
|
+
* module, so concurrent first calls share one load instead of racing. */
|
|
4
|
+
export declare function loadEngine(): Promise<EngineModule>;
|
|
5
|
+
/** Has the engine been loaded already? Lets the capabilities probe report the
|
|
6
|
+
* sandbox without forcing a load on a machine that does not run flows. */
|
|
7
|
+
export declare function engineLoaded(): boolean;
|
|
8
|
+
export {};
|