@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.
- package/dist/command-runner.js +70 -2
- package/dist/desktop/docker.js +5 -2
- package/dist/desktop/engine.d.ts +12 -0
- package/dist/desktop/engine.js +39 -10
- package/dist/desktop/install-engine.d.ts +20 -0
- package/dist/desktop/install-engine.js +91 -0
- package/dist/desktop/open-viewer.d.ts +17 -0
- package/dist/desktop/open-viewer.js +40 -0
- package/dist/desktop/provider.d.ts +5 -0
- package/dist/tui/dashboard.d.ts +1 -0
- package/dist/tui/dashboard.js +8 -0
- package/dist/tui/desktops.d.ts +11 -0
- package/dist/tui/desktops.js +201 -0
- package/dist/tui/run.js +2 -0
- package/package.json +1 -1
package/dist/command-runner.js
CHANGED
|
@@ -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() +
|
|
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() +
|
|
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;
|
package/dist/desktop/docker.js
CHANGED
|
@@ -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
|
-
|
|
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; }
|
package/dist/desktop/engine.d.ts
CHANGED
|
@@ -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>;
|
package/dist/desktop/engine.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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('
|
|
25
|
-
child.on('
|
|
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(
|
|
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
|
|
37
|
-
if (!
|
|
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
|
|
40
|
-
if (
|
|
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;
|
package/dist/tui/dashboard.d.ts
CHANGED
package/dist/tui/dashboard.js
CHANGED
|
@@ -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.
|
|
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": [
|