@addai/node 0.19.0 → 0.20.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.
@@ -0,0 +1,69 @@
1
+ import { AutostartBackend, AutostartEntry } from './autostart';
2
+ export declare const USER_UNIT_DIR: string;
3
+ export declare const UNIT_PATH: string;
4
+ /** systemd records an `enable` as a symlink here; its presence IS "enabled". */
5
+ export declare const WANTS_PATH: string;
6
+ /** Where systemd records that a user may linger. Readable without a subprocess,
7
+ * which matters because status() rides every heartbeat. */
8
+ export declare function lingerPath(user?: string): string;
9
+ export declare function currentUser(env?: NodeJS.ProcessEnv): string;
10
+ export interface UnitSpec {
11
+ entry: AutostartEntry;
12
+ env: Record<string, string>;
13
+ logPath: string;
14
+ workingDirectory: string;
15
+ /**
16
+ * `append:` writes straight to our own log file, which is where every other
17
+ * platform puts it and where the console's Logs screen looks. It needs
18
+ * systemd 240+; older releases fail to load the unit entirely rather than
19
+ * ignoring the directive, so anything older is sent to the journal instead.
20
+ */
21
+ logMode: 'append' | 'journal';
22
+ }
23
+ /**
24
+ * The unit file, as a string. Pure — this is what the tests assert on.
25
+ *
26
+ * Restart=always mirrors launchd's KeepAlive: the node is meant to be
27
+ * always-on, and a remote roll depends on it. A supervised `update_runtime`
28
+ * installs the new version and simply EXITS, and the supervisor starting the
29
+ * replacement is what makes that safe (see index.ts and isSupervised).
30
+ *
31
+ * RestartSec matches macOS's ThrottleInterval so a crash loop is one launch
32
+ * per 10s on both rather than a spin.
33
+ */
34
+ export declare function buildUnit(spec: UnitSpec): string;
35
+ /** Pull ExecStart back out of a unit we wrote, for `status`. */
36
+ export declare function parseUnitEntry(unit: string): AutostartEntry | undefined;
37
+ /** systemd's major version, or null when it can't be read. */
38
+ export declare function systemdVersion(probe?: () => string | null): number | null;
39
+ /**
40
+ * Is this machine actually running systemd?
41
+ *
42
+ * `/run/systemd/system` is the documented test for "booted with systemd" —
43
+ * plenty of containers and a few distros have the binaries present but no
44
+ * running manager, and there the unit would be written and never run.
45
+ */
46
+ export declare function systemdAvailable(exists?: (p: string) => boolean): boolean;
47
+ /**
48
+ * Normalise `systemctl is-active` output.
49
+ *
50
+ * It prints one word and exits non-zero for anything but `active`, so the exit
51
+ * code carries no information the word doesn't.
52
+ */
53
+ export declare function normaliseActiveState(out: string): string;
54
+ /**
55
+ * Did the unit actually come up?
56
+ *
57
+ * The truth contract this codebase applies to harness logins, applied here:
58
+ * the exit code is a hint, the probe is the verdict. `systemctl enable --now`
59
+ * returns 0 for "request accepted", which is not the same as a running node —
60
+ * a unit can be accepted and then fail its very first ExecStart because a path
61
+ * is wrong or the runtime is missing, and reporting that as success is the
62
+ * failure mode that hurts most, because nothing looks wrong until an entity
63
+ * sits at "Getting ready…" forever.
64
+ *
65
+ * `activating` is given a few seconds rather than being called a failure; a
66
+ * unit that has genuinely failed reports `failed` immediately.
67
+ */
68
+ export declare function waitUntilActive(probe?: () => string, attempts?: number, sleepMs?: number, sleep?: (ms: number) => void): string;
69
+ export declare const backend: AutostartBackend;
@@ -0,0 +1,390 @@
1
+ "use strict";
2
+ // Linux backend: a `systemd --user` unit.
3
+ //
4
+ // A USER unit, not a system one, for the same reason macOS gets a LaunchAgent
5
+ // and Windows a logon task rather than a service: the daemon needs the user's
6
+ // PATH, their ~/.claude and ~/.codex, and their keyring. A root unit would
7
+ // faithfully boot a node that then fails every single run.
8
+ //
9
+ // The piece with no equivalent on the other two platforms is LINGER. A user
10
+ // manager normally starts at the user's first login and stops when their last
11
+ // session ends, so on a server — where nobody logs in, and where an ssh session
12
+ // ending is the normal case — a user unit without linger is a node that dies
13
+ // the moment you disconnect. `loginctl enable-linger` is what makes the user
14
+ // manager start at BOOT and stay up. On a headless box it is not a nicety;
15
+ // without it this feature does not work at all.
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.backend = exports.WANTS_PATH = exports.UNIT_PATH = exports.USER_UNIT_DIR = void 0;
51
+ exports.lingerPath = lingerPath;
52
+ exports.currentUser = currentUser;
53
+ exports.buildUnit = buildUnit;
54
+ exports.parseUnitEntry = parseUnitEntry;
55
+ exports.systemdVersion = systemdVersion;
56
+ exports.systemdAvailable = systemdAvailable;
57
+ exports.normaliseActiveState = normaliseActiveState;
58
+ exports.waitUntilActive = waitUntilActive;
59
+ const fs = __importStar(require("fs"));
60
+ const os = __importStar(require("os"));
61
+ const path = __importStar(require("path"));
62
+ const child_process_1 = require("child_process");
63
+ const autostart_1 = require("./autostart");
64
+ exports.USER_UNIT_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
65
+ exports.UNIT_PATH = path.join(exports.USER_UNIT_DIR, autostart_1.SERVICE_NAME);
66
+ /** systemd records an `enable` as a symlink here; its presence IS "enabled". */
67
+ exports.WANTS_PATH = path.join(exports.USER_UNIT_DIR, 'default.target.wants', autostart_1.SERVICE_NAME);
68
+ /** Where systemd records that a user may linger. Readable without a subprocess,
69
+ * which matters because status() rides every heartbeat. */
70
+ function lingerPath(user = currentUser()) {
71
+ return path.join('/var/lib/systemd/linger', user);
72
+ }
73
+ function currentUser(env = process.env) {
74
+ if (env.USER)
75
+ return env.USER;
76
+ if (env.LOGNAME)
77
+ return env.LOGNAME;
78
+ try {
79
+ return os.userInfo().username;
80
+ }
81
+ catch {
82
+ return '';
83
+ }
84
+ }
85
+ /**
86
+ * systemd writes `Environment=` as an ini-ish line, so a value carrying a
87
+ * space, a `#` or a quote has to be quoted and escaped or the unit silently
88
+ * parses wrong. A real PATH routinely contains spaces.
89
+ */
90
+ function envLine(key, value) {
91
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
92
+ return `Environment="${key}=${escaped}"`;
93
+ }
94
+ /** ExecStart takes a command line, so each argument is quoted the same way. */
95
+ function execArg(value) {
96
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
97
+ }
98
+ /**
99
+ * The unit file, as a string. Pure — this is what the tests assert on.
100
+ *
101
+ * Restart=always mirrors launchd's KeepAlive: the node is meant to be
102
+ * always-on, and a remote roll depends on it. A supervised `update_runtime`
103
+ * installs the new version and simply EXITS, and the supervisor starting the
104
+ * replacement is what makes that safe (see index.ts and isSupervised).
105
+ *
106
+ * RestartSec matches macOS's ThrottleInterval so a crash loop is one launch
107
+ * per 10s on both rather than a spin.
108
+ */
109
+ function buildUnit(spec) {
110
+ const exec = [spec.entry.file, ...spec.entry.args].map(execArg).join(' ');
111
+ const env = Object.entries(spec.env).map(([k, v]) => envLine(k, v)).join('\n');
112
+ const out = spec.logMode === 'append' ? `append:${spec.logPath}` : 'journal';
113
+ return `[Unit]
114
+ Description=+Ai Node
115
+ Documentation=https://node.add.ai
116
+ # The node's first act is to reach Supabase, so starting before the network is
117
+ # up just means a first heartbeat that fails and retries.
118
+ After=network-online.target
119
+ Wants=network-online.target
120
+
121
+ [Service]
122
+ Type=simple
123
+ ExecStart=${exec}
124
+ WorkingDirectory=${spec.workingDirectory}
125
+ Restart=always
126
+ RestartSec=10
127
+ ${env}
128
+ StandardOutput=${out}
129
+ StandardError=${out}
130
+
131
+ [Install]
132
+ WantedBy=default.target
133
+ `;
134
+ }
135
+ /** Pull ExecStart back out of a unit we wrote, for `status`. */
136
+ function parseUnitEntry(unit) {
137
+ const line = /^ExecStart=(.*)$/m.exec(unit);
138
+ if (!line)
139
+ return undefined;
140
+ const parts = [...line[1].matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)]
141
+ .map(m => (m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2]));
142
+ if (!parts.length)
143
+ return undefined;
144
+ return { file: parts[0], args: parts.slice(1) };
145
+ }
146
+ function run(file, args) {
147
+ try {
148
+ const out = (0, child_process_1.execFileSync)(file, args, {
149
+ encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'],
150
+ // systemctl --user needs to find its user manager. Over ssh with no
151
+ // seat, or from a daemon started by something that stripped the
152
+ // environment, XDG_RUNTIME_DIR is missing and every call fails with
153
+ // "Failed to connect to bus" — which reads like systemd is broken.
154
+ env: { ...process.env, ...runtimeDirEnv() },
155
+ });
156
+ return { ok: true, output: out.toString().trim() };
157
+ }
158
+ catch (err) {
159
+ const e = err;
160
+ const stderr = e.stderr ? e.stderr.toString().trim() : '';
161
+ return { ok: false, output: stderr || e.message || `${file} failed` };
162
+ }
163
+ }
164
+ function runtimeDirEnv() {
165
+ if (process.env.XDG_RUNTIME_DIR)
166
+ return {};
167
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
168
+ if (uid === null)
169
+ return {};
170
+ const guess = `/run/user/${uid}`;
171
+ return fs.existsSync(guess) ? { XDG_RUNTIME_DIR: guess } : {};
172
+ }
173
+ function systemctl(args) {
174
+ return run('systemctl', ['--user', ...args]);
175
+ }
176
+ /** systemd's major version, or null when it can't be read. */
177
+ function systemdVersion(probe = () => {
178
+ const r = run('systemctl', ['--version']);
179
+ return r.ok ? r.output : null;
180
+ }) {
181
+ const raw = probe();
182
+ if (!raw)
183
+ return null;
184
+ const m = /systemd\s+(\d+)/.exec(raw);
185
+ return m ? Number(m[1]) : null;
186
+ }
187
+ /**
188
+ * Is this machine actually running systemd?
189
+ *
190
+ * `/run/systemd/system` is the documented test for "booted with systemd" —
191
+ * plenty of containers and a few distros have the binaries present but no
192
+ * running manager, and there the unit would be written and never run.
193
+ */
194
+ function systemdAvailable(exists = p => fs.existsSync(p)) {
195
+ return exists('/run/systemd/system');
196
+ }
197
+ /**
198
+ * Normalise `systemctl is-active` output.
199
+ *
200
+ * It prints one word and exits non-zero for anything but `active`, so the exit
201
+ * code carries no information the word doesn't.
202
+ */
203
+ function normaliseActiveState(out) {
204
+ const word = (out || '').trim().split(/\s+/)[0] ?? '';
205
+ return word || 'unknown';
206
+ }
207
+ /** Sleep without going async. `startup enable` is a one-shot CLI command. */
208
+ function nap(ms) {
209
+ const buf = new Int32Array(new SharedArrayBuffer(4));
210
+ Atomics.wait(buf, 0, 0, ms);
211
+ }
212
+ /**
213
+ * Did the unit actually come up?
214
+ *
215
+ * The truth contract this codebase applies to harness logins, applied here:
216
+ * the exit code is a hint, the probe is the verdict. `systemctl enable --now`
217
+ * returns 0 for "request accepted", which is not the same as a running node —
218
+ * a unit can be accepted and then fail its very first ExecStart because a path
219
+ * is wrong or the runtime is missing, and reporting that as success is the
220
+ * failure mode that hurts most, because nothing looks wrong until an entity
221
+ * sits at "Getting ready…" forever.
222
+ *
223
+ * `activating` is given a few seconds rather than being called a failure; a
224
+ * unit that has genuinely failed reports `failed` immediately.
225
+ */
226
+ function waitUntilActive(probe = () => normaliseActiveState(systemctl(['is-active', autostart_1.SERVICE_NAME]).output), attempts = 8, sleepMs = 500, sleep = nap) {
227
+ let state = 'unknown';
228
+ for (let i = 0; i < attempts; i++) {
229
+ state = probe();
230
+ if (state === 'active' || state === 'failed')
231
+ return state;
232
+ sleep(sleepMs);
233
+ }
234
+ return state;
235
+ }
236
+ /** The last few log lines, for an error message that can be acted on. */
237
+ function recentLog() {
238
+ const r = run('journalctl', ['--user', '-u', autostart_1.SERVICE_NAME, '-n', '15', '--no-pager']);
239
+ return r.ok ? r.output : '';
240
+ }
241
+ function unsupported(reason) {
242
+ return { supported: false, enabled: false, supervisor: null, issues: [reason] };
243
+ }
244
+ function status() {
245
+ if (!systemdAvailable()) {
246
+ return unsupported('this machine is not running systemd, so there is nothing to start the node at boot');
247
+ }
248
+ if (!fs.existsSync(exports.UNIT_PATH)) {
249
+ return { supported: true, enabled: false, supervisor: 'systemd' };
250
+ }
251
+ // Deliberately no `systemctl` call here: status() rides every heartbeat, and
252
+ // the two files on disk are the whole answer. The unit says what to run; the
253
+ // default.target.wants symlink is how systemd records `enable`.
254
+ const unit = fs.readFileSync(exports.UNIT_PATH, 'utf8');
255
+ const entry = parseUnitEntry(unit);
256
+ const enabled = fs.existsSync(exports.WANTS_PATH);
257
+ const issues = [];
258
+ const program = entry?.args[0] ?? entry?.file;
259
+ if (program && !fs.existsSync(program)) {
260
+ issues.push(`the pinned program is gone (${program}) — re-run \`ainode startup enable\``);
261
+ }
262
+ // The one that actually bites on a server, and it is invisible until the
263
+ // first logout takes the node down with it.
264
+ if (enabled && !fs.existsSync(lingerPath())) {
265
+ issues.push('lingering is off, so this node stops when you log out — '
266
+ + `run \`sudo loginctl enable-linger ${currentUser()}\``);
267
+ }
268
+ return {
269
+ supported: true,
270
+ enabled,
271
+ supervisor: 'systemd',
272
+ entry,
273
+ path: exports.UNIT_PATH,
274
+ ...(issues.length ? { issues } : {}),
275
+ };
276
+ }
277
+ function enable(entry, env, logPath) {
278
+ const warnings = [];
279
+ if (!systemdAvailable()) {
280
+ return {
281
+ ok: false,
282
+ warnings,
283
+ error: 'this machine is not running systemd (no /run/systemd/system), so there is nothing to register with',
284
+ };
285
+ }
286
+ const version = systemdVersion();
287
+ // Unknown version is treated as old. Guessing "new" and being wrong means a
288
+ // unit systemd refuses to load at all, which is a worse failure than logs
289
+ // landing in the journal.
290
+ const logMode = version !== null && version >= 240 ? 'append' : 'journal';
291
+ if (logMode === 'journal') {
292
+ warnings.push(`systemd ${version ?? 'of unknown version'} is older than 240, so output goes to the journal `
293
+ + `instead of ${logPath} — read it with \`journalctl --user -u ${autostart_1.SERVICE_NAME} -f\``);
294
+ }
295
+ fs.mkdirSync(exports.USER_UNIT_DIR, { recursive: true });
296
+ fs.writeFileSync(exports.UNIT_PATH, buildUnit({
297
+ entry,
298
+ env,
299
+ logPath,
300
+ workingDirectory: os.homedir(),
301
+ logMode,
302
+ }), { mode: 0o644 });
303
+ const reload = systemctl(['daemon-reload']);
304
+ if (!reload.ok) {
305
+ return {
306
+ ok: false,
307
+ path: exports.UNIT_PATH,
308
+ warnings,
309
+ error: `systemctl --user could not be reached: ${reload.output}\n`
310
+ + 'If this is an ssh session on a server, the user manager may not be running — '
311
+ + `try \`sudo loginctl enable-linger ${currentUser()}\` and run this again.`,
312
+ };
313
+ }
314
+ // Linger BEFORE starting, so a node armed over ssh survives the disconnect
315
+ // that usually follows within the minute. Not fatal if it fails: the unit is
316
+ // still correct and still starts at the next login, so say what is missing
317
+ // rather than refusing the whole operation.
318
+ if (!fs.existsSync(lingerPath())) {
319
+ const linger = run('loginctl', ['enable-linger', currentUser()]);
320
+ if (!linger.ok) {
321
+ warnings.push('could not enable lingering, so this node will stop when you log out — '
322
+ + `run \`sudo loginctl enable-linger ${currentUser()}\` to fix that`);
323
+ }
324
+ }
325
+ const handover = (0, autostart_1.stopRunningDaemon)();
326
+ if (handover.kind === 'stuck') {
327
+ return {
328
+ ok: false,
329
+ path: exports.UNIT_PATH,
330
+ warnings,
331
+ error: `a node is already running here (pid ${handover.pid}) and did not stop within 40s — quit it, then run \`ainode startup enable\` again`,
332
+ };
333
+ }
334
+ if (handover.kind === 'self') {
335
+ // Deliberately `enable` without `--now`: starting the unit would spawn a
336
+ // second daemon, which cannot take the lockfile and would be restarted
337
+ // every 10s for as long as this one lives.
338
+ const armed = systemctl(['enable', autostart_1.SERVICE_NAME]);
339
+ if (!armed.ok) {
340
+ return { ok: false, path: exports.UNIT_PATH, warnings, error: `systemctl --user enable failed: ${armed.output}` };
341
+ }
342
+ warnings.push('this node is running right now, so systemd takes over when it next restarts');
343
+ return { ok: true, path: exports.UNIT_PATH, warnings };
344
+ }
345
+ if (handover.kind === 'stopped') {
346
+ warnings.push('stopped the node that was already running; systemd owns it now');
347
+ }
348
+ const armed = systemctl(['enable', '--now', autostart_1.SERVICE_NAME]);
349
+ if (!armed.ok) {
350
+ return { ok: false, path: exports.UNIT_PATH, warnings, error: `systemctl --user enable --now failed: ${armed.output}` };
351
+ }
352
+ // The verdict, not the hint. Everything above this line has only established
353
+ // that systemd accepted the unit.
354
+ const state = waitUntilActive();
355
+ if (state !== 'active') {
356
+ const log = recentLog();
357
+ return {
358
+ ok: false,
359
+ path: exports.UNIT_PATH,
360
+ warnings,
361
+ error: [
362
+ `the unit was registered but did not start (systemd reports it "${state}").`,
363
+ `Its definition is at ${exports.UNIT_PATH} and has been left in place so you can look.`,
364
+ '',
365
+ ` systemctl --user status ${autostart_1.SERVICE_NAME}`,
366
+ ` journalctl --user -u ${autostart_1.SERVICE_NAME} -n 50`,
367
+ ...(log ? ['', log] : []),
368
+ ].join('\n'),
369
+ };
370
+ }
371
+ return { ok: true, path: exports.UNIT_PATH, warnings };
372
+ }
373
+ function disable() {
374
+ if (!systemdAvailable())
375
+ return { ok: true }; // nothing was ever registered
376
+ // `disable --now` both stops the running unit and removes the symlink. This
377
+ // takes the node offline, which is what "stop starting at login" has to mean
378
+ // when the thing is running right now — same as the launchd backend.
379
+ systemctl(['disable', '--now', autostart_1.SERVICE_NAME]);
380
+ try {
381
+ fs.unlinkSync(exports.UNIT_PATH);
382
+ }
383
+ catch { /* already gone */ }
384
+ systemctl(['daemon-reload']);
385
+ // Linger is deliberately LEFT ON. It is a property of the user account, not
386
+ // of this node, and a user may well have other lingering units; turning it
387
+ // off here would reach outside what this feature owns.
388
+ return { ok: true };
389
+ }
390
+ exports.backend = { supervisor: 'systemd', status, enable, disable };
@@ -25,38 +25,4 @@ export declare function parsePlistEntry(xml: string): AutostartEntry | undefined
25
25
  /** Directories macOS gates behind user consent no background agent can give. */
