@addai/node 0.11.3 → 0.13.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.
@@ -45,6 +45,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.setRestartHook = setRestartHook;
46
46
  exports.wake = wake;
47
47
  const child_process_1 = require("child_process");
48
+ const crypto = __importStar(require("crypto"));
48
49
  const fs = __importStar(require("fs"));
49
50
  const os = __importStar(require("os"));
50
51
  const path = __importStar(require("path"));
@@ -59,6 +60,9 @@ const self_update_1 = require("./self-update");
59
60
  const autostart_1 = require("./autostart");
60
61
  const paths_1 = require("./paths");
61
62
  const pty_helper_1 = require("./pty-helper");
63
+ const docker_1 = require("./desktop/docker");
64
+ const manager_1 = require("./desktop/manager");
65
+ const creds_1 = require("./desktop/creds");
62
66
  const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
63
67
  /** Own version, read the same way index.ts does. Resolved here rather than
64
68
  * imported from index.ts, which already imports this module. */
@@ -557,6 +561,115 @@ async function runSetAutostart(cmd) {
557
561
  }
558
562
  await update(cmd.id, 'completed', { autostart: state });
559
563
  }
564
+ /* ── desktops ────────────────────────────────────────────────────────────
565
+ * All six desktop_* kinds. Progress streams into the command payload the
566
+ * same way install_harness does, so Studio's existing live-log panel renders
567
+ * the image pull with no new component. */
568
+ const DESKTOP_KINDS = [
569
+ 'desktop_create', 'desktop_start', 'desktop_stop',
570
+ 'desktop_delete', 'desktop_rebuild', 'desktop_sync_logins',
571
+ ];
572
+ /** Setup scripts run as a FILE, never as a `sh -c` string: a script with
573
+ * quotes or newlines must not need escaping to survive. */
574
+ async function runSetupScript(provider, row, onLog) {
575
+ const { confDir } = (0, manager_1.ensureDirs)(row.id);
576
+ fs.writeFileSync(path.join(confDir, 'setup.sh'), row.setup_script ?? '', { mode: 0o700 });
577
+ await (0, creds_1.execInDesktop)(provider, row, ['bash', '/conf/setup.sh'], onLog);
578
+ }
579
+ async function runDesktopCommand(cmd) {
580
+ if (!cmd.desktop_id) {
581
+ await update(cmd.id, 'failed', undefined, 'no desktop on command');
582
+ return;
583
+ }
584
+ // force: a user who just installed Docker should not have to restart the
585
+ // daemon for their first desktop to build.
586
+ const provider = await (0, docker_1.getProvider)(true);
587
+ if (!provider) {
588
+ const why = 'No container engine on this machine. Install Docker Desktop or Podman, then try again.';
589
+ await update(cmd.id, 'failed', undefined, why);
590
+ await (0, manager_1.setStatus)(cmd.desktop_id, { status: 'failed', status_message: why });
591
+ return;
592
+ }
593
+ const rows = await (0, manager_1.listDesktops)();
594
+ const row = rows.find(r => r.id === cmd.desktop_id);
595
+ if (!row) {
596
+ await update(cmd.id, 'failed', undefined, 'desktop row not found');
597
+ return;
598
+ }
599
+ let log = '';
600
+ const onLog = (chunk) => {
601
+ log = (log + chunk).slice(-LOG_TAIL_CHARS);
602
+ void update(cmd.id, null, { log });
603
+ };
604
+ try {
605
+ await update(cmd.id, 'running', { log });
606
+ switch (cmd.kind) {
607
+ case 'desktop_create':
608
+ case 'desktop_rebuild': {
609
+ if (cmd.kind === 'desktop_rebuild') {
610
+ onLog('removing existing container\n');
611
+ await provider.remove(row);
612
+ }
613
+ await (0, manager_1.setStatus)(row.id, { status: 'creating', status_message: null });
614
+ const { confDir, workDir } = (0, manager_1.ensureDirs)(row.id);
615
+ const vncPort = await (0, manager_1.allocateVncPort)();
616
+ // A fresh password on every build; it only ever travels between the
617
+ // daemon and the container on the same machine.
618
+ const vncPassword = crypto.randomBytes(12).toString('base64url');
619
+ const built = { ...row, vnc_password: vncPassword };
620
+ const containerId = await provider.create(built, { confDir, workDir, vncPort }, onLog);
621
+ await (0, manager_1.setStatus)(row.id, {
622
+ status: 'running', container_id: containerId,
623
+ engine: provider.id, vnc_port: vncPort, status_message: null,
624
+ });
625
+ await (0, creds_1.syncCredentials)(provider, built, onLog);
626
+ if (row.setup_script) {
627
+ onLog('running setup script\n');
628
+ try {
629
+ await runSetupScript(provider, built, onLog);
630
+ }
631
+ catch (err) {
632
+ // The desktop exists and works; the script did not. Record it and
633
+ // do not destroy the box the user just waited minutes for.
634
+ await (0, manager_1.setStatus)(row.id, {
635
+ status_message: `Setup script failed: ${err.message}`.slice(0, 400),
636
+ });
637
+ }
638
+ }
639
+ break;
640
+ }
641
+ case 'desktop_start':
642
+ await (0, manager_1.setStatus)(row.id, { status: 'starting' });
643
+ await provider.start(row);
644
+ await (0, manager_1.setStatus)(row.id, { status: 'running', status_message: null });
645
+ break;
646
+ case 'desktop_stop':
647
+ await provider.stop(row);
648
+ await (0, manager_1.setStatus)(row.id, { status: 'stopped', status_message: null });
649
+ break;
650
+ case 'desktop_delete': {
651
+ await provider.remove(row);
652
+ // The server keeps the row until the machine confirms, so a delete
653
+ // issued to an offline node is not lost.
654
+ const t = token();
655
+ if (t)
656
+ await (0, supabase_client_1.rpc)('runtime_desktop_deleted', { p_token: t, p_desktop_id: row.id });
657
+ break;
658
+ }
659
+ case 'desktop_sync_logins':
660
+ await (0, creds_1.syncCredentials)(provider, row, onLog);
661
+ break;
662
+ }
663
+ await update(cmd.id, 'completed', { log });
664
+ }
665
+ catch (err) {
666
+ const message = err.message.slice(0, 500);
667
+ await update(cmd.id, 'failed', { log }, message);
668
+ if (cmd.kind !== 'desktop_delete') {
669
+ await (0, manager_1.setStatus)(row.id, { status: 'failed', status_message: message });
670
+ }
671
+ }
672
+ }
560
673
  /* ── dispatcher ──────────────────────────────────────────────────────── */
