@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.
@@ -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;
@@ -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),
@@ -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;
@@ -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
- const t = textBuf;
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);
@@ -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
- // 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);
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
- 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;
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.replace(/^Bearer\s+/i, '');
97
+ extraEnv[varName] = String(bearer[1]);
108
98
  toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
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`;
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';
@@ -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
- const provider = await (0, docker_1.getProvider)(true);
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
  }
@@ -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
+ }>;
@@ -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
@@ -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
- const forget = () => { bridges.delete(msg.viewerId); };
182
- tcp.on('close', forget);
183
- tcp.on('error', forget);
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)?.write(Buffer.from(msg.b64, 'base64'));
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 {};