26
26
  export declare function protectedPathReason(scriptPath: string, home?: string): string | null;
27
27
  export declare function protectedPathError(label: string, scriptPath: string): string;
28
- /**
29
- * Hand the node over to launchd.
30
- *
31
- * A daemon started by hand holds the lockfile, and `acquireLockfile()` throws
32
- * for anyone else. Bootstrapping the agent underneath it would therefore mean
33
- * launchd relaunching a job that exits 1 every ten seconds until the human
34
- * closes their terminal. So we ask the incumbent to stand down first — SIGTERM
35
- * runs its normal drain, so live entity turns finish rather than being cut.
36
- *
37
- * Except when the incumbent is US. `ainode startup enable` can be pressed from
38
- * inside the console of the very daemon it is arming, and signalling ourselves
39
- * there would kill the node the user is looking at. That case writes the plist
40
- * and stops: ~/Library/LaunchAgents is read at every login, so the change
41
- * lands at the next one without anybody being killed for it.
42
- */
43
- export type Handover =
44
- /** Nothing was running — safe to start the agent immediately. */
45
- {
46
- kind: 'none';
47
- }
48
- /** The running daemon is this very process. Write only, don't bootstrap. */
49
- | {
50
- kind: 'self';
51
- }
52
- /** Someone else's daemon stood down; the agent can take over now. */
53
- | {
54
- kind: 'stopped';
55
- }
56
- /** It was asked to stop and didn't. */
57
- | {
58
- kind: 'stuck';
59
- pid: number;
60
- };
61
- export declare function stopRunningDaemon(timeoutMs?: number, selfPid?: number): Handover;
62
28
  export declare const backend: AutostartBackend;