561
674
  async function execute(cmd) {
562
675
  // Not a harness command — dispatch before the harness lookup, which would
@@ -569,6 +682,10 @@ async function execute(cmd) {
569
682
  await runSetAutostart(cmd);
570
683
  return;
571
684
  }
685
+ if (DESKTOP_KINDS.includes(cmd.kind)) {
686
+ await runDesktopCommand(cmd);
687
+ return;
688
+ }
572
689
  const spec = (0, harness_registry_1.harness)(cmd.harness ?? '');
573
690
  if (!spec) {
574
691
  await update(cmd.id, 'failed', {}, `unknown harness: ${cmd.harness}`);
@@ -0,0 +1,7 @@
1
+ import { DesktopRow } from './spec';
2
+ import { DesktopProvider } from './provider';
3
+ /** Keep the auth fields, drop host MCP servers and project history. */
4
+ export declare function stripMcpServers(raw: string): string;
5
+ /** Run a command inside a desktop, streaming output to onLog. */
6
+ export declare function execInDesktop(provider: DesktopProvider, row: DesktopRow, cmd: string[], onLog: (c: string) => void): Promise<void>;
7
+ export declare function syncCredentials(provider: DesktopProvider, row: DesktopRow, onLog: (c: string) => void): Promise<void>;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.stripMcpServers = stripMcpServers;
37
+ exports.execInDesktop = execInDesktop;
38
+ exports.syncCredentials = syncCredentials;
39
+ // Harness logins are COPIED into a desktop, never bind-mounted.
40
+ //
41
+ // Bind-mounting ~/.claude would hand the box write access to every transcript
42
+ // on this machine - which defeats the isolation the desktop exists for. The
43
+ // entity already effectively holds these tokens, so copying the auth blob
44
+ // costs nothing; the host's project history and MCP servers are what must not
45
+ // travel, so they are stripped here.
46
+ const child_process_1 = require("child_process");
47
+ const fs = __importStar(require("fs"));
48
+ const os = __importStar(require("os"));
49
+ const path = __importStar(require("path"));
50
+ const win_1 = require("../win");
51
+ const provider_1 = require("./provider");
52
+ /** Keep the auth fields, drop host MCP servers and project history. */
53
+ function stripMcpServers(raw) {
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ if (parsed === null || typeof parsed !== 'object')
58
+ throw new Error('not an object');
59
+ }
60
+ catch {
61
+ return '{"mcpServers":{}}';
62
+ }
63
+ delete parsed.projects;
64
+ parsed.mcpServers = {};
65
+ return JSON.stringify(parsed);
66
+ }
67
+ /** Run a command inside a desktop, streaming output to onLog. */
68
+ function execInDesktop(provider, row, cmd, onLog) {
69
+ const args = (0, provider_1.buildExecArgs)(row, cmd, { cwd: '/work', env: {}, tty: false });
70
+ const inv = (0, win_1.resolveCliInvocation)(provider.id, args);
71
+ return new Promise((resolve, reject) => {
72
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
73
+ env: process.env, windowsHide: true, timeout: 10 * 60_000,
74
+ });
75
+ let err = '';
76
+ child.stdout?.on('data', b => onLog(b.toString('utf8')));
77
+ child.stderr?.on('data', b => { const s = b.toString('utf8'); err += s; onLog(s); });
78
+ child.on('error', reject);
79
+ child.on('exit', code => code === 0
80
+ ? resolve()
81
+ : reject(new Error(err.trim().slice(0, 400) || `exit ${code}`)));
82
+ });
83
+ }
84
+ /** Write a file into the container through stdin, so contents with quotes,
85
+ * newlines or ESC bytes survive verbatim - nothing is ever interpolated
86
+ * into a shell string. */
87
+ function writeInto(provider, row, dest, contents) {
88
+ const args = (0, provider_1.buildExecArgs)(row, ['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat > "$1"', '_', dest], { cwd: '/work', env: {}, tty: false });
89
+ const inv = (0, win_1.resolveCliInvocation)(provider.id, args);
90
+ return new Promise((resolve, reject) => {
91
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, { env: process.env, windowsHide: true });
92
+ child.on('error', reject);
93
+ child.on('exit', code => code === 0
94
+ ? resolve()
95
+ : reject(new Error(`write ${dest} failed (exit ${code})`)));
96
+ child.stdin?.end(contents);
97
+ });
98
+ }
99
+ function credFiles() {
100
+ const home = os.homedir();
101
+ return [
102
+ {
103
+ hostPath: path.join(home, '.claude.json'),
104
+ destPath: '/home/entity/.claude.json',
105
+ transform: stripMcpServers,
106
+ },
107
+ { hostPath: path.join(home, '.claude', '.credentials.json'),
108
+ destPath: '/home/entity/.claude/.credentials.json' },
109
+ { hostPath: path.join(home, '.codex', 'auth.json'),
110
+ destPath: '/home/entity/.codex/auth.json' },
111
+ { hostPath: path.join(home, '.gemini', 'oauth_creds.json'),
112
+ destPath: '/home/entity/.gemini/oauth_creds.json' },
113
+ ];
114
+ }
115
+ async function syncCredentials(provider, row, onLog) {
116
+ for (const f of credFiles()) {
117
+ let raw;
118
+ try {
119
+ raw = fs.readFileSync(f.hostPath, 'utf8');
120
+ }
121
+ catch {
122
+ continue;
123
+ } // not logged in here: nothing to copy
124
+ const body = f.transform ? f.transform(raw) : raw;
125
+ try {
126
+ await writeInto(provider, row, f.destPath, body);
127
+ onLog(`copied ${path.basename(f.destPath)}\n`);
128
+ }
129
+ catch (err) {
130
+ // One missing login must not abort the others.
131
+ onLog(`could not copy ${path.basename(f.destPath)}: ${err.message}\n`);
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,19 @@
1
+ import { DesktopRow } from './spec';
2
+ import { DesktopProvider, EngineInfo, CreateOpts } from './provider';
3
+ export declare class CliProvider implements DesktopProvider {
4
+ readonly id: 'docker' | 'podman';
5
+ private version;
6
+ constructor(id: 'docker' | 'podman', version: string);
7
+ private run;
8
+ probe(): Promise<EngineInfo | null>;
9
+ create(row: DesktopRow, opts: CreateOpts, onLog: (c: string) => void): Promise<string>;
10
+ start(row: DesktopRow): Promise<void>;
11
+ stop(row: DesktopRow): Promise<void>;
12
+ remove(row: DesktopRow): Promise<void>;
13
+ inspect(row: DesktopRow): Promise<{
14
+ running: boolean;
15
+ containerId: string;
16
+ } | null>;
17
+ }
18
+ export declare function getProvider(force?: boolean): Promise<DesktopProvider | null>;
19
+ export declare function resetProviderCache(): void;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CliProvider = void 0;
4
+ exports.getProvider = getProvider;
5
+ exports.resetProviderCache = resetProviderCache;
6
+ // Docker/Podman backend. One class serves both: the CLI surface we use
7
+ // (run/start/stop/rm/inspect/exec) is compatible between them.
8
+ const child_process_1 = require("child_process");
9
+ const win_1 = require("../win");
10
+ const provider_1 = require("./provider");
11
+ const engine_1 = require("./engine");
12
+ class CliProvider {
13
+ id;
14
+ version;
15
+ constructor(id, version) {
16
+ this.id = id;
17
+ this.version = version;
18
+ }
19
+ run(args, onLog, timeoutMs = 15 * 60_000) {
20
+ return new Promise(resolve => {
21
+ let stdout = '', stderr = '';
22
+ try {
23
+ const inv = (0, win_1.resolveCliInvocation)(this.id, args);
24
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
25
+ env: process.env, windowsHide: true, timeout: timeoutMs,
26
+ });
27
+ child.stdout?.on('data', b => { const s = b.toString('utf8'); stdout += s; onLog?.(s); });
28
+ child.stderr?.on('data', b => { const s = b.toString('utf8'); stderr += s; onLog?.(s); });
29
+ child.on('error', err => resolve({ ok: false, stdout, stderr: String(err) }));
30
+ child.on('exit', code => resolve({ ok: code === 0, stdout, stderr }));
31
+ }
32
+ catch (err) {
33
+ resolve({ ok: false, stdout, stderr: err.message });
34
+ }
35
+ });
36
+ }
37
+ async probe() {
38
+ return { id: this.id, version: this.version };
39
+ }
40
+ async create(row, opts, onLog) {
41
+ // Pull first so the slow, chatty image download streams into the command
42
+ // payload as its own phase rather than hiding inside `run`.
43
+ onLog(`pulling ${row.image}\n`);
44
+ const pull = await this.run(['pull', row.image], onLog);
45
+ if (!pull.ok)
46
+ throw new Error(`image pull failed: ${pull.stderr.trim().slice(0, 500)}`);
47
+ onLog(`creating ${(0, provider_1.containerName)(row)}\n`);
48
+ const res = await this.run((0, provider_1.buildCreateArgs)(row, opts), onLog);
49
+ if (!res.ok)
50
+ throw new Error(`create failed: ${res.stderr.trim().slice(0, 500)}`);
51
+ return res.stdout.trim().split('\n').pop() || '';
52
+ }
53
+ async start(row) {
54
+ const res = await this.run(['start', (0, provider_1.containerName)(row)], undefined, 60_000);
55
+ if (!res.ok)
56
+ throw new Error(`start failed: ${res.stderr.trim().slice(0, 300)}`);
57
+ }
58
+ async stop(row) {
59
+ const res = await this.run(['stop', '-t', '10', (0, provider_1.containerName)(row)], undefined, 60_000);
60
+ if (!res.ok)
61
+ throw new Error(`stop failed: ${res.stderr.trim().slice(0, 300)}`);
62
+ }
63
+ async remove(row) {
64
+ // -f so a running desktop deletes without a separate stop; -v drops its
65
+ // anonymous volumes, otherwise deleting leaks disk forever.
66
+ const res = await this.run(['rm', '-f', '-v', (0, provider_1.containerName)(row)], undefined, 120_000);
67
+ // "No such container" IS success for a delete.
68
+ if (!res.ok && !/no such container/i.test(res.stderr)) {
69
+ throw new Error(`remove failed: ${res.stderr.trim().slice(0, 300)}`);
70
+ }
71
+ }
72
+ async inspect(row) {
73
+ const res = await this.run(['inspect', '-f', '{{.Id}} {{.State.Running}}', (0, provider_1.containerName)(row)], undefined, 30_000);
74
+ if (!res.ok)
75
+ return null;
76
+ const [id, running] = res.stdout.trim().split(/\s+/);
77
+ if (!id)
78
+ return null;
79
+ return { running: running === 'true', containerId: id };
80
+ }
81
+ }
82
+ exports.CliProvider = CliProvider;
83
+ let cached;
84
+ async function getProvider(force = false) {
85
+ if (!force && cached !== undefined)
86
+ return cached;
87
+ const info = await (0, engine_1.detectEngine)();
88
+ cached = info ? new CliProvider(info.id, info.version) : null;
89
+ return cached;
90
+ }
91
+ function resetProviderCache() { cached = undefined; }
@@ -0,0 +1,3 @@
1
+ import { EngineInfo } from './provider';
2
+ export declare function parseEngineVersion(stdout: string): string | null;
3
+ export declare function detectEngine(): Promise<EngineInfo | null>;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseEngineVersion = parseEngineVersion;
4
+ exports.detectEngine = detectEngine;
5
+ // Which container engine is on this machine? Docker first (the verified
6
+ // path), Podman second. Never installs anything: installing Docker Desktop
7
+ // needs admin rights and, on Windows, a reboot for the WSL2 kernel, so the
8
+ // daemon reports the absence and Studio shows guidance instead.
9
+ const child_process_1 = require("child_process");
10
+ const win_1 = require("../win");
11
+ function parseEngineVersion(stdout) {
12
+ const m = /version\s+v?(\d+\.\d+\.\d+)/i.exec(stdout);
13
+ return m ? m[1] : null;
14
+ }
15
+ function runVersion(binary) {
16
+ return new Promise(resolve => {
17
+ try {
18
+ const inv = (0, win_1.resolveCliInvocation)(binary, ['--version']);
19
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
20
+ env: process.env, windowsHide: true, timeout: 8_000,
21
+ });
22
+ let out = '';
23
+ 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
+ }
27
+ catch {
28
+ // resolveCliInvocation throws when a Windows shim can't be resolved -
29
+ // that is "not installed" as far as this probe is concerned.
30
+ resolve(null);
31
+ }
32
+ });
33
+ }
34
+ async function detectEngine() {
35
+ for (const id of ['docker', 'podman']) {
36
+ const out = await runVersion(id);
37
+ if (!out)
38
+ continue;
39
+ const version = parseEngineVersion(out);
40
+ if (version)
41
+ return { id, version };
42
+ }
43
+ return null;
44
+ }
@@ -0,0 +1,30 @@
1
+ import { DesktopRow } from './spec';
2
+ export interface Assignment {
3
+ desktop_id: string;
4
+ runtime_id: string;
5
+ is_default: boolean;
6
+ }
7
+ export interface Invocation {
8
+ file: string;
9
+ args: string[];
10
+ cwd: string;
11
+ env: Record<string, string>;
12
+ }
13
+ /** Resolution order from the spec: the chat/schedule picker, then the
14
+ * entity's default on THIS node, then any assignment on this node, then the
15
+ * host. Returns the desktop id, or null for "run on the host".
16
+ *
17
+ * A desktop on another node is deliberately ignored rather than treated as
18
+ * an error: a run that failed over to a second machine must still run. */
19
+ export declare function resolveDesktopForRun(req: {
20
+ desktop_id: string | null;
21
+ runtime_id: string;
22
+ }, assignments: Assignment[], desktops: {
23
+ id: string;
24
+ runtime_id: string;
25
+ }[]): string | null;
26
+ /** Turn a host invocation into a container one. */
27
+ export declare function wrapInvocation(engine: 'docker' | 'podman', row: Pick<DesktopRow, 'id' | 'name'>, inv: Invocation, tty?: boolean): {
28
+ file: string;
29
+ args: string[];
30
+ };
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveDesktopForRun = resolveDesktopForRun;
4
+ exports.wrapInvocation = wrapInvocation;
5
+ // Decides whether a run happens on the host or inside a desktop, and wraps
6
+ // the spawn when it is the latter. Everything about HOW a session runs is
7
+ // unchanged - only the argv it is launched with.
8
+ const spec_1 = require("./spec");
9
+ const provider_1 = require("./provider");
10
+ /** Resolution order from the spec: the chat/schedule picker, then the
11
+ * entity's default on THIS node, then any assignment on this node, then the
12
+ * host. Returns the desktop id, or null for "run on the host".
13
+ *
14
+ * A desktop on another node is deliberately ignored rather than treated as
15
+ * an error: a run that failed over to a second machine must still run. */
16
+ function resolveDesktopForRun(req, assignments, desktops) {
17
+ const onThisNode = (id) => desktops.some(d => d.id === id && d.runtime_id === req.runtime_id);
18
+ if (req.desktop_id && onThisNode(req.desktop_id))
19
+ return req.desktop_id;
20
+ const here = assignments.filter(a => a.runtime_id === req.runtime_id
21
+ && onThisNode(a.desktop_id));
22
+ const preferred = here.find(a => a.is_default) ?? here[0];
23
+ return preferred ? preferred.desktop_id : null;
24
+ }
25
+ /** Turn a host invocation into a container one. */
26
+ function wrapInvocation(engine, row, inv, tty = false) {
27
+ // A cwd that isn't a posix absolute path is a HOST path (a Windows drive
28
+ // path, or a relative one) and means nothing inside the container. Falling
29
+ // back to the workspace root beats handing docker a path it will reject.
30
+ const cwd = inv.cwd.startsWith('/') ? inv.cwd : spec_1.WORK_ROOT;
31
+ const args = (0, provider_1.buildExecArgs)(row, [inv.file, ...inv.args], {
32
+ cwd, env: inv.env, tty,
33
+ });
34
+ return { file: engine, args };
35
+ }
@@ -0,0 +1,28 @@
1
+ import { DesktopRow } from './spec';
2
+ export type Action = 'none' | 'start' | 'mark_failed' | 'mark_stopped' | 'mark_running';
3
+ /** Pure: given what the row claims and what the engine reports, what now?
4
+ * `actual` is null when no such container exists. */
5
+ export declare function reconcileAction(row: Pick<DesktopRow, 'status' | 'autostart'>, actual: {
6
+ running: boolean;
7
+ } | null): Action;
8
+ export declare function listDesktops(): Promise<DesktopRow[]>;
9
+ export declare function setStatus(id: string, fields: {
10
+ status?: DesktopRow['status'];
11
+ status_message?: string | null;
12
+ container_id?: string | null;
13
+ engine?: string | null;
14
+ vnc_port?: number | null;
15
+ }): Promise<void>;
16
+ /** A free loopback port for this desktop's VNC. Asking the OS for port 0 and
17
+ * reading back what it bound is the only race-free way to pick one. */
18
+ export declare function allocateVncPort(): Promise<number>;
19
+ export declare function ensureDirs(desktopId: string): {
20
+ confDir: string;
21
+ workDir: string;
22
+ };
23
+ export declare function startDesktopManager(): void;
24
+ export declare function stopDesktopManager(): void;
25
+ /** Bring a desktop up and wait for it, for the run path. Returns the row when
26
+ * running, null when it could not be started - the caller then falls back to
27
+ * the host rather than failing the run. */
28
+ export declare function ensureRunning(desktopId: string, timeoutMs?: number): Promise<DesktopRow | null>;