@addai/node 0.15.0 → 0.18.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.
@@ -63,6 +63,7 @@ const pty_helper_1 = require("./pty-helper");
63
63
  const docker_1 = require("./desktop/docker");
64
64
  const manager_1 = require("./desktop/manager");
65
65
  const creds_1 = require("./desktop/creds");
66
+ const install_engine_1 = require("./desktop/install-engine");
66
67
  const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
67
68
  /** Own version, read the same way index.ts does. Resolved here rather than
68
69
  * imported from index.ts, which already imports this module. */
@@ -107,6 +108,12 @@ async function npmGlobalBinFor(name) {
107
108
  return null;
108
109
  }
109
110
  const INPUT_POLL_MS = 2_000;
111
+ /** How long to wait on a HUMAN before giving up. Deliberately shorter than
112
+ * COMMAND_TIMEOUT_MS: one command runs at a time, so a login nobody ever
113
+ * pastes a code into blocks every other command on this node for as long as
114
+ * it waits. Three minutes is long enough to fetch a code from a browser and
115
+ * short enough that a forgotten login is not a wedged machine. */
116
+ const INPUT_WAIT_MS = 3 * 60_000;
110
117
  const LOG_TAIL_CHARS = 4_000;
111
118
  const LOG_PUSH_MS = 1_000;
112
119
  let running = false;
@@ -254,7 +261,7 @@ async function runLoginPty(cmd, spec, login = spec.login) {
254
261
  return;
255
262
  }
256
263
  await update(cmd.id, 'running', { step: 'starting-login', instructions: login.instructions });
257
- const deadline = Date.now() + COMMAND_TIMEOUT_MS;
264
+ const deadline = Date.now() + INPUT_WAIT_MS;
258
265
  const ptyOpts = {
259
266
  name: 'xterm-256color', cols: 120, rows: 30,
260
267
  cwd: os.homedir(),
@@ -408,7 +415,7 @@ async function applyApiKey(spec, key, method) {
408
415
  }
409
416
  async function runLoginApiKey(cmd, spec, method, instructions) {
410
417
  await update(cmd.id, 'awaiting_input', { needs: 'api_key', instructions: instructions ?? spec.login.instructions });
411
- const deadline = Date.now() + COMMAND_TIMEOUT_MS;
418
+ const deadline = Date.now() + INPUT_WAIT_MS;
412
419
  const key = cmd.input?.api_key?.trim() || await awaitInput(cmd.id, deadline, i => i.api_key);
413
420
  if (key === null) {
414
421
  await update(cmd.id, 'canceled', {}, 'canceled or timed out waiting for the key');
@@ -673,6 +680,63 @@ async function runDesktopCommand(cmd) {
673
680
  }
674
681
  }
675
682
  }
683
+ /** Install a container engine, then prove it took. The capability probe is
684
+ * the verdict, not the installer's exit code - Docker Desktop on macOS
685
+ * installs the app but the daemon is not up until someone launches it, so a
686
+ * clean exit does not mean a usable engine. */
687
+ async function runInstallEngine(cmd) {
688
+ let log = '';
689
+ const onLog = (chunk) => {
690
+ log = (log + chunk).slice(-LOG_TAIL_CHARS);
691
+ void update(cmd.id, null, { log });
692
+ };
693
+ await update(cmd.id, 'running', { log });
694
+ try {
695
+ await (0, install_engine_1.installEngine)(onLog, async () => {
696
+ // Park the command and let Studio collect it, exactly as a harness
697
+ // login collects a paste-back code.
698
+ await update(cmd.id, 'awaiting_input', { needs: 'sudo_password', log });
699
+ const deadline = Date.now() + INPUT_WAIT_MS;
700
+ while (Date.now() < deadline) {
701
+ const row = await pollRow(cmd.id);
702
+ if (!row)
703
+ break;
704
+ if (row.status === 'canceled')
705
+ return null;
706
+ const pw = row.input?.sudo_password;
707
+ if (pw) {
708
+ // Wipe it from the row the moment we hold it. It exists in one
709
+ // process's memory from here, and nowhere else.
710
+ const t = token();
711
+ if (t) {
712
+ try {
713
+ await (0, supabase_client_1.rpc)('runtime_command_clear_input', { p_token: t, p_id: cmd.id });
714
+ }
715
+ catch { /* best effort — the install still must not print it */ }
716
+ }
717
+ await update(cmd.id, 'running', { needs: null, log });
718
+ return pw;
719
+ }
720
+ await new Promise(r => setTimeout(r, INPUT_POLL_MS));
721
+ }
722
+ return null;
723
+ });
724
+ (0, docker_1.resetProviderCache)();
725
+ const engine = await (0, docker_1.getProvider)(true);
726
+ if (!engine) {
727
+ await update(cmd.id, 'failed', { log }, 'Installed, but no engine is answering yet. On macOS, Docker Desktop has to be opened once before its daemon runs.');
728
+ return;
729
+ }
730
+ onLog(`\n${engine.id} is ready.\n`);
731
+ await update(cmd.id, 'completed', { log, engine: engine.id });
732
+ // Push the new capability immediately so the tab flips without waiting
733
+ // for the next 30s heartbeat.
734
+ await (0, heartbeat_1.beat)();
735
+ }
736
+ catch (err) {
737
+ await update(cmd.id, 'failed', { log }, err.message.slice(0, 600));
738
+ }
739
+ }
676
740
  /* ── dispatcher ──────────────────────────────────────────────────────── */
677
741
  async function execute(cmd) {
678
742
  // Not a harness command — dispatch before the harness lookup, which would
@@ -685,6 +749,10 @@ async function execute(cmd) {
685
749
  await runSetAutostart(cmd);
686
750
  return;
687
751
  }
752
+ if (cmd.kind === 'install_engine') {
753
+ await runInstallEngine(cmd);
754
+ return;
755
+ }
688
756
  if (DESKTOP_KINDS.includes(cmd.kind)) {
689
757
  await runDesktopCommand(cmd);
690
758
  return;
@@ -35,7 +35,7 @@ class CliProvider {
35
35
  });
36
36
  }
37
37
  async probe() {
38
- return { id: this.id, version: this.version };
38
+ return { id: this.id, version: this.version, running: true };
39
39
  }
40
40
  async create(row, opts, onLog) {
41
41
  // Pull first so the slow, chatty image download streams into the command
@@ -93,7 +93,10 @@ async function getProvider(force = false) {
93
93
  if (!force && cached !== undefined)
94
94
  return cached;
95
95
  const info = await (0, engine_1.detectEngine)();
96
- cached = info ? new CliProvider(info.id, info.version) : null;
96
+ // An engine whose daemon is not answering is not a provider: every call
97
+ // through it would fail at the socket. Studio reports the reason from the
98
+ // capability probe instead.
99
+ cached = info?.running ? new CliProvider(info.id, info.version) : null;
97
100
  return cached;
98
101
  }
99
102
  function resetProviderCache() { cached = undefined; }
@@ -1,3 +1,15 @@
1
1
  import { EngineInfo } from './provider';
2
2
  export declare function parseEngineVersion(stdout: string): string | null;
3
+ /** Why an installed engine still cannot run anything. The three causes are
4
+ * worth telling apart, because the fix for each is different and none of
5
+ * them is "install Docker". */
6
+ export declare function explainUnreachable(output: string, platform: NodeJS.Platform): string;
7
+ /**
8
+ * Is there an engine here that can actually RUN something?
9
+ *
10
+ * `--version` is not enough: the CLI answers it with no daemon at all, so a
11
+ * machine where Docker Desktop is installed-but-closed, or where the user is
12
+ * not in the docker group, would report itself ready and then fail on every
13
+ * create. `info` is the question that needs the daemon, so it is the one asked.
14
+ */
3
15
  export declare function detectEngine(): Promise<EngineInfo | null>;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseEngineVersion = parseEngineVersion;
4
+ exports.explainUnreachable = explainUnreachable;
4
5
  exports.detectEngine = detectEngine;
5
6
  // Which container engine is on this machine? Docker first (the verified
6
7
  // path), Podman second. Never installs anything: installing Docker Desktop
@@ -12,33 +13,61 @@ function parseEngineVersion(stdout) {
12
13
  const m = /version\s+v?(\d+\.\d+\.\d+)/i.exec(stdout);
13
14
  return m ? m[1] : null;
14
15
  }
15
- function runVersion(binary) {
16
+ /** Run a command and hand back exit code + output. */
17
+ function run(binary, args) {
16
18
  return new Promise(resolve => {
17
19
  try {
18
- const inv = (0, win_1.resolveCliInvocation)(binary, ['--version']);
20
+ const inv = (0, win_1.resolveCliInvocation)(binary, args);
19
21
  const child = (0, child_process_1.spawn)(inv.file, inv.args, {
20
22
  env: process.env, windowsHide: true, timeout: 8_000,
21
23
  });
22
24
  let out = '';
23
25
  child.stdout?.on('data', b => { out += b.toString('utf8'); });
24
- child.on('error', () => resolve(null));
25
- child.on('exit', code => resolve(code === 0 ? out : null));
26
+ child.stderr?.on('data', b => { out += b.toString('utf8'); });
27
+ child.on('error', () => resolve({ ok: false, out: '' }));
28
+ child.on('exit', code => resolve({ ok: code === 0, out }));
26
29
  }
27
30
  catch {
28
31
  // resolveCliInvocation throws when a Windows shim can't be resolved -
29
32
  // that is "not installed" as far as this probe is concerned.
30
- resolve(null);
33
+ resolve({ ok: false, out: '' });
31
34
  }
32
35
  });
33
36
  }
37
+ /** Why an installed engine still cannot run anything. The three causes are
38
+ * worth telling apart, because the fix for each is different and none of
39
+ * them is "install Docker". */
40
+ function explainUnreachable(output, platform) {
41
+ const t = output.toLowerCase();
42
+ if (t.includes('permission denied') || t.includes('dial unix')) {
43
+ return 'Docker is installed but this user cannot reach it. Add the user to the docker group:\n sudo usermod -aG docker $USER (then log out and back in)';
44
+ }
45
+ if (platform === 'darwin')
46
+ return 'Docker is installed but not running. Open Docker Desktop once and leave it running.';
47
+ if (platform === 'win32')
48
+ return 'Docker is installed but not running. Start Docker Desktop and let it finish starting.';
49
+ return 'Docker is installed but its daemon is not answering. Start it:\n sudo systemctl start docker';
50
+ }
51
+ /**
52
+ * Is there an engine here that can actually RUN something?
53
+ *
54
+ * `--version` is not enough: the CLI answers it with no daemon at all, so a
55
+ * machine where Docker Desktop is installed-but-closed, or where the user is
56
+ * not in the docker group, would report itself ready and then fail on every
57
+ * create. `info` is the question that needs the daemon, so it is the one asked.
58
+ */
34
59
  async function detectEngine() {
35
60
  for (const id of ['docker', 'podman']) {
36
- const out = await runVersion(id);
37
- if (!out)
61
+ const v = await run(id, ['--version']);
62
+ if (!v.ok)
63
+ continue;
64
+ const version = parseEngineVersion(v.out);
65
+ if (!version)
38
66
  continue;
39
- const version = parseEngineVersion(out);
40
- if (version)
41
- return { id, version };
67
+ const info = await run(id, ['info', '--format', '{{.ServerVersion}}']);
68
+ if (info.ok)
69
+ return { id, version, running: true };
70
+ return { id, version, running: false, reason: explainUnreachable(info.out, process.platform) };
42
71
  }
43
72
  return null;
44
73
  }
@@ -0,0 +1,20 @@
1
+ export interface InstallPlan {
2
+ /** Null when this platform has no unattended path we trust. */
3
+ file: string | null;
4
+ args: string[];
5
+ /** What a human would have to run, shown when we cannot do it ourselves. */
6
+ manual: string;
7
+ needsRoot: boolean;
8
+ }
9
+ /** Pure, so the choice is testable on any machine. */
10
+ export declare function installPlan(platform: NodeJS.Platform, isRoot: boolean): InstallPlan;
11
+ export declare function runningAsRoot(): boolean;
12
+ /** Build the argv for a privileged install. The password NEVER goes in argv —
13
+ * argv is world-readable through /proc on Linux, so anyone with a shell on
14
+ * the box could read it. `-S` makes sudo take it on stdin instead, and
15
+ * `-p ''` stops it printing a prompt into the log we stream back. */
16
+ export declare function sudoArgv(inner: string): {
17
+ file: string;
18
+ args: string[];
19
+ };
20
+ export declare function installEngine(onLog: (s: string) => void, askPassword?: () => Promise<string | null>): Promise<void>;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installPlan = installPlan;
4
+ exports.runningAsRoot = runningAsRoot;
5
+ exports.sudoArgv = sudoArgv;
6
+ exports.installEngine = installEngine;
7
+ // Install a container engine on this machine, on request.
8
+ //
9
+ // Deliberately per-platform and deliberately honest about privilege: the
10
+ // Linux path needs root, and a daemon running as an ordinary user cannot get
11
+ // it. Rather than half-run and leave a broken apt state, it checks first and
12
+ // says exactly what to do instead. A button that fails clearly beats one that
13
+ // fails mysteriously.
14
+ const child_process_1 = require("child_process");
15
+ const win_1 = require("../win");
16
+ /** Pure, so the choice is testable on any machine. */
17
+ function installPlan(platform, isRoot) {
18
+ if (platform === 'darwin') {
19
+ // Homebrew installs Docker Desktop without sudo when brew owns its prefix.
20
+ return {
21
+ file: 'brew', args: ['install', '--cask', '--no-quarantine', 'docker'],
22
+ manual: 'brew install --cask docker', needsRoot: false,
23
+ };
24
+ }
25
+ if (platform === 'win32') {
26
+ return {
27
+ file: 'winget',
28
+ args: ['install', '-e', '--id', 'Docker.DockerDesktop', '--accept-package-agreements', '--accept-source-agreements'],
29
+ manual: 'winget install -e --id Docker.DockerDesktop', needsRoot: false,
30
+ };
31
+ }
32
+ // Linux: get.docker.com is the vendor's own script and needs root. Podman
33
+ // from the distro is the lighter option but still needs a package manager.
34
+ return {
35
+ file: isRoot ? 'sh' : null,
36
+ args: ['-c', 'curl -fsSL https://get.docker.com | sh'],
37
+ manual: 'curl -fsSL https://get.docker.com | sudo sh', needsRoot: true,
38
+ };
39
+ }
40
+ function runningAsRoot() {
41
+ return typeof process.getuid === 'function' && process.getuid() === 0;
42
+ }
43
+ /** Build the argv for a privileged install. The password NEVER goes in argv —
44
+ * argv is world-readable through /proc on Linux, so anyone with a shell on
45
+ * the box could read it. `-S` makes sudo take it on stdin instead, and
46
+ * `-p ''` stops it printing a prompt into the log we stream back. */
47
+ function sudoArgv(inner) {
48
+ return { file: 'sudo', args: ['-S', '-p', '', 'sh', '-c', inner] };
49
+ }
50
+ async function installEngine(onLog, askPassword) {
51
+ const plan = installPlan(process.platform, runningAsRoot());
52
+ let file = plan.file;
53
+ let args = plan.args;
54
+ let password = null;
55
+ if (!file && plan.needsRoot && askPassword) {
56
+ onLog('This machine needs a sudo password to install Docker.\n');
57
+ password = await askPassword();
58
+ if (!password) {
59
+ throw new Error(`No password given. Run it on the machine instead:\n ${plan.manual}`);
60
+ }
61
+ const sudo = sudoArgv(plan.args[1] ?? '');
62
+ file = sudo.file;
63
+ args = sudo.args;
64
+ }
65
+ if (!file) {
66
+ throw new Error(`This needs root, and the node is not running as root. Run this on ${require('os').hostname()}:\n ${plan.manual}`);
67
+ }
68
+ onLog(`installing a container engine\n ${plan.manual}\n\n`);
69
+ const inv = (0, win_1.resolveCliInvocation)(file, args);
70
+ await new Promise((resolve, reject) => {
71
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
72
+ env: process.env, windowsHide: true, timeout: 25 * 60_000,
73
+ stdio: password ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
74
+ });
75
+ if (password) {
76
+ // Straight down stdin and gone. It is never echoed, never logged, and
77
+ // the caller wipes it from the row as soon as it reaches here.
78
+ child.stdin?.write(`${password}\n`);
79
+ child.stdin?.end();
80
+ password = null;
81
+ }
82
+ let tail = '';
83
+ const cap = (b) => { const s = b.toString('utf8'); tail = (tail + s).slice(-2000); onLog(s); };
84
+ child.stdout?.on('data', cap);
85
+ child.stderr?.on('data', cap);
86
+ child.on('error', err => reject(new Error(`${file} is not available here. Install it by hand:\n ${plan.manual}\n(${err.message})`)));
87
+ child.on('exit', code => code === 0
88
+ ? resolve()
89
+ : reject(new Error(`install failed (exit ${code}). Try by hand:\n ${plan.manual}\n${tail.slice(-400)}`)));
90
+ });
91
+ }
@@ -0,0 +1,17 @@
1
+ export interface ViewerTarget {
2
+ vncPort: number | null;
3
+ vncPassword: string | null;
4
+ desktopId: string;
5
+ }
6
+ /** What we would run, as a pure value, so the choice is testable without
7
+ * launching anything. */
8
+ export declare function viewerCommand(platform: NodeJS.Platform, t: ViewerTarget, studioBase?: string): {
9
+ file: string;
10
+ args: string[];
11
+ kind: 'local' | 'browser';
12
+ } | null;
13
+ export declare function openViewer(t: ViewerTarget): {
14
+ ok: boolean;
15
+ kind: string;
16
+ error?: string;
17
+ };
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.viewerCommand = viewerCommand;
4
+ exports.openViewer = openViewer;
5
+ // Open a desktop in whatever this machine calls a screen viewer.
6
+ //
7
+ // A terminal cannot draw a remote framebuffer, so "control it from the
8
+ // console" means handing the desktop to something that can. macOS has a VNC
9
+ // client built in and the RFB port is already on loopback, so Screen Sharing
10
+ // connects straight to the container with no relay in the path - the lowest
11
+ // latency route there is. Windows has no built-in VNC client, so there the
12
+ // browser viewer is the honest fallback.
13
+ const child_process_1 = require("child_process");
14
+ /** What we would run, as a pure value, so the choice is testable without
15
+ * launching anything. */
16
+ function viewerCommand(platform, t, studioBase = 'https://entities.add.ai') {
17
+ if (platform === 'darwin' && t.vncPort) {
18
+ // The password rides in the URL so Screen Sharing does not prompt. It
19
+ // never leaves this machine: the whole URL is handed to a local app.
20
+ const auth = t.vncPassword ? `:${encodeURIComponent(t.vncPassword)}@` : '';
21
+ return { file: 'open', args: [`vnc://${auth}127.0.0.1:${t.vncPort}`], kind: 'local' };
22
+ }
23
+ const url = `${studioBase}/desktops/${t.desktopId}`;
24
+ if (platform === 'win32')
25
+ return { file: 'cmd', args: ['/c', 'start', '', url], kind: 'browser' };
26
+ return { file: 'xdg-open', args: [url], kind: 'browser' };
27
+ }
28
+ function openViewer(t) {
29
+ const cmd = viewerCommand(process.platform, t);
30
+ if (!cmd)
31
+ return { ok: false, kind: 'none', error: 'no viewer for this platform' };
32
+ try {
33
+ const child = (0, child_process_1.spawn)(cmd.file, cmd.args, { detached: true, stdio: 'ignore' });
34
+ child.unref();
35
+ return { ok: true, kind: cmd.kind };
36
+ }
37
+ catch (err) {
38
+ return { ok: false, kind: cmd.kind, error: err.message };
39
+ }
40
+ }
@@ -2,6 +2,11 @@ import { DesktopRow } from './spec';
2
2
  export interface EngineInfo {
3
3
  id: 'docker' | 'podman';
4
4
  version: string;
5
+ /** The CLI exists AND its daemon answers. False means installed but
6
+ * unusable — a different problem, with a different fix, than missing. */
7
+ running: boolean;
8
+ /** Set when running is false: what to do about it, in words. */
9
+ reason?: string;
5
10
  }
6
11
  export interface ExecOpts {
7
12
  cwd: string;
@@ -86,5 +86,6 @@ export declare function createDashboardScreen(deps: {
86
86
  openTranscript(r: RequestRow): void;
87
87
  openRequests(): void;
88
88
  openHarnesses(): void;
89
+ openDesktops(): void;
89
90
  openLogs(): void;
90
91
  }): Screen;
@@ -26,6 +26,7 @@ const autostart_1 = require("../autostart");
26
26
  exports.MENU = [
27
27
  { key: 'activity', label: 'Activity', hint: 'Every request this node has handled', shortcut: 'a' },
28
28
  { key: 'harnesses', label: 'Harnesses', hint: 'Install / log in agent CLIs', shortcut: 'h' },
29
+ { key: 'desktops', label: 'Desktops', hint: 'Isolated environments entities work in', shortcut: 'd' },
29
30
  { key: 'logs', label: 'Logs', hint: 'What the daemon is saying', shortcut: 'l' },
30
31
  ];
31
32
  /** Live rows the NOW band shows before it starts counting the rest. */
@@ -388,6 +389,7 @@ function createDashboardScreen(deps) {
388
389
  { keys: '⏎', label: 'open the destination, or watch the selected run' },
389
390
  { keys: 'a', label: 'activity — full request history' },
390
391
  { keys: 'h', label: 'harnesses — install / log in agent CLIs' },
392
+ { keys: 'd', label: 'desktops — the environments entities work in' },
391
393
  { keys: 'l', label: 'logs — daemon output' },
392
394
  { keys: 's', label: 'start this node at login (on / off)' },
393
395
  { keys: 'r', label: 'refresh now' },
@@ -412,6 +414,10 @@ function createDashboardScreen(deps) {
412
414
  deps.openHarnesses();
413
415
  return;
414
416
  }
417
+ if (key.name === 'd') {
418
+ deps.openDesktops();
419
+ return;
420
+ }
415
421
  if (key.name === 'l') {
416
422
  deps.openLogs();
417
423
  return;
@@ -445,6 +451,8 @@ function createDashboardScreen(deps) {
445
451
  const target = exports.MENU[st.sel]?.key;
446
452
  if (target === 'harnesses')
447
453
  deps.openHarnesses();
454
+ else if (target === 'desktops')
455
+ deps.openDesktops();
448
456
  else if (target === 'logs')
449
457
  deps.openLogs();
450
458
  else
@@ -0,0 +1,11 @@
1
+ import type { DesktopRow } from '../desktop/spec';
2
+ import { type AppHost, type Screen } from './app';
3
+ /** A desktop with a recent tool call is being worked in right now. Same 90s
4
+ * definition the chat card uses, so the console and chat never disagree. */
5
+ export declare function isBusyWith(row: {
6
+ last_used_at?: string | null;
7
+ }): boolean;
8
+ export declare function desktopLine(d: DesktopRow, selected: boolean, width: number): string;
9
+ export declare function createDesktopsScreen(deps: {
10
+ host: AppHost;
11
+ }): Screen;
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+ // The desktops on this machine, and what is happening in them.
3
+ //
4
+ // A screen on the stack like Harnesses, so it inherits the diff painter, the
5
+ // footer grammar and `?` help for free. Actions go through the same command
6
+ // path Studio uses rather than touching docker directly — one way to start a
7
+ // desktop, whoever asked for it, so the two surfaces can never disagree.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.isBusyWith = isBusyWith;
10
+ exports.desktopLine = desktopLine;
11
+ exports.createDesktopsScreen = createDesktopsScreen;
12
+ const manager_1 = require("../desktop/manager");
13
+ const docker_1 = require("../desktop/docker");
14
+ const open_viewer_1 = require("../desktop/open-viewer");
15
+ const render_1 = require("./render");
16
+ const app_1 = require("./app");
17
+ function tone(status) {
18
+ if (status === 'running')
19
+ return render_1.green;
20
+ if (status === 'failed')
21
+ return render_1.red;
22
+ if (status === 'creating' || status === 'starting' || status === 'deleting')
23
+ return render_1.yellow;
24
+ return render_1.grey;
25
+ }
26
+ /** A desktop with a recent tool call is being worked in right now. Same 90s
27
+ * definition the chat card uses, so the console and chat never disagree. */
28
+ function isBusyWith(row) {
29
+ if (!row.last_used_at)
30
+ return false;
31
+ return Date.now() - new Date(row.last_used_at).getTime() < 90_000;
32
+ }
33
+ function desktopLine(d, selected, width) {
34
+ const mark = selected ? (0, render_1.bold)('▸ ') : ' ';
35
+ const status = tone(d.status)(d.status.padEnd(9));
36
+ const size = (0, render_1.grey)(`${d.cpus}c ${Math.round(d.memory_mb / 1024)}g`);
37
+ const port = d.vnc_port ? (0, render_1.grey)(`:${d.vnc_port}`) : (0, render_1.grey)('—');
38
+ const name = (0, render_1.truncate)(d.name, Math.max(12, width - 34));
39
+ return `${mark}${status} ${name.padEnd(Math.max(12, width - 34))} ${size} ${port}`;
40
+ }
41
+ function createDesktopsScreen(deps) {
42
+ const st = { rows: null, sel: 0, spin: 0, busy: null, confirmDelete: null, note: null };
43
+ const current = () => st.rows?.[st.sel] ?? null;
44
+ const act = async (label, fn) => {
45
+ st.busy = label;
46
+ deps.host.redraw();
47
+ try {
48
+ await fn();
49
+ st.note = null;
50
+ }
51
+ catch (err) {
52
+ st.note = err.message.slice(0, 120);
53
+ }
54
+ finally {
55
+ st.busy = null;
56
+ await refresh();
57
+ deps.host.redraw();
58
+ }
59
+ };
60
+ const refresh = async () => {
61
+ try {
62
+ st.rows = await (0, manager_1.listDesktops)();
63
+ }
64
+ catch (err) {
65
+ st.note = err.message.slice(0, 120);
66
+ st.rows = st.rows ?? [];
67
+ }
68
+ if (st.sel >= (st.rows?.length ?? 0))
69
+ st.sel = Math.max(0, (st.rows?.length ?? 1) - 1);
70
+ };
71
+ return {
72
+ id: 'desktops',
73
+ title: 'Desktops',
74
+ async poll() { await refresh(); },
75
+ pollMs: () => 5_000,
76
+ tick(n) { st.spin = n; return st.busy !== null; },
77
+ keys() {
78
+ return [
79
+ { keys: '↑/↓', label: 'choose a desktop' },
80
+ { keys: '↵', label: 'open it — Screen Sharing on this Mac' },
81
+ { keys: 's', label: 'start / stop' },
82
+ { keys: 'r', label: 'rebuild from the image' },
83
+ { keys: 'd', label: 'delete (asks first)' },
84
+ ];
85
+ },
86
+ render(width, height) {
87
+ const out = [];
88
+ out.push((0, app_1.heading)('Desktops', 'on this machine'));
89
+ out.push('');
90
+ if (st.rows === null) {
91
+ out.push((0, render_1.dim)(` ${render_1.SPIN[st.spin % render_1.SPIN.length]} looking…`));
92
+ return out;
93
+ }
94
+ if (st.rows.length === 0) {
95
+ out.push((0, render_1.dim)(' No desktops here yet.'));
96
+ out.push('');
97
+ out.push((0, render_1.dim)(' Create one from the machine\'s page in Entity Studio.'));
98
+ return out;
99
+ }
100
+ const live = st.rows.filter(d => d.status === 'running').length;
101
+ out.push((0, render_1.dim)(` ${live} running · ${st.rows.length} total`));
102
+ out.push('');
103
+ const room = Math.max(3, height - 8);
104
+ st.rows.slice(0, room).forEach((d, i) => out.push(desktopLine(d, i === st.sel, width)));
105
+ const sel = current();
106
+ if (sel?.status_message) {
107
+ out.push('');
108
+ out.push((0, render_1.dim)(` ${(0, render_1.truncate)(sel.status_message, width - 4)}`));
109
+ }
110
+ if (st.busy) {
111
+ out.push('');
112
+ out.push((0, render_1.dim)(` ${render_1.SPIN[st.spin % render_1.SPIN.length]} ${st.busy}…`));
113
+ }
114
+ if (st.confirmDelete) {
115
+ out.push('');
116
+ out.push((0, render_1.red)(` Delete "${st.confirmDelete}"? y to confirm, any other key to keep it.`));
117
+ }
118
+ if (st.note) {
119
+ out.push('');
120
+ out.push((0, render_1.red)(` ${(0, render_1.truncate)(st.note, width - 4)}`));
121
+ }
122
+ out.push('');
123
+ out.push((0, app_1.footerHint)(this.keys ? this.keys() : []));
124
+ return out;
125
+ },
126
+ async onKey(key) {
127
+ if (st.confirmDelete) {
128
+ const name = st.confirmDelete;
129
+ st.confirmDelete = null;
130
+ if (key.name !== 'y') {
131
+ deps.host.redraw();
132
+ return;
133
+ }
134
+ const row = st.rows?.find(d => d.name === name);
135
+ if (row) {
136
+ await act('deleting', async () => {
137
+ const p = await (0, docker_1.getProvider)();
138
+ if (!p)
139
+ throw new Error('no container engine on this machine');
140
+ await p.remove(row);
141
+ await (0, manager_1.setStatus)(row.id, { status: 'deleting' });
142
+ });
143
+ }
144
+ return;
145
+ }
146
+ const rows = st.rows ?? [];
147
+ if (key.name === 'up') {
148
+ st.sel = Math.max(0, st.sel - 1);
149
+ deps.host.redraw();
150
+ return;
151
+ }
152
+ if (key.name === 'down') {
153
+ st.sel = Math.min(rows.length - 1, st.sel + 1);
154
+ deps.host.redraw();
155
+ return;
156
+ }
157
+ const row = current();
158
+ if (!row)
159
+ return;
160
+ if (key.name === 'return') {
161
+ if (row.status !== 'running') {
162
+ st.note = 'Start it first — there is nothing to show yet.';
163
+ deps.host.redraw();
164
+ return;
165
+ }
166
+ const r = (0, open_viewer_1.openViewer)({ vncPort: row.vnc_port, vncPassword: row.vnc_password, desktopId: row.id });
167
+ st.note = r.ok
168
+ ? (r.kind === 'local' ? 'Opening in Screen Sharing…' : 'Opening the viewer in your browser…')
169
+ : `Could not open it: ${r.error ?? 'unknown'}`;
170
+ deps.host.redraw();
171
+ return;
172
+ }
173
+ if (key.name === 's') {
174
+ await act(row.status === 'running' ? 'stopping' : 'starting', async () => {
175
+ const p = await (0, docker_1.getProvider)();
176
+ if (!p)
177
+ throw new Error('no container engine on this machine');
178
+ if (row.status === 'running') {
179
+ await p.stop(row);
180
+ await (0, manager_1.setStatus)(row.id, { status: 'stopped' });
181
+ }
182
+ else {
183
+ await p.start(row);
184
+ await (0, manager_1.setStatus)(row.id, { status: 'running', status_message: null });
185
+ }
186
+ });
187
+ return;
188
+ }
189
+ if (key.name === 'r') {
190
+ st.note = 'Rebuild runs from Entity Studio — it re-pulls the image and copies logins in.';
191
+ deps.host.redraw();
192
+ return;
193
+ }
194
+ if (key.name === 'd') {
195
+ st.confirmDelete = row.name;
196
+ deps.host.redraw();
197
+ return;
198
+ }
199
+ },
200
+ };
201
+ }
package/dist/tui/run.js CHANGED
@@ -48,6 +48,7 @@ const dashboard_1 = require("./dashboard");
48
48
  const requests_1 = require("./requests");
49
49
  const transcript_1 = require("./transcript");
50
50
  const harnesses_1 = require("./harnesses");
51
+ const desktops_1 = require("./desktops");
51
52
  const logs_1 = require("./logs");
52
53
  const render_1 = require("./render");
53
54
  const console_capture_1 = require("./console-capture");
@@ -208,6 +209,7 @@ async function runDashboard(opts) {
208
209
  openTranscript,
209
210
  openRequests: () => host.push((0, requests_1.createRequestsScreen)({ data, host, openTranscript })),
210
211
  openHarnesses: () => host.push((0, harnesses_1.createHarnessesScreen)({ host, suspend })),
212
+ openDesktops: () => host.push((0, desktops_1.createDesktopsScreen)({ host })),
211
213
  openLogs: () => host.push((0, logs_1.createLogsScreen)({
212
214
  host,
213
215
  logs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.15.0",
3
+ "version": "0.18.0",
4
4
  "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
5
  "license": "MIT",
6
6
  "keywords": [