@@ -43,13 +43,11 @@ exports.buildPlist = buildPlist;
43
43
  exports.parsePlistEntry = parsePlistEntry;
44
44
  exports.protectedPathReason = protectedPathReason;
45
45
  exports.protectedPathError = protectedPathError;
46
- exports.stopRunningDaemon = stopRunningDaemon;
47
46
  const fs = __importStar(require("fs"));
48
47
  const os = __importStar(require("os"));
49
48
  const path = __importStar(require("path"));
50
49
  const child_process_1 = require("child_process");
51
50
  const autostart_1 = require("./autostart");
52
- const lockfile_1 = require("./lockfile");
53
51
  exports.PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${autostart_1.LABEL}.plist`);
54
52
  /** `<string>` bodies are the only place user data lands in the plist, and a
55
53
  * PATH entry with an `&` in it would otherwise produce invalid XML that
@@ -186,29 +184,6 @@ function launchctl(args) {
186
184
  return { ok: false, output: stderr || e.message || 'launchctl failed' };
187
185
  }
188
186
  }
189
- function stopRunningDaemon(timeoutMs = 40_000, selfPid = process.pid) {
190
- const lock = (0, lockfile_1.readLockfile)();
191
- if (!lock || !(0, lockfile_1.lockfileAlive)(lock))
192
- return { kind: 'none' };
193
- if (lock.pid === selfPid)
194
- return { kind: 'self' };
195
- try {
196
- process.kill(lock.pid, 'SIGTERM');
197
- }
198
- catch {
199
- return { kind: 'stopped' };
200
- }
201
- // Synchronous poll: `startup enable` is a one-shot CLI command, and making
202
- // the whole backend async to sleep 250ms would buy nothing.
203
- const deadline = Date.now() + timeoutMs;
204
- const nap = new Int32Array(new SharedArrayBuffer(4));
205
- while (Date.now() < deadline) {
206
- Atomics.wait(nap, 0, 0, 250);
207
- if (!(0, lockfile_1.lockfileAlive)((0, lockfile_1.readLockfile)()))
208
- return { kind: 'stopped' };
209
- }
210
- return { kind: 'stuck', pid: lock.pid };
211
- }
212
187
  function enable(entry, env, logPath, opts = {}) {
213
188
  const warnings = [];
214
189
  const script = entry.args[0] ?? entry.file;
@@ -232,7 +207,7 @@ function enable(entry, env, logPath, opts = {}) {
232
207
  // handover would sit there watching a lockfile that keeps coming back and
233
208
  // conclude the node is stuck. Booting out first makes the stop stick.
234
209
  launchctl(['bootout', `${domain()}/${autostart_1.LABEL}`]);
235
- const handover = stopRunningDaemon();
210
+ const handover = (0, autostart_1.stopRunningDaemon)();
236
211
  if (handover.kind === 'stuck') {
237
212
  return {
238
213
  ok: false,
@@ -1,4 +1,4 @@
1
- export type Supervisor = 'launchd' | 'schtasks';
1
+ export type Supervisor = 'launchd' | 'schtasks' | 'systemd';
2
2
  /** The exact command a supervisor is told to run. */
3
3
  export interface AutostartEntry {
4
4
  file: string;
@@ -43,6 +43,27 @@ export declare const LABEL = "ai.add.node";
43
43
  /** Task Scheduler's name for the same thing. Space-free: `schtasks` quoting
44
44
  * through cmd.exe is a known source of Windows breakage in this codebase. */
45
45
  export declare const TASK_NAME = "AiNode";
46
+ /** systemd's name for the same thing — a per-user unit, never a system one. */
47
+ export declare const SERVICE_NAME = "ainode.service";
48
+ export type Handover =
49
+ /** Nothing was running — safe to start the supervised job immediately. */
50
+ {
51
+ kind: 'none';
52
+ }
53
+ /** The running daemon is this very process. Write only, don't start. */
54
+ | {
55
+ kind: 'self';
56
+ }
57
+ /** Someone else's daemon stood down; the supervisor can take over now. */
58
+ | {
59
+ kind: 'stopped';
60
+ }
61
+ /** It was asked to stop and didn't. */
62
+ | {
63
+ kind: 'stuck';
64
+ pid: number;
65
+ };
66
+ export declare function stopRunningDaemon(timeoutMs?: number, selfPid?: number): Handover;
46
67
  export interface ResolvedEntry {
47
68
  entry?: AutostartEntry;
48
69
  error?: string;
package/dist/autostart.js CHANGED
@@ -50,7 +50,8 @@ var __importStar = (this && this.__importStar) || (function () {
50
50
  };
51
51
  })();
52
52
  Object.defineProperty(exports, "__esModule", { value: true });
53
- exports.TASK_NAME = exports.LABEL = void 0;
53
+ exports.SERVICE_NAME = exports.TASK_NAME = exports.LABEL = void 0;
54
+ exports.stopRunningDaemon = stopRunningDaemon;
54
55
  exports.resolveEntry = resolveEntry;
55
56
  exports.buildEnv = buildEnv;
56
57
  exports.status = status;
@@ -67,6 +68,31 @@ exports.LABEL = 'ai.add.node';
67
68
  /** Task Scheduler's name for the same thing. Space-free: `schtasks` quoting
68
69
  * through cmd.exe is a known source of Windows breakage in this codebase. */
69
70
  exports.TASK_NAME = 'AiNode';
71
+ /** systemd's name for the same thing — a per-user unit, never a system one. */
72
+ exports.SERVICE_NAME = 'ainode.service';
73
+ function stopRunningDaemon(timeoutMs = 40_000, selfPid = process.pid) {
74
+ const lock = (0, lockfile_1.readLockfile)();
75
+ if (!lock || !(0, lockfile_1.lockfileAlive)(lock))
76
+ return { kind: 'none' };
77
+ if (lock.pid === selfPid)
78
+ return { kind: 'self' };
79
+ try {
80
+ process.kill(lock.pid, 'SIGTERM');
81
+ }
82
+ catch {
83
+ return { kind: 'stopped' };
84
+ }
85
+ // Synchronous poll: `startup enable` is a one-shot CLI command, and making
86
+ // the whole backend async to sleep 250ms would buy nothing.
87
+ const deadline = Date.now() + timeoutMs;
88
+ const nap = new Int32Array(new SharedArrayBuffer(4));
89
+ while (Date.now() < deadline) {
90
+ Atomics.wait(nap, 0, 0, 250);
91
+ if (!(0, lockfile_1.lockfileAlive)((0, lockfile_1.readLockfile)()))
92
+ return { kind: 'stopped' };
93
+ }
94
+ return { kind: 'stuck', pid: lock.pid };
95
+ }
70
96
  /**
71
97
  * The command to write into the supervisor's definition.
72
98
  *
@@ -154,6 +180,10 @@ function backend(platform = process.platform) {
154
180
  // eslint-disable-next-line @typescript-eslint/no-require-imports
155
181
  return require('./autostart-win').backend;
156
182
  }
183
+ if (platform === 'linux') {
184
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
185
+ return require('./autostart-linux').backend;
186
+ }
157
187
  return null;
158
188
  }
159
189
  const UNSUPPORTED = { supported: false, enabled: false, supervisor: null };
@@ -199,13 +229,17 @@ function disable() {
199
229
  /** Is a supervisor going to restart us if we exit? Decides whether a remote
200
230
  * roll self-respawns or simply stands down and lets the supervisor act. */
201
231
  function isSupervised(state = status(), env = process.env) {
202
- // launchd is a live supervisor: KeepAlive brings the job straight back.
203
- // Task Scheduler is not its logon trigger has already fired, and the
204
- // 5-minute repeat is a backstop measured in minutes, not a restart path.
205
- // So only launchd earns exit-and-be-restarted.
206
- if (env.AINODE_SUPERVISOR === 'launchd')
232
+ // launchd (KeepAlive) and systemd (Restart=always) are live supervisors:
233
+ // they bring the job straight back, which is what makes a remote roll safe
234
+ // to perform by simply exiting.
235
+ //
236
+ // Task Scheduler is not one. Its logon trigger has already fired, and the
237
+ // 5-minute repeat is a backstop measured in minutes, not a restart path — so
238
+ // a Windows roll self-respawns instead (see index.ts).
239
+ const live = ['launchd', 'systemd'];
240
+ if (env.AINODE_SUPERVISOR && live.includes(env.AINODE_SUPERVISOR))
207
241
  return true;
208
- return state.supervisor === 'launchd' && state.enabled;
242
+ return live.includes(state.supervisor) && state.enabled;
209
243
  }
210
244
  /* ── human-readable state, shared by the CLI and the TUI ──────────────── */
211
245
  function describeStatus(s) {
@@ -1,3 +1,22 @@
1
+ /**
2
+ * The daemon's own npm prefix — `~/.ainode/tools`.
3
+ *
4
+ * Two ways a harness ends up in there rather than on the machine's global
5
+ * PATH. npmInstallGlobal falls back to it when the real global prefix is
6
+ * root-owned (the classic `/usr/local` Mac), and a node installed by
7
+ * node.add.ai points npm's builtin prefix at it deliberately, so the node
8
+ * carries its own runtime and its own CLIs and touches nothing else.
9
+ *
10
+ * win.ts's shared findCliBinary has searched here for a while, which is why
11
+ * codex/gemini/grok/kimi find their installs. This file predates that helper
12
+ * and keeps its own resolution on purpose — its POSIX probe runs an
13
+ * *interactive login* shell so a `claude` shell alias resolves — so it needs
14
+ * the same candidates added by hand rather than a refactor onto the helper.
15
+ *
16
+ * The legacy home is searched too: a box whose state dir was never migrated
17
+ * can still hold harnesses installed before the rename.
18
+ */
19
+ export declare function toolsPrefixCandidates(name: string, isWindows?: boolean, roots?: string[]): string[];
1
20
  /**
2
21
  * Find the Claude Code CLI binary. Mirrors the resolution strategy
3
22
  * the desktop app uses, so the runtime spawns the same Claude the
@@ -33,12 +33,40 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.toolsPrefixCandidates = toolsPrefixCandidates;
36
37
  exports.findClaudeBinary = findClaudeBinary;
37
38
  exports.encodeProjectPath = encodeProjectPath;
38
39
  const fs = __importStar(require("fs"));
39
40
  const path = __importStar(require("path"));
40
41
  const child_process_1 = require("child_process");
42
+ const paths_1 = require("./paths");
41
43
  const IS_WINDOWS = process.platform === 'win32';
44
+ /**
45
+ * The daemon's own npm prefix — `~/.ainode/tools`.
46
+ *
47
+ * Two ways a harness ends up in there rather than on the machine's global
48
+ * PATH. npmInstallGlobal falls back to it when the real global prefix is
49
+ * root-owned (the classic `/usr/local` Mac), and a node installed by
50
+ * node.add.ai points npm's builtin prefix at it deliberately, so the node
51
+ * carries its own runtime and its own CLIs and touches nothing else.
52
+ *
53
+ * win.ts's shared findCliBinary has searched here for a while, which is why
54
+ * codex/gemini/grok/kimi find their installs. This file predates that helper
55
+ * and keeps its own resolution on purpose — its POSIX probe runs an
56
+ * *interactive login* shell so a `claude` shell alias resolves — so it needs
57
+ * the same candidates added by hand rather than a refactor onto the helper.
58
+ *
59
+ * The legacy home is searched too: a box whose state dir was never migrated
60
+ * can still hold harnesses installed before the rename.
61
+ */
62
+ function toolsPrefixCandidates(name, isWindows = IS_WINDOWS, roots = [paths_1.RUNTIME_HOME, paths_1.LEGACY_RUNTIME_HOME]) {
63
+ return roots.flatMap(root => isWindows
64
+ ? [
65
+ path.join(root, 'tools', `${name}.exe`),
66
+ path.join(root, 'tools', `${name}.cmd`),
67
+ ]
68
+ : [path.join(root, 'tools', 'bin', name)]);
69
+ }
42
70
  /**
43
71
  * Find the Claude Code CLI binary. Mirrors the resolution strategy
44
72
  * the desktop app uses, so the runtime spawns the same Claude the
@@ -78,6 +106,7 @@ function findClaudeBinary() {
78
106
  path.join(localAppData, 'npm', 'claude.cmd'),
79
107
  path.join(userProfile, '.npm-global', 'claude.cmd'),
80
108
  path.join(userProfile, 'AppData', 'Roaming', 'npm', 'claude.cmd'),
109
+ ...toolsPrefixCandidates('claude'),
81
110
  ];
82
111
  for (const p of candidates) {
83
112
  try {
@@ -123,6 +152,7 @@ function findClaudeBinary() {
123
152
  `${home}/.bun/bin/claude`,
124
153
  `${home}/.volta/bin/claude`,
125
154
  `${home}/.nvm/current/bin/claude`,
155
+ ...toolsPrefixCandidates('claude'),
126
156
  ];
127
157
  for (const p of candidates) {
128
158
  try {
@@ -55,6 +55,7 @@ const store_1 = require("./store");
55
55
  const capabilities_1 = require("./capabilities");
56
56
  const heartbeat_1 = require("./heartbeat");
57
57
  const win_1 = require("./win");
58
+ const start_engine_1 = require("./desktop/start-engine");
58
59
  const harness_registry_1 = require("./harness-registry");
59
60
  const self_update_1 = require("./self-update");
60
61
  const autostart_1 = require("./autostart");
@@ -685,6 +686,63 @@ async function runDesktopCommand(cmd) {
685
686
  * the verdict, not the installer's exit code - Docker Desktop on macOS
686
687
  * installs the app but the daemon is not up until someone launches it, so a
687
688
  * clean exit does not mean a usable engine. */
689
+ /**
690
+ * Wake an engine that is installed but asleep.
691
+ *
692
+ * Distinct from runInstallEngine on purpose: there is nothing to install, so
693
+ * there is nothing to reboot for and — on macOS and Windows — no password to
694
+ * ask for. Only the Linux path needs the sudo round trip, and only when this
695
+ * daemon is not already root, so most machines see a button that just works.
696
+ */
697
+ async function runStartEngine(cmd) {
698
+ let log = '';
699
+ const onLog = (chunk) => {
700
+ log = (log + chunk).slice(-LOG_TAIL_CHARS);
701
+ void update(cmd.id, null, { log });
702
+ };
703
+ await update(cmd.id, 'running', { log });
704
+ try {
705
+ const engine = await (0, start_engine_1.startEngine)(onLog, async () => {
706
+ // Same parking pattern as the install: Studio collects the password the
707
+ // way it collects a harness login code.
708
+ await update(cmd.id, 'awaiting_input', { needs: 'sudo_password', log });
709
+ const deadline = Date.now() + INPUT_WAIT_MS;
710
+ while (Date.now() < deadline) {
711
+ const row = await pollRow(cmd.id);
712
+ if (!row)
713
+ break;
714
+ if (row.status === 'canceled')
715
+ return null;
716
+ const pw = row.input?.sudo_password;
717
+ if (pw) {
718
+ // Wipe it from the row the moment we hold it.
719
+ const t = token();
720
+ if (t) {
721
+ try {
722
+ await (0, supabase_client_1.rpc)('runtime_command_clear_input', { p_token: t, p_id: cmd.id });
723
+ }
724
+ catch { /* best effort — it still must not reach the log */ }
725
+ }
726
+ await update(cmd.id, 'running', { needs: null, log });
727
+ return pw;
728
+ }
729
+ await new Promise(r => setTimeout(r, INPUT_POLL_MS));
730
+ }
731
+ return null;
732
+ });
733
+ // The provider caches "no engine"; without this the next desktop command
734
+ // would still believe the machine cannot host anything.
735
+ (0, docker_1.resetProviderCache)();
736
+ if (!engine) {
737
+ await update(cmd.id, 'failed', { log }, 'The engine did not start. It may still be booting — this machine reports in every 30 seconds.');
738
+ return;
739
+ }
740
+ await update(cmd.id, 'completed', { log });
741
+ }
742
+ catch (e) {
743
+ await update(cmd.id, 'failed', { log }, e instanceof Error ? e.message : String(e));
744
+ }
745
+ }
688
746
  async function runInstallEngine(cmd) {
689
747
  let log = '';
690
748
  const onLog = (chunk) => {
@@ -769,6 +827,10 @@ async function execute(cmd) {
769
827
  await runInstallEngine(cmd);
770
828
  return;
771
829
  }
830
+ if (cmd.kind === 'start_engine') {
831
+ await runStartEngine(cmd);
832
+ return;
833
+ }
772
834
  if (DESKTOP_KINDS.includes(cmd.kind)) {
773
835
  await runDesktopCommand(cmd);
774
836
  return;
@@ -0,0 +1,44 @@
1
+ import { EngineInfo } from './provider';
2
+ export interface StartPlan {
3
+ /** Null when this platform has no way we can drive without a password. */
4
+ file: string | null;
5
+ args: string[];
6
+ /** What a human would run, shown when we cannot do it ourselves. */
7
+ manual: string;
8
+ needsRoot: boolean;
9
+ /** Launch and walk away — Docker Desktop is a GUI app that outlives us. */
10
+ detached: boolean;
11
+ }
12
+ /**
13
+ * Pure, so the choice is testable on any machine.
14
+ *
15
+ * @param programFiles %ProgramFiles% on Windows. Passed in rather than read
16
+ * from the environment so the Windows path is testable
17
+ * from a Mac, which is where it is usually written.
18
+ */
19
+ export declare function startPlan(platform: NodeJS.Platform, isRoot: boolean, programFiles?: string): StartPlan;
20
+ /** Build the argv for a privileged start. Password on stdin, never argv —
21
+ * argv is world-readable through /proc. */
22
+ export declare function sudoStartArgv(): {
23
+ file: string;
24
+ args: string[];
25
+ };
26
+ export declare function runningAsRoot(): boolean;
27
+ /**
28
+ * How long to wait for the daemon after the launch command returns.
29
+ *
30
+ * Docker Desktop is not "started" when its process exists — it boots a VM.
31
+ * On a cold Windows machine that is routinely a minute or more, so a short
32
+ * timeout would report failure on a machine that was about to work.
33
+ */
34
+ export declare const START_TIMEOUT_MS = 150000;
35
+ /** Poll until the engine actually answers, or we run out of patience. */
36
+ export declare function waitForEngine(onLog: (s: string) => void, timeoutMs?: number, probe?: () => Promise<EngineInfo | null>): Promise<EngineInfo | null>;
37
+ /**
38
+ * Ask this machine's engine to start.
39
+ *
40
+ * Returns the engine once it answers, or null with the reason logged. The
41
+ * password callback is only ever invoked on Linux, and only when this process
42
+ * is not already root.
43
+ */
44
+ export declare function startEngine(onLog: (chunk: string) => void, askPassword: () => Promise<string | null>): Promise<EngineInfo | null>;
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.START_TIMEOUT_MS = void 0;
4
+ exports.startPlan = startPlan;
5
+ exports.sudoStartArgv = sudoStartArgv;
6
+ exports.runningAsRoot = runningAsRoot;
7
+ exports.waitForEngine = waitForEngine;
8
+ exports.startEngine = startEngine;
9
+ // Start a container engine that is installed but asleep.
10
+ //
11
+ // A machine with Docker installed and closed is the single most common reason
12
+ // the Desktops tab is dark, and it is the one case where the fix is genuinely
13
+ // one command. Reinstalling would be wrong (it is already there), and telling
14
+ // someone to walk to the machine is only honest while nothing here can do it.
15
+ //
16
+ // Deliberately separate from install-engine: installing is a slow, privileged,
17
+ // possibly-rebooting operation, and starting is a fast unprivileged one on the
18
+ // two desktop platforms. Sharing a code path would have meant the start button
19
+ // inheriting the install button's sudo round trip on machines that never need
20
+ // it.
21
+ const child_process_1 = require("child_process");
22
+ const win_1 = require("../win");
23
+ const engine_1 = require("./engine");
24
+ /**
25
+ * Pure, so the choice is testable on any machine.
26
+ *
27
+ * @param programFiles %ProgramFiles% on Windows. Passed in rather than read
28
+ * from the environment so the Windows path is testable
29
+ * from a Mac, which is where it is usually written.
30
+ */
31
+ function startPlan(platform, isRoot, programFiles) {
32
+ if (platform === 'darwin') {
33
+ // `open -a` returns as soon as the app is launching; the daemon comes up
34
+ // a while later, which is why the caller polls rather than trusting exit 0.
35
+ return {
36
+ file: 'open', args: ['-a', 'Docker'],
37
+ manual: 'open -a Docker', needsRoot: false, detached: false,
38
+ };
39
+ }
40
+ if (platform === 'win32') {
41
+ // Spawned directly rather than through `cmd /c start`: quoting a path with
42
+ // spaces through cmd is the kind of thing that works until someone's
43
+ // Windows is installed on a different drive.
44
+ const base = programFiles || 'C:\\Program Files';
45
+ return {
46
+ file: `${base}\\Docker\\Docker\\Docker Desktop.exe`, args: [],
47
+ manual: 'Start Docker Desktop from the Start menu',
48
+ needsRoot: false, detached: true,
49
+ };
50
+ }
51
+ // Linux: the daemon is a system service, so this needs root the same way
52
+ // installing does — and takes the same password round trip.
53
+ return {
54
+ file: isRoot ? 'sh' : null,
55
+ args: ['-c', 'systemctl start docker'],
56
+ manual: 'sudo systemctl start docker',
57
+ needsRoot: true, detached: false,
58
+ };
59
+ }
60
+ /** Build the argv for a privileged start. Password on stdin, never argv —
61
+ * argv is world-readable through /proc. */
62
+ function sudoStartArgv() {
63
+ return { file: 'sudo', args: ['-S', '-p', '', 'sh', '-c', 'systemctl start docker'] };
64
+ }
65
+ function runningAsRoot() {
66
+ return typeof process.getuid === 'function' && process.getuid() === 0;
67
+ }
68
+ /**
69
+ * How long to wait for the daemon after the launch command returns.
70
+ *
71
+ * Docker Desktop is not "started" when its process exists — it boots a VM.
72
+ * On a cold Windows machine that is routinely a minute or more, so a short
73
+ * timeout would report failure on a machine that was about to work.
74
+ */
75
+ exports.START_TIMEOUT_MS = 150_000;
76
+ const POLL_MS = 3_000;
77
+ /** Poll until the engine actually answers, or we run out of patience. */
78
+ async function waitForEngine(onLog, timeoutMs = exports.START_TIMEOUT_MS, probe = engine_1.detectEngine) {
79
+ const deadline = Date.now() + timeoutMs;
80
+ let dots = 0;
81
+ while (Date.now() < deadline) {
82
+ const e = await probe();
83
+ if (e && e.running)
84
+ return e;
85
+ // A line every few seconds, so the log looks alive during a long VM boot
86
+ // rather than looking like it hung.
87
+ if (dots++ % 4 === 0) {
88
+ onLog(` still waiting for the engine… ${Math.round((Date.now() - (deadline - timeoutMs)) / 1000)}s\n`);
89
+ }
90
+ await new Promise(r => setTimeout(r, POLL_MS));
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Ask this machine's engine to start.
96
+ *
97
+ * Returns the engine once it answers, or null with the reason logged. The
98
+ * password callback is only ever invoked on Linux, and only when this process
99
+ * is not already root.
100
+ */
101
+ async function startEngine(onLog, askPassword) {
102
+ const plan = startPlan(process.platform, runningAsRoot(), process.env.ProgramFiles);
103
+ if (!plan.file) {
104
+ // Linux, not root. Ask for the password and go through sudo.
105
+ const pw = await askPassword();
106
+ if (!pw) {
107
+ onLog(`\nNo password given. Start it on the machine instead:\n ${plan.manual}\n`);
108
+ return null;
109
+ }
110
+ const { file, args } = sudoStartArgv();
111
+ const code = await run(file, args, onLog, false, pw);
112
+ if (code !== 0) {
113
+ onLog(`\nThat did not work. On the machine:\n ${plan.manual}\n`);
114
+ return null;
115
+ }
116
+ }
117
+ else {
118
+ onLog(`Starting ${plan.file}…\n`);
119
+ const code = await run(plan.file, plan.args, onLog, plan.detached);
120
+ // A detached GUI launch has no meaningful exit code, and on Windows the
121
+ // exe returns immediately. Only a hard spawn failure is worth reporting,
122
+ // and that surfaces as -1 below.
123
+ if (code === -1) {
124
+ onLog(`\nCould not launch it. On the machine:\n ${plan.manual}\n`);
125
+ return null;
126
+ }
127
+ }
128
+ onLog('Waiting for the engine to answer…\n');
129
+ const engine = await waitForEngine(onLog);
130
+ if (!engine) {
131
+ onLog(`\nIt did not come up in time. It may still be starting — this page turns on by itself when it does.\n`);
132
+ return null;
133
+ }
134
+ onLog(`\n${engine.id} is ready.\n`);
135
+ return engine;
136
+ }
137
+ function run(binary, args, onLog, detached, stdin) {
138
+ return new Promise(resolve => {
139
+ try {
140
+ const inv = (0, win_1.resolveCliInvocation)(binary, args);
141
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
142
+ env: process.env,
143
+ windowsHide: true,
144
+ detached,
145
+ stdio: stdin ? ['pipe', 'pipe', 'pipe'] : undefined,
146
+ });
147
+ if (stdin && child.stdin) {
148
+ child.stdin.write(`${stdin}\n`);
149
+ child.stdin.end();
150
+ }
151
+ child.stdout?.on('data', b => onLog(b.toString('utf8')));
152
+ child.stderr?.on('data', b => onLog(b.toString('utf8')));
153
+ child.on('error', () => resolve(-1));
154
+ if (detached) {
155
+ // Let it outlive this process; Docker Desktop must not die with the
156
+ // daemon that launched it.
157
+ child.unref();
158
+ resolve(0);
159
+ return;
160
+ }
161
+ child.on('exit', code => resolve(code ?? -1));
162
+ }
163
+ catch {
164
+ resolve(-1);
165
+ }
166
+ });
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.19.0",
3
+ "version": "0.20.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": [