@addai/node 0.11.2 → 0.12.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,139 @@
1
+ import { type HarnessId } from './harness-registry';
2
+ import { type LaunchMode } from './self-update';
3
+ /**
4
+ * A stable per-node offset into the hour, derived from the daemon token.
5
+ *
6
+ * The fleet has to be spread across the hour rather than converging on :00 —
7
+ * with no soak window, the stagger is the ONLY thing limiting how fast a bad
8
+ * publish reaches everyone. Deriving it from the token (rather than random)
9
+ * means a node keeps its slot across restarts, including the restart an
10
+ * auto-roll itself causes, which would otherwise re-converge every rolled
11
+ * node onto the same minute.
12
+ */
13
+ export declare function tickOffsetMs(token: string): number;
14
+ /**
15
+ * Milliseconds until this node's next slot, measured against the wall clock
16
+ * rather than against boot time — so a daemon that restarts twice an hour
17
+ * keeps firing on its own slot instead of drifting to a new one each time.
18
+ */
19
+ export declare function msUntilNextTick(nowMs: number, offsetMs: number): number;
20
+ export type SkipReason = 'disabled' | 'busy' | 'breaker';
21
+ /**
22
+ * The guards, in the order they are cheapest to check.
23
+ *
24
+ * `busy` is a skip, NOT a drain: an auto-update is elective and there is
25
+ * always another hour, whereas draining would take the node out of rotation
26
+ * for up to ten minutes every time the tick happened to land mid-turn. A node
27
+ * that is never idle never auto-updates, and gets rolled by hand.
28
+ */
29
+ export declare function shouldRun(s: {
30
+ enabled: boolean;
31
+ busy: boolean;
32
+ breakerOpen: boolean;
33
+ }): SkipReason | null;
34
+ export interface HarnessUpgrade {
35
+ id: HarnessId;
36
+ npmPackage: string;
37
+ }
38
+ /**
39
+ * Which harnesses this tick will `npm i -g …@latest`.
40
+ *
41
+ * Two rules, both deliberate:
42
+ * - npm-able only. grok's `npmPackage` is null (xAI ships its own installer),
43
+ * so it is never touched here — a truthful skip beats a fake upgrade.
44
+ * - installed only. The tick upgrades; it does not provision. Installing a
45
+ * harness nobody asked for is a different feature, and one with a cost
46
+ * (disk, a login prompt that never comes) the user never opted into.
47
+ */
48
+ export declare function planHarnessUpgrades(installed: HarnessId[]): HarnessUpgrade[];
49
+ export interface RollDecision {
50
+ roll: boolean;
51
+ /** Why not, when roll is false. Logged, and surfaced in capabilities. */
52
+ reason?: string;
53
+ }
54
+ /**
55
+ * Whether to roll the daemon itself.
56
+ *
57
+ * A source checkout is refused for the same reason a remote version bump is
58
+ * (see planRoll): its version comes from the working tree, so installing and
59
+ * restarting would come back up on exactly the same bits — a no-op wearing a
60
+ * success. Harnesses on such a node are still upgraded; only this half is off.
61
+ */
62
+ export declare function planAutoRoll(current: string, latest: string | null, mode: LaunchMode): RollDecision;
63
+ export interface HarnessOutcome {
64
+ from?: string;
65
+ to?: string;
66
+ /** Where the binary resolves NOW. You chose npm-for-every-npm-able-harness,
67
+ * so on a machine whose Claude Code came from Anthropic's native installer
68
+ * npm has just put a second copy somewhere else — possibly shadowing the
69
+ * first. The path is what makes that visible instead of silent. */
70
+ path?: string;
71
+ error?: string;
72
+ }
73
+ export interface AutoUpdateState {
74
+ enabled: boolean;
75
+ last_checked_at?: string;
76
+ last_result?: 'ok' | 'skipped' | 'failed';
77
+ skipped_reason?: SkipReason;
78
+ /** Set when a roll actually happened — written by the daemon that came up
79
+ * after it, from the restart handoff. */
80
+ runtime?: {
81
+ from: string;
82
+ to: string;
83
+ wanted?: string;
84
+ } | null;
85
+ harnesses?: Record<string, HarnessOutcome>;
86
+ error?: string;
87
+ }
88
+ /** Read by capabilities.ts so the whole picture rides the heartbeat the AiNode
89
+ * page already reads — no new RPC, no new column, no polling. */
90
+ export declare function autoUpdateState(): AutoUpdateState;
91
+ /** Called by index.ts on boot when the handoff it consumed had no command id,
92
+ * i.e. the roll was ours rather than a button press. */
93
+ export declare function recordAutoRoll(r: {
94
+ from: string;
95
+ to: string;
96
+ wanted?: string;
97
+ }): void;
98
+ /** Only the part of a capabilities probe this module reads. Kept structural
99
+ * rather than importing CapabilitiesShape, which would put capabilities.ts on
100
+ * both ends of an import cycle (it reads autoUpdateState from here). */
101
+ type ProbeShape = Partial<Record<HarnessId, {
102
+ version?: string;
103
+ available?: boolean;
104
+ }>>;
105
+ export interface AutoUpdateDeps {
106
+ /** The daemon token — only used to derive this node's slot. */
107
+ token: () => string | null;
108
+ /** True while this node holds a child process for a live run. */
109
+ isBusy: () => boolean;
110
+ /** The daemon-wide Supabase circuit breaker. */
111
+ breakerOpen: () => boolean;
112
+ /** Whether the server says auto-update is on for this node. */
113
+ enabled: () => boolean;
114
+ /** Full capabilities probe — used for before/after harness versions. */
115
+ probe: () => Promise<ProbeShape>;
116
+ /** Push a heartbeat so Studio sees the new versions without waiting. */
117
+ beat: () => Promise<void>;
118
+ /** The same closure `update_runtime` uses. No command id: nobody asked. */
119
+ roll: (opts: {
120
+ version: string;
121
+ }) => Promise<void>;
122
+ /** This daemon's own version. */
123
+ version: string;
124
+ /** `npm i -g <spec>`; null on success. Injectable so the orchestration below
125
+ * — the before/after diff, the busy re-check — can be tested without
126
+ * actually installing anything. */
127
+ install?: (spec: string) => Promise<string | null>;
128
+ /** What npm calls latest for a package, or null if we couldn't ask. */
129
+ latest?: (pkg: string) => Promise<string | null>;
130
+ /** How this daemon was launched. Injectable because under a test runner
131
+ * argv[1] is the runner, which would read as `source` and mask the roll. */
132
+ mode?: () => LaunchMode;
133
+ }
134
+ /** One pass. Exported so `ainode` can be made to do it on demand later, and
135
+ * so a test can drive it without waiting an hour. */
136
+ export declare function runOnce(d: AutoUpdateDeps): Promise<void>;
137
+ export declare function start(d: AutoUpdateDeps): void;
138
+ export declare function stop(): void;
139
+ export {};
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ // Hourly auto-update — this node keeps itself, and its harnesses, current.
3
+ //
4
+ // Until now a node only moved when somebody clicked. `update_runtime` issued
5
+ // by hand from Entity Studio was the ONLY thing that ever rolled a daemon,
6
+ // which is how the fleet came to sit on 0.2.51/0.2.52/0.2.53 for days after
7
+ // 0.2.55 shipped. Harnesses were worse: Studio offers Install only while a
8
+ // harness reads `missing`, so once Claude Code was on a node it could never
9
+ // be upgraded from anywhere.
10
+ //
11
+ // The schedule lives here rather than in a server-side cron because a node
12
+ // asleep, behind NAT, or between pairings then needs no special case — the
13
+ // daemon already wakes on its own for reclaim, diskguard and sleep detection.
14
+ //
15
+ // Everything the tick needs from the rest of the daemon is INJECTED by
16
+ // index.ts (roll, probe, beat, isBusy, isEnabled). That keeps this module's
17
+ // imports down to the registry and the npm helpers, so the decisions below
18
+ // are testable without booting a daemon — and avoids the
19
+ // auto-update → heartbeat → capabilities → auto-update cycle that importing
20
+ // them directly would create.
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.tickOffsetMs = tickOffsetMs;
23
+ exports.msUntilNextTick = msUntilNextTick;
24
+ exports.shouldRun = shouldRun;
25
+ exports.planHarnessUpgrades = planHarnessUpgrades;
26
+ exports.planAutoRoll = planAutoRoll;
27
+ exports.autoUpdateState = autoUpdateState;
28
+ exports.recordAutoRoll = recordAutoRoll;
29
+ exports.runOnce = runOnce;
30
+ exports.start = start;
31
+ exports.stop = stop;
32
+ const harness_registry_1 = require("./harness-registry");
33
+ const self_update_1 = require("./self-update");
34
+ const HOUR_MS = 60 * 60 * 1000;
35
+ /** Nothing auto-rolls in the first few minutes of a boot. A node that just
36
+ * came up may BE the result of a roll (or of one that went badly), and
37
+ * rolling again immediately would turn a bad publish into a restart loop. */
38
+ const BOOT_GRACE_MS = 5 * 60 * 1000;
39
+ /* ── when ─────────────────────────────────────────────────────────────── */
40
+ /**
41
+ * A stable per-node offset into the hour, derived from the daemon token.
42
+ *
43
+ * The fleet has to be spread across the hour rather than converging on :00 —
44
+ * with no soak window, the stagger is the ONLY thing limiting how fast a bad
45
+ * publish reaches everyone. Deriving it from the token (rather than random)
46
+ * means a node keeps its slot across restarts, including the restart an
47
+ * auto-roll itself causes, which would otherwise re-converge every rolled
48
+ * node onto the same minute.
49
+ */
50
+ function tickOffsetMs(token) {
51
+ // FNV-1a. Not cryptographic — it only has to spread evenly and be stable.
52
+ let h = 0x811c9dc5;
53
+ for (let i = 0; i < token.length; i++) {
54
+ h ^= token.charCodeAt(i);
55
+ h = Math.imul(h, 0x01000193) >>> 0;
56
+ }
57
+ return h % HOUR_MS;
58
+ }
59
+ /**
60
+ * Milliseconds until this node's next slot, measured against the wall clock
61
+ * rather than against boot time — so a daemon that restarts twice an hour
62
+ * keeps firing on its own slot instead of drifting to a new one each time.
63
+ */
64
+ function msUntilNextTick(nowMs, offsetMs) {
65
+ const since = (((nowMs - offsetMs) % HOUR_MS) + HOUR_MS) % HOUR_MS;
66
+ const wait = HOUR_MS - since;
67
+ // since === 0 lands exactly on the slot; wait a full hour rather than
68
+ // firing twice for the same slot.
69
+ return wait === 0 ? HOUR_MS : wait;
70
+ }
71
+ /**
72
+ * The guards, in the order they are cheapest to check.
73
+ *
74
+ * `busy` is a skip, NOT a drain: an auto-update is elective and there is
75
+ * always another hour, whereas draining would take the node out of rotation
76
+ * for up to ten minutes every time the tick happened to land mid-turn. A node
77
+ * that is never idle never auto-updates, and gets rolled by hand.
78
+ */
79
+ function shouldRun(s) {
80
+ if (!s.enabled)
81
+ return 'disabled';
82
+ if (s.breakerOpen)
83
+ return 'breaker';
84
+ if (s.busy)
85
+ return 'busy';
86
+ return null;
87
+ }
88
+ /**
89
+ * Which harnesses this tick will `npm i -g …@latest`.
90
+ *
91
+ * Two rules, both deliberate:
92
+ * - npm-able only. grok's `npmPackage` is null (xAI ships its own installer),
93
+ * so it is never touched here — a truthful skip beats a fake upgrade.
94
+ * - installed only. The tick upgrades; it does not provision. Installing a
95
+ * harness nobody asked for is a different feature, and one with a cost
96
+ * (disk, a login prompt that never comes) the user never opted into.
97
+ */
98
+ function planHarnessUpgrades(installed) {
99
+ const present = new Set(installed);
100
+ return harness_registry_1.HARNESS_IDS
101
+ .filter(id => present.has(id) && harness_registry_1.HARNESSES[id].npmPackage !== null)
102
+ .map(id => ({ id, npmPackage: harness_registry_1.HARNESSES[id].npmPackage }));
103
+ }
104
+ /**
105
+ * Whether to roll the daemon itself.
106
+ *
107
+ * A source checkout is refused for the same reason a remote version bump is
108
+ * (see planRoll): its version comes from the working tree, so installing and
109
+ * restarting would come back up on exactly the same bits — a no-op wearing a
110
+ * success. Harnesses on such a node are still upgraded; only this half is off.
111
+ */
112
+ function planAutoRoll(current, latest, mode) {
113
+ if (mode === 'source')
114
+ return { roll: false, reason: 'source checkout — pull and rebuild it there' };
115
+ if (!latest)
116
+ return { roll: false, reason: 'could not read the latest published version' };
117
+ if (latest === current)
118
+ return { roll: false, reason: 'already latest' };
119
+ return { roll: true };
120
+ }
121
+ let state = { enabled: true };
122
+ let isEnabled = () => true;
123
+ /** Read by capabilities.ts so the whole picture rides the heartbeat the AiNode
124
+ * page already reads — no new RPC, no new column, no polling. */
125
+ function autoUpdateState() {
126
+ return { ...state, enabled: isEnabled() };
127
+ }
128
+ /** Called by index.ts on boot when the handoff it consumed had no command id,
129
+ * i.e. the roll was ours rather than a button press. */
130
+ function recordAutoRoll(r) {
131
+ state = { ...state, runtime: r, last_result: 'ok', last_checked_at: new Date().toISOString() };
132
+ }
133
+ let timer = null;
134
+ let stopped = false;
135
+ let deps = null;
136
+ function harnessVersion(probe, id) {
137
+ const p = probe[id];
138
+ return p?.available ? p.version : undefined;
139
+ }
140
+ function installedHarnesses(probe) {
141
+ return harness_registry_1.HARNESS_IDS.filter(id => probe[id]?.available === true);
142
+ }
143
+ /** One pass. Exported so `ainode` can be made to do it on demand later, and
144
+ * so a test can drive it without waiting an hour. */
145
+ async function runOnce(d) {
146
+ const skip = shouldRun({ enabled: d.enabled(), busy: d.isBusy(), breakerOpen: d.breakerOpen() });
147
+ if (skip) {
148
+ state = { ...state, last_checked_at: new Date().toISOString(), last_result: 'skipped', skipped_reason: skip };
149
+ if (skip !== 'breaker')
150
+ console.log(`[auto-update] skipped — ${skip === 'busy' ? 'the node is mid-run' : 'turned off for this node'}`);
151
+ return;
152
+ }
153
+ const checkedAt = new Date().toISOString();
154
+ const harnesses = {};
155
+ let failed;
156
+ const install = d.install ?? self_update_1.npmInstallGlobal;
157
+ const latestOf = d.latest ?? self_update_1.npmLatestVersion;
158
+ /* ── harnesses first — nothing restarts, so this half always lands ──── */
159
+ try {
160
+ const before = await d.probe();
161
+ const upgrades = planHarnessUpgrades(installedHarnesses(before));
162
+ for (const u of upgrades) {
163
+ const from = harnessVersion(before, u.id);
164
+ const err = await install(`${u.npmPackage}@latest`);
165
+ harnesses[u.id] = { from, ...(err ? { error: err } : {}) };
166
+ if (err)
167
+ console.error(`[auto-update] ${u.id}: ${err}`);
168
+ }
169
+ if (upgrades.length > 0) {
170
+ // The finders cached their pre-upgrade lookups; a probe through stale
171
+ // caches would report the OLD version and hide the upgrade entirely.
172
+ (0, harness_registry_1.resetBinaryCaches)();
173
+ const after = await d.probe();
174
+ for (const u of upgrades) {
175
+ const to = harnessVersion(after, u.id);
176
+ const path = harness_registry_1.HARNESSES[u.id].findBinary() ?? undefined;
177
+ harnesses[u.id] = { ...harnesses[u.id], to, path };
178
+ if (harnesses[u.id].from !== to) {
179
+ console.log(`[auto-update] ${u.id} ${harnesses[u.id].from ?? '?'} → ${to ?? '?'} (${path ?? 'not resolvable'})`);
180
+ }
181
+ }
182
+ await d.beat();
183
+ }
184
+ }
185
+ catch (err) {
186
+ failed = `harness upgrade: ${err.message}`;
187
+ console.error(`[auto-update] ${failed}`);
188
+ }
189
+ state = {
190
+ ...state,
191
+ last_checked_at: checkedAt,
192
+ last_result: failed ? 'failed' : 'ok',
193
+ skipped_reason: undefined,
194
+ harnesses,
195
+ error: failed,
196
+ };
197
+ /* ── then the daemon itself ─────────────────────────────────────────── */
198
+ const mode = d.mode ? d.mode() : (0, self_update_1.detectLaunchMode)(process.argv[1] ?? '');
199
+ const latest = mode === 'source' ? null : await latestOf(self_update_1.PACKAGE_NAME);
200
+ const decision = planAutoRoll(d.version, latest, mode);
201
+ if (!decision.roll) {
202
+ if (decision.reason !== 'already latest')
203
+ console.log(`[auto-update] not rolling — ${decision.reason}`);
204
+ return;
205
+ }
206
+ // Re-check busy: the harness upgrades above can take minutes, and a run may
207
+ // well have arrived while npm was working. Rolling now would guillotine it
208
+ // at the drain ceiling for no reason — there is another hour.
209
+ if (d.isBusy()) {
210
+ state = { ...state, last_result: 'skipped', skipped_reason: 'busy' };
211
+ console.log('[auto-update] a run arrived during the harness pass — leaving the roll for next hour');
212
+ return;
213
+ }
214
+ console.log(`[auto-update] rolling ${d.version} → ${latest}`);
215
+ try {
216
+ // This does not return: the roll drains, hands over, and exits. The
217
+ // daemon that comes up completes the story from the handoff file.
218
+ await d.roll({ version: latest });
219
+ }
220
+ catch (err) {
221
+ state = { ...state, last_result: 'failed', error: `roll: ${err.message}` };
222
+ console.error(`[auto-update] roll failed — staying on ${d.version}:`, err.message);
223
+ }
224
+ }
225
+ function schedule(firstRun) {
226
+ if (stopped || !deps)
227
+ return;
228
+ const token = deps.token() ?? '';
229
+ const offset = tickOffsetMs(token);
230
+ let wait = msUntilNextTick(Date.now(), offset);
231
+ if (firstRun)
232
+ wait = Math.max(wait, BOOT_GRACE_MS);
233
+ timer = setTimeout(() => {
234
+ void (async () => {
235
+ try {
236
+ await runOnce(deps);
237
+ }
238
+ catch (err) {
239
+ console.error('[auto-update] tick crashed:', err.message);
240
+ }
241
+ schedule(false);
242
+ })();
243
+ }, wait);
244
+ // Never keep the process alive just to auto-update.
245
+ timer.unref?.();
246
+ }
247
+ function start(d) {
248
+ if (timer || stopped)
249
+ return;
250
+ deps = d;
251
+ isEnabled = d.enabled;
252
+ state = { ...state, enabled: d.enabled() };
253
+ schedule(true);
254
+ }
255
+ function stop() {
256
+ stopped = true;
257
+ if (timer) {
258
+ clearTimeout(timer);
259
+ timer = null;
260
+ }
261
+ }
@@ -1,4 +1,6 @@
1
1
  import { type AutostartState } from './autostart';
2
+ import { type AutoUpdateState } from './auto-update';
3
+ import { type MachineSample } from './machine-metrics';
2
4
  interface AuthShape {
3
5
  authed: boolean;
4
6
  account?: string;
@@ -42,6 +44,13 @@ interface CapabilitiesShape {
42
44
  * rather than a column of its own — same channel Studio already reads the
43
45
  * harness grid from. */
44
46
  autostart?: AutostartState;
47
+ /** CPU / memory / disk / runs at the moment of this probe. The AiNode page
48
+ * has read this key since it was written; nothing produced it until now. */
49
+ machine?: MachineSample;
50
+ /** What the hourly auto-update last did — versions moved, skips, failures.
51
+ * Same channel, same reason: without it the AiNode page is somewhere the
52
+ * version silently changes with no explanation. */
53
+ auto_update?: AutoUpdateState;
45
54
  }
46
55
  /** Codex's credential store: $CODEX_HOME (or ~/.codex) + /auth.json. */
47
56
  export declare function codexHome(): string;
@@ -53,6 +53,8 @@ const codex_binary_1 = require("./codex-binary");
53
53
  const win_1 = require("./win");
54
54
  const harness_registry_1 = require("./harness-registry");
55
55
  const autostart_1 = require("./autostart");
56
+ const auto_update_1 = require("./auto-update");
57
+ const machine_metrics_1 = require("./machine-metrics");
56
58
  function readDaemonVersion() {
57
59
  try {
58
60
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -398,6 +400,8 @@ async function probeCapabilities() {
398
400
  return {
399
401
  daemon_version: readDaemonVersion(),
400
402
  autostart: (0, autostart_1.status)(),
403
+ auto_update: (0, auto_update_1.autoUpdateState)(),
404
+ machine: (0, machine_metrics_1.sampleMachine)(),
401
405
  claude: deco('claude', claude),
402
406
  codex: deco('codex', codex),
403
407
  kimi: deco('kimi', kimi),
@@ -25,6 +25,14 @@ export interface CodexInput {
25
25
  * Mirrors the claude --strict-mcp-config behavior. */
26
26
  mcpServers?: CodexMcpServer[];
27
27
  }
28
+ /** Build a config.toml that declares only the supplied MCP servers and
29
+ * return the CODEX_HOME directory plus any env vars the child needs
30
+ * (bearer tokens for remote servers). Caller is responsible for cleanup
31
+ * (we drop it under os.tmpdir() so it gets reaped). */
32
+ export declare function writeCodexHome(servers: CodexMcpServer[], workingDirectory: string): {
33
+ home: string;
34
+ extraEnv: Record<string, string>;
35
+ };
28
36
  export interface CodexHandle {
29
37
  pid: number | undefined;
30
38
  threadId: string | null;
@@ -40,6 +40,7 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  };
41
41
  })();
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.writeCodexHome = writeCodexHome;
43
44
  exports.codexEffort = codexEffort;
44
45
  exports.spawnCodex = spawnCodex;
45
46
  const child_process_1 = require("child_process");
@@ -54,15 +55,58 @@ const events_1 = require("./events");
54
55
  function tomlString(s) {
55
56
  return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
56
57
  }
58
+ /** Env var name carrying an HTTP MCP's bearer token into the codex process.
59
+ * Only the NAME goes in config.toml — the value rides on the child env, so
60
+ * the token never lands on disk. */
61
+ const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
57
62
  /** Build a config.toml that declares only the supplied MCP servers and
58
- * return the directory path to use as CODEX_HOME. Caller is responsible
59
- * for cleanup (we drop it under os.tmpdir() so it gets reaped). */
63
+ * return the CODEX_HOME directory plus any env vars the child needs
64
+ * (bearer tokens for remote servers). Caller is responsible for cleanup
65
+ * (we drop it under os.tmpdir() so it gets reaped). */
60
66
  function writeCodexHome(servers, workingDirectory) {
61
67
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'entities-codex-'));
68
+ const extraEnv = {};
62
69
  let toml = '# Generated by ainode — sandboxes codex against host config\n\n';
63
70
  for (const s of servers) {
64
71
  if (!s.slug || !s.command)
65
72
  continue;
73
+ // Remote MCP servers. Registry rows store command='http'|'sse' with
74
+ // args=[url]; codex spells that `url = "..."`, NOT command/args. Writing
75
+ // the row verbatim made codex try to exec a binary called `http`, so the
76
+ // server never started and its tools silently vanished — which is how a
77
+ // provider-ladder hop to codex dropped an entity's GitHub access without
78
+ // any error anywhere. The claude path (install.ts) always handled this;
79
+ // codex and grok did not.
80
+ if (s.command === 'http' || s.command === 'sse') {
81
+ const url = Array.isArray(s.args) && typeof s.args[0] === 'string' ? s.args[0] : null;
82
+ if (!url) {
83
+ console.error(`[codex] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
84
+ continue;
85
+ }
86
+ const name = tomlString(s.slug).slice(1, -1);
87
+ toml += `[mcp_servers.${name}]\n`;
88
+ toml += `url = ${tomlString(url)}\n`;
89
+ // codex reads the bearer from its OWN environment (--env is stdio-only),
90
+ // so the value goes on the child env and only the var name in the file.
91
+ const creds = Object.entries(s.env || {})
92
+ .filter(([k, v]) => typeof v === 'string' && v.length > 0 && !k.startsWith('OAUTH_REFRESH'));
93
+ const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
94
+ if (bearer) {
95
+ const varName = bearerEnvVar(s.slug);
96
+ extraEnv[varName] = String(bearer[1]);
97
+ toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
98
+ if (creds.length > 1) {
99
+ // codex carries one bearer and no arbitrary headers. Anything that
100
+ // authenticates with several custom headers is only partly wired,
101
+ // and saying so beats a server that 401s for no visible reason.
102
+ console.error(`[codex] MCP ${s.slug}: only ${bearer[0]} is forwarded as a bearer token; ` +
103
+ `codex cannot send custom headers, so ${creds.length - 1} other credential ` +
104
+ `field(s) were dropped`);
105
+ }
106
+ }
107
+ toml += '\n';
108
+ continue;
109
+ }
66
110
  // On native Windows, registry commands like `npx` are .cmd shims that
67
111
  // codex can't CreateProcess directly — wrap with `cmd /c` (no-op on POSIX).
68
112
  const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args : []);
@@ -109,7 +153,7 @@ function writeCodexHome(servers, workingDirectory) {
109
153
  if (fs.existsSync(sessionSkillsDir)) {
110
154
  (0, win_1.linkDir)(sessionSkillsDir, path.join(dir, 'skills'));
111
155
  }
112
- return dir;
156
+ return { home: dir, extraEnv };
113
157
  }
114
158
  function lineToEvents(line) {
115
159
  const type = typeof line.type === 'string' ? line.type : '';
@@ -194,7 +238,7 @@ function spawnCodex(input) {
194
238
  // Sandbox codex against an isolated CODEX_HOME so the host's
195
239
  // ~/.codex/config.toml mcp_servers don't leak in. TERM=dumb keeps
196
240
  // codex's TTY heuristics happy (no fancy terminal output).
197
- const codexHome = writeCodexHome(input.mcpServers ?? [], input.workingDirectory);
241
+ const { home: codexHome, extraEnv: mcpBearerEnv } = writeCodexHome(input.mcpServers ?? [], input.workingDirectory);
198
242
  // Register the scratch dir with DiskGuard: marks it active (so a
199
243
  // concurrent sweep won't evict a live run) and writes a PID lock so a
200
244
  // different runtime process can tell it's still owned. dispose(true)
@@ -205,6 +249,9 @@ function spawnCodex(input) {
205
249
  ...process.env,
206
250
  TERM: 'dumb',
207
251
  CODEX_HOME: codexHome,
252
+ // Bearer tokens for remote (http/sse) MCP servers. config.toml names these
253
+ // vars via bearer_token_env_var; the values only ever live here.
254
+ ...mcpBearerEnv,
208
255
  };
209
256
  // Resolve binary path before spawn. Previously called spawn('codex', ...)
210
257
  // bare, which threw ENOENT under launchd / desktop-app PATH inheritance
@@ -1,5 +1,7 @@
1
1
  export type RestartHook = (opts: {
2
- commandId: string;
2
+ /** Absent when the hourly auto-update is the one rolling: nobody issued a
3
+ * command, so there is nothing to close on the other side. */
4
+ commandId?: string;
3
5
  version: string;
4
6
  /** Bounce this daemon on the version it is already running: no npm
5
7
  * install, no version change. See runUpdateRuntime. */
@@ -38,4 +38,8 @@ export interface GrokHandle {
38
38
  onActivity(cb: () => void): void;
39
39
  done: Promise<number>;
40
40
  }
41
+ /** Write the entity's MCP servers as a project-scoped `<cwd>/.grok/config.toml`.
42
+ * Project scope only supports `[mcp_servers]`, which is exactly what we need.
43
+ * Returns the config path (for logging) or null when there are no servers. */
44
+ export declare function writeProjectMcpConfig(workingDirectory: string, servers: GrokMcpServer[]): string | null;
41
45
  export declare function spawnGrok(input: GrokInput): GrokHandle;
@@ -64,6 +64,7 @@ var __importStar = (this && this.__importStar) || (function () {
64
64
  };
65
65
  })();
66
66
  Object.defineProperty(exports, "__esModule", { value: true });
67
+ exports.writeProjectMcpConfig = writeProjectMcpConfig;
67
68
  exports.spawnGrok = spawnGrok;
68
69
  const child_process_1 = require("child_process");
69
70
  const fs = __importStar(require("fs"));
@@ -158,6 +159,18 @@ function writeProjectMcpConfig(workingDirectory, servers) {
158
159
  }
159
160
  const lines = [];
160
161
  for (const s of usable) {
162
+ // Remote MCP servers (registry command='http'|'sse', args=[url]) have no
163
+ // verified grok config spelling, and writing the row verbatim is worse
164
+ // than skipping: grok would try to exec a binary called `http`, the
165
+ // server would never start, and its tools would vanish with no error —
166
+ // the exact silent failure that cost an entity its GitHub access after a
167
+ // provider-ladder hop. Skip it, and say so loudly enough to be findable.
168
+ if (s.command === 'http' || s.command === 'sse') {
169
+ console.error(`[grok] MCP ${s.slug}: remote ${s.command} servers are not supported by the grok ` +
170
+ `adapter — skipping. Its tools will be ABSENT from this run. Use claude (or codex, ` +
171
+ `which supports url + bearer_token_env_var) for entities that depend on it.`);
172
+ continue;
173
+ }
161
174
  // `cmd /c` wrapper for npm-shim commands on native Windows (no-op on POSIX).
162
175
  const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args.map(String) : []);
163
176
  lines.push(`[mcp_servers.${tomlStr(s.slug)}]`);
@@ -1,4 +1,5 @@
1
1
  export declare function setPendingCommandsHook(cb: () => void): void;
2
+ export declare function autoUpdateEnabled(): boolean;
2
3
  /** Announce a clean shutdown so the server flips us offline immediately
3
4
  * (status='offline') instead of waiting ~90s for last_seen_at to age out —
4
5
  * lets a planned restart fail over instantly. Best-effort; the stale-window
package/dist/heartbeat.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // is paired.
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.setPendingCommandsHook = setPendingCommandsHook;
7
+ exports.autoUpdateEnabled = autoUpdateEnabled;
7
8
  exports.goOffline = goOffline;
8
9
  exports.beat = beat;
9
10
  exports.start = start;
@@ -21,6 +22,15 @@ let onPendingCommands = null;
21
22
  function setPendingCommandsHook(cb) {
22
23
  onPendingCommands = cb;
23
24
  }
25
+ // Is hourly auto-update armed for this node? It rides the heartbeat rather
26
+ // than getting an RPC of its own — the daemon is already talking to the server
27
+ // every 30s, and the answer is one boolean on the node's own row.
28
+ //
29
+ // Default ON, and specifically ON when the field is ABSENT: a server that has
30
+ // not had the migration yet must not read as "everybody opted out". Only an
31
+ // explicit `false` turns it off.
32
+ let autoUpdateArmed = true;
33
+ function autoUpdateEnabled() { return autoUpdateArmed; }
24
34
  async function tick() {
25
35
  // Skip if the daemon-wide circuit breaker is open. Heartbeats are
26
36
  // safe to miss — vault marks the runtime offline after ~90s; the
@@ -41,6 +51,8 @@ async function tick() {
41
51
  if (res && typeof res.pending_commands === 'number' && res.pending_commands > 0) {
42
52
  onPendingCommands?.();
43
53
  }
54
+ if (res && typeof res.auto_update === 'boolean')
55
+ autoUpdateArmed = res.auto_update;
44
56
  }
45
57
  catch (err) {
46
58
  if (err instanceof supabase_client_1.RpcError && err.status === 401) {
package/dist/index.js CHANGED
@@ -49,6 +49,9 @@ const pairing_1 = require("./pairing");
49
49
  const heartbeat_1 = require("./heartbeat");
50
50
  const command_runner_1 = require("./command-runner");
51
51
  const self_update_1 = require("./self-update");
52
+ const auto_update_1 = require("./auto-update");
53
+ const machine_metrics_1 = require("./machine-metrics");
54
+ const capabilities_1 = require("./capabilities");
52
55
  const autostart_1 = require("./autostart");
53
56
  const sleep_detector_1 = require("./sleep-detector");
54
57
  const claude_config_1 = require("./claude-config");
@@ -143,6 +146,11 @@ async function start(argv = []) {
143
146
  // server reports queued install/login commands, the command runner
144
147
  // drains them.
145
148
  (0, heartbeat_1.setPendingCommandsHook)(command_runner_1.wake);
149
+ // So each machine sample can say how many runs the box was holding at the
150
+ // time — "pinned at 90%" and "pinned at 90% running four things" are
151
+ // different stories. Hooked rather than imported so capabilities.ts needn't
152
+ // pull in request-pump, and through it session-runner.
153
+ (0, machine_metrics_1.setRunsInFlightHook)(() => (0, request_pump_1.activeRequestIdList)().length);
146
154
  (0, heartbeat_1.start)();
147
155
  // If we are the daemon a remote `update_runtime` restarted INTO, close out
148
156
  // that command now — with the version we actually came up as. The process
@@ -211,6 +219,7 @@ async function start(argv = []) {
211
219
  (0, projects_1.stop)();
212
220
  (0, request_pump_1.stop)();
213
221
  (0, heartbeat_1.stop)();
222
+ (0, auto_update_1.stop)();
214
223
  sleepMonitor.stop();
215
224
  diskGuard.stop();
216
225
  // Graceful drain: wait up to drainTimeoutMs for in-flight requests
@@ -254,7 +263,7 @@ async function start(argv = []) {
254
263
  // to the same bits (which matters for npx, whose cache dir is per-version)
255
264
  // and npm is never called — nothing to fetch, and nothing that can fail
256
265
  // offline.
257
- (0, command_runner_1.setRestartHook)(async ({ commandId, version, restartOnly }) => {
266
+ const performRestart = async ({ commandId, version, restartOnly }) => {
258
267
  const mode = (0, self_update_1.detectLaunchMode)(process.argv[1] ?? '');
259
268
  const plan = (0, self_update_1.planRespawn)(mode, process.argv[1] ?? '', version, process.execPath, process.argv.slice(2));
260
269
  const supervised = (0, autostart_1.isSupervised)();
@@ -293,6 +302,21 @@ async function start(argv = []) {
293
302
  ? `[restart] standing down for launchd to restart us (${what}); exiting`
294
303
  : `[restart] handed over to ${plan.file} (${what}); exiting`);
295
304
  setTimeout(() => process.exit(0), 250).unref?.();
305
+ };
306
+ (0, command_runner_1.setRestartHook)(performRestart);
307
+ // Hourly auto-update — the node keeps itself and its harnesses current
308
+ // without anybody clicking. It rolls through the very same closure the
309
+ // manual button does, minus the command id, so a hand roll and an auto
310
+ // roll cannot drift apart.
311
+ (0, auto_update_1.start)({
312
+ token: () => (0, store_1.readPairing)()?.daemonToken ?? null,
313
+ isBusy: () => (0, request_pump_1.activeRequestIdList)().length > 0,
314
+ breakerOpen: () => (0, supabase_client_1.rpcShouldSkip)(),
315
+ enabled: heartbeat_1.autoUpdateEnabled,
316
+ probe: () => (0, capabilities_1.probeCapabilities)(),
317
+ beat: () => (0, heartbeat_1.beat)(),
318
+ roll: ({ version }) => performRestart({ version }),
319
+ version: VERSION,
296
320
  });
297
321
  process.once('SIGINT', () => { stop().finally(() => process.exit(0)); });
298
322
  process.once('SIGTERM', () => { stop().finally(() => process.exit(0)); });
@@ -392,6 +416,16 @@ async function finalizeRestartHandoff() {
392
416
  const changed = h.from_version !== VERSION;
393
417
  const wanted = h.target_version;
394
418
  const missed = wanted !== 'latest' && wanted !== VERSION;
419
+ // No command id means the hourly auto-update rolled us, not a button. There
420
+ // is nothing to close; the outcome goes into capabilities instead, which the
421
+ // next heartbeat (seconds away) carries to the AiNode page.
422
+ if (!h.command_id) {
423
+ (0, auto_update_1.recordAutoRoll)({ from: h.from_version, to: VERSION, ...(missed ? { wanted } : {}) });
424
+ console.log(missed
425
+ ? `[auto-update] restarted, but came up as ${VERSION} (wanted ${wanted})`
426
+ : `[auto-update] back up as ${VERSION} (was ${h.from_version})`);
427
+ return;
428
+ }
395
429
  try {
396
430
  await (0, supabase_client_1.rpc)('runtime_command_update', {
397
431
  p_token: t,
@@ -0,0 +1,72 @@
1
+ import * as os from 'os';
2
+ export interface MachineSample {
3
+ /** Logical cores. */
4
+ cpus?: number;
5
+ /** Physical memory, MB. */
6
+ mem_total_mb?: number;
7
+ /** Memory in use, 0-100. */
8
+ mem_used_pct?: number;
9
+ /** Processor busy across all cores since the previous sample, 0-100.
10
+ * Absent on the first sample of a daemon's life — there is nothing to
11
+ * measure against yet, and a made-up first value would be a lie that
12
+ * lands in a graph. */
13
+ cpu_used_pct?: number;
14
+ /** 1-minute load average per core. Zero on Windows, where the OS has no
15
+ * such concept and Node reports [0,0,0]. */
16
+ load_per_cpu?: number;
17
+ /** Scratch volume in use, 0-100 — the same signal the health gate refuses
18
+ * new runs on at 97%. A filling disk is the failure that has actually
19
+ * bitten prod, so it belongs on the same timeline as the rest. */
20
+ disk_used_pct?: number;
21
+ /** Agent runs this node was holding at the moment of the sample. Turns
22
+ * "the box was pinned at 90%" into "because it was running four things". */
23
+ runs?: number;
24
+ /** Seconds since the machine booted. */
25
+ uptime_s?: number;
26
+ }
27
+ export interface CpuTimes {
28
+ idle: number;
29
+ total: number;
30
+ }
31
+ /**
32
+ * Smallest interval, in summed CPU-milliseconds across all cores, we will
33
+ * report a busy figure for.
34
+ *
35
+ * Without a floor, two readings a millisecond apart can straddle a single
36
+ * scheduler tick: idle advances by 0, total by ~1, and the honest arithmetic
37
+ * says 100% busy. That is noise, not measurement, and it lands in the graph as
38
+ * a spike that never happened — which is exactly how it was found, by a test
39
+ * that took two samples back to back and got 100 roughly one run in twelve.
40
+ *
41
+ * 100ms across all cores is ~12ms of wall time on an 8-core box, far below the
42
+ * 30s heartbeat this actually runs at, so no real sample is ever refused.
43
+ */
44
+ export declare const MIN_SAMPLE_MS = 100;
45
+ /** Summed CPU time across every core, in the units Node reports. */
46
+ export declare function readCpuTimes(cpus: os.CpuInfo[]): CpuTimes;
47
+ /**
48
+ * Busy percentage between two cumulative readings.
49
+ *
50
+ * Deliberately delta-based rather than derived from loadavg: load is a queue
51
+ * length, not a utilisation, it means different things on different kernels,
52
+ * and on Windows it is always zero. Deltas of the counters Node already
53
+ * exposes give a real 0-100 on every platform we run on.
54
+ *
55
+ * Returns null when the comparison is meaningless — no previous reading, or a
56
+ * total that did not advance (same tick) or went backwards (core count
57
+ * changed under us, a VM migrated). A null is "we don't know", which the graph
58
+ * shows as a gap; inventing 0% would draw an idle machine that wasn't.
59
+ */
60
+ export declare function cpuUsedPct(prev: CpuTimes | null, now: CpuTimes): number | null;
61
+ /** Memory in use as a percentage. Note this counts the page cache as used on
62
+ * Linux, the same way `free` does before you read the -/+ buffers line — it
63
+ * is the number every other tool shows, so it is the one to match. */
64
+ export declare function memUsedPct(totalBytes: number, freeBytes: number): number | null;
65
+ export declare function setRunsInFlightHook(fn: () => number): void;
66
+ /** Reset between tests. */
67
+ export declare function resetCpuBaseline(): void;
68
+ /**
69
+ * One reading of the machine. Never throws: every field is independently
70
+ * best-effort, and a sample with holes in it beats no heartbeat at all.
71
+ */
72
+ export declare function sampleMachine(): MachineSample;
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ // What the machine underneath the daemon is actually doing.
3
+ //
4
+ // Entity Studio's AiNode page has read `capabilities.machine` for cores,
5
+ // memory, load and uptime since it was written — but no version of this daemon
6
+ // has ever produced that key, so those readouts have always been dashes and
7
+ // the Memory tile has always silently fallen back to showing the pairing date.
8
+ // This is the producing half.
9
+ //
10
+ // Sampled on the capabilities probe (every heartbeat, ~30s). Everything here
11
+ // is cheap and synchronous: three `os` calls and one statfs. Nothing may throw
12
+ // — a metrics failure must never cost the heartbeat, because the heartbeat is
13
+ // what keeps the node marked online.
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.MIN_SAMPLE_MS = void 0;
49
+ exports.readCpuTimes = readCpuTimes;
50
+ exports.cpuUsedPct = cpuUsedPct;
51
+ exports.memUsedPct = memUsedPct;
52
+ exports.setRunsInFlightHook = setRunsInFlightHook;
53
+ exports.resetCpuBaseline = resetCpuBaseline;
54
+ exports.sampleMachine = sampleMachine;
55
+ const os = __importStar(require("os"));
56
+ const node_health_1 = require("./node-health");
57
+ const paths_1 = require("./paths");
58
+ /**
59
+ * Smallest interval, in summed CPU-milliseconds across all cores, we will
60
+ * report a busy figure for.
61
+ *
62
+ * Without a floor, two readings a millisecond apart can straddle a single
63
+ * scheduler tick: idle advances by 0, total by ~1, and the honest arithmetic
64
+ * says 100% busy. That is noise, not measurement, and it lands in the graph as
65
+ * a spike that never happened — which is exactly how it was found, by a test
66
+ * that took two samples back to back and got 100 roughly one run in twelve.
67
+ *
68
+ * 100ms across all cores is ~12ms of wall time on an 8-core box, far below the
69
+ * 30s heartbeat this actually runs at, so no real sample is ever refused.
70
+ */
71
+ exports.MIN_SAMPLE_MS = 100;
72
+ /** Summed CPU time across every core, in the units Node reports. */
73
+ function readCpuTimes(cpus) {
74
+ let idle = 0;
75
+ let total = 0;
76
+ for (const c of cpus) {
77
+ for (const key of Object.keys(c.times)) {
78
+ total += c.times[key];
79
+ }
80
+ idle += c.times.idle;
81
+ }
82
+ return { idle, total };
83
+ }
84
+ /**
85
+ * Busy percentage between two cumulative readings.
86
+ *
87
+ * Deliberately delta-based rather than derived from loadavg: load is a queue
88
+ * length, not a utilisation, it means different things on different kernels,
89
+ * and on Windows it is always zero. Deltas of the counters Node already
90
+ * exposes give a real 0-100 on every platform we run on.
91
+ *
92
+ * Returns null when the comparison is meaningless — no previous reading, or a
93
+ * total that did not advance (same tick) or went backwards (core count
94
+ * changed under us, a VM migrated). A null is "we don't know", which the graph
95
+ * shows as a gap; inventing 0% would draw an idle machine that wasn't.
96
+ */
97
+ function cpuUsedPct(prev, now) {
98
+ if (!prev)
99
+ return null;
100
+ const total = now.total - prev.total;
101
+ const idle = now.idle - prev.idle;
102
+ if (total < exports.MIN_SAMPLE_MS || idle < 0)
103
+ return null;
104
+ const pct = (1 - idle / total) * 100;
105
+ return Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
106
+ }
107
+ /** Memory in use as a percentage. Note this counts the page cache as used on
108
+ * Linux, the same way `free` does before you read the -/+ buffers line — it
109
+ * is the number every other tool shows, so it is the one to match. */
110
+ function memUsedPct(totalBytes, freeBytes) {
111
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0)
112
+ return null;
113
+ const pct = (1 - freeBytes / totalBytes) * 100;
114
+ return Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
115
+ }
116
+ /* ── live sampling ────────────────────────────────────────────────────── */
117
+ let prevCpu = null;
118
+ /** How many runs this node is holding. Wired by index.ts rather than imported,
119
+ * so capabilities.ts does not have to pull in request-pump (and through it
120
+ * session-runner) just to count them. */
121
+ let runsInFlight = null;
122
+ function setRunsInFlightHook(fn) { runsInFlight = fn; }
123
+ /** Reset between tests. */
124
+ function resetCpuBaseline() { prevCpu = null; }
125
+ /**
126
+ * One reading of the machine. Never throws: every field is independently
127
+ * best-effort, and a sample with holes in it beats no heartbeat at all.
128
+ */
129
+ function sampleMachine() {
130
+ const s = {};
131
+ try {
132
+ const cpus = os.cpus();
133
+ if (cpus.length > 0) {
134
+ s.cpus = cpus.length;
135
+ const now = readCpuTimes(cpus);
136
+ const pct = cpuUsedPct(prevCpu, now);
137
+ if (pct !== null)
138
+ s.cpu_used_pct = pct;
139
+ prevCpu = now;
140
+ }
141
+ }
142
+ catch { /* no cpu info — the other fields still stand */ }
143
+ try {
144
+ const total = os.totalmem();
145
+ s.mem_total_mb = Math.round(total / (1024 * 1024));
146
+ const pct = memUsedPct(total, os.freemem());
147
+ if (pct !== null)
148
+ s.mem_used_pct = pct;
149
+ }
150
+ catch { /* skip */ }
151
+ try {
152
+ const [one] = os.loadavg();
153
+ const cores = s.cpus ?? 0;
154
+ if (cores > 0 && Number.isFinite(one))
155
+ s.load_per_cpu = Math.round((one / cores) * 100) / 100;
156
+ }
157
+ catch { /* skip */ }
158
+ try {
159
+ const disk = (0, node_health_1.readDiskUsedPct)(paths_1.RUNTIME_HOME);
160
+ if (disk !== null)
161
+ s.disk_used_pct = Math.round(disk * 10) / 10;
162
+ }
163
+ catch { /* skip */ }
164
+ try {
165
+ s.runs = runsInFlight?.() ?? 0;
166
+ }
167
+ catch {
168
+ s.runs = 0;
169
+ }
170
+ try {
171
+ s.uptime_s = Math.round(os.uptime());
172
+ }
173
+ catch { /* skip */ }
174
+ return s;
175
+ }
@@ -53,6 +53,23 @@ export interface RollPlan {
53
53
  /** Non-null = refuse before anything drains, with this explanation. */
54
54
  refusal: string | null;
55
55
  }
56
+ /**
57
+ * Is this something we are willing to hand to `npm i -g @addai/node@…`?
58
+ *
59
+ * npm accepts far more than a version after the `@`: a tarball URL, a
60
+ * `github:user/repo`, a `file:` path — each of which installs code of the
61
+ * caller's choosing, which this daemon then re-execs as its own binary. A
62
+ * remotely-supplied spec is therefore restricted to a plain semver or a
63
+ * dist-tag, and everything else is refused before npm is ever invoked.
64
+ *
65
+ * This is the second lock. The first is the RLS policy on runtime_commands,
66
+ * which was found comparing `input ->> 'restart_only'` (text) while this file
67
+ * compared `=== true` (boolean) — so the string "true" read as a harmless
68
+ * restart to the database and as a roll-to-anything here, giving anyone a node
69
+ * was merely SHARED with arbitrary code execution on the owner's machine.
70
+ * The policy is fixed; this makes the same mistake unexploitable next time.
71
+ */
72
+ export declare function isSafeVersionSpec(v: string): boolean;
56
73
  /**
57
74
  * Read an `update_runtime` command's input into what this node should do.
58
75
  *
@@ -67,7 +84,12 @@ export declare function planRoll(input: {
67
84
  restart_only?: boolean;
68
85
  } | null, mode: LaunchMode, currentVersion: string): RollPlan;
69
86
  export interface RestartHandoff {
70
- command_id: string;
87
+ /** Absent when the roll was the hourly auto-update rather than a button:
88
+ * there is no command to close, so the daemon that comes up records the
89
+ * outcome in capabilities instead. The property that matters survives
90
+ * either way — it is the NEW process that reports, so a restart that
91
+ * never comes back still never reports success. */
92
+ command_id?: string;
71
93
  from_version: string;
72
94
  target_version: string;
73
95
  mode: LaunchMode;
@@ -87,11 +109,27 @@ export declare function writeHandoff(runtimeDir: string, h: RestartHandoff): voi
87
109
  /** Reads and DELETES the handoff — it must never be replayed twice. */
88
110
  export declare function takeHandoff(runtimeDir: string): RestartHandoff | null;
89
111
  /**
90
- * `npm i -g <pkg>@<version>`. Returns null on success, else the reason.
112
+ * `npm i -g <spec>` for any package. Returns null on success, else the reason.
91
113
  * Never throws — a failed install must leave the CURRENT daemon running
92
114
  * rather than take the node down.
115
+ *
116
+ * On a classic Mac with a root-owned /usr/local prefix the first attempt dies
117
+ * EACCES, so we retry into the daemon's own prefix — the same fallback
118
+ * command-runner's interactive install uses, and the same one the binary
119
+ * finders already search. Without it, auto-update would fail silently forever
120
+ * on exactly the nodes that needed a human least.
93
121
  */
122
+ export declare function npmInstallGlobal(spec: string, timeoutMs?: number): Promise<string | null>;
123
+ /** `npm i -g @addai/node@<version>`. Thin wrapper kept for the roll paths. */
94
124
  export declare function installGlobal(targetVersion: string, timeoutMs?: number): Promise<string | null>;
125
+ /**
126
+ * What npm currently calls `latest` for a package, or null if we couldn't ask.
127
+ *
128
+ * Null is deliberately NOT treated as "up to date" by callers — it means the
129
+ * question went unanswered (offline, registry blip), and an unanswered
130
+ * question must never masquerade as a clean bill of health.
131
+ */
132
+ export declare function npmLatestVersion(pkg: string, timeoutMs?: number): Promise<string | null>;
95
133
  /**
96
134
  * Spawn the replacement daemon fully detached, so it survives this process's
97
135
  * exit. stdio is redirected to the daemon log (inheriting our stdio would tie
@@ -55,16 +55,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
55
55
  exports.PACKAGE_NAME = void 0;
56
56
  exports.detectLaunchMode = detectLaunchMode;
57
57
  exports.planRespawn = planRespawn;
58
+ exports.isSafeVersionSpec = isSafeVersionSpec;
58
59
  exports.planRoll = planRoll;
59
60
  exports.handoffPath = handoffPath;
60
61
  exports.writeHandoff = writeHandoff;
61
62
  exports.takeHandoff = takeHandoff;
63
+ exports.npmInstallGlobal = npmInstallGlobal;
62
64
  exports.installGlobal = installGlobal;
65
+ exports.npmLatestVersion = npmLatestVersion;
63
66
  exports.spawnReplacement = spawnReplacement;
64
67
  const child_process_1 = require("child_process");
65
68
  const fs = __importStar(require("fs"));
66
69
  const path = __importStar(require("path"));
67
70
  const win_1 = require("./win");
71
+ const paths_1 = require("./paths");
68
72
  exports.PACKAGE_NAME = '@addai/node';
69
73
  /**
70
74
  * Classify the launch from the running script's path.
@@ -113,6 +117,29 @@ userArgs = []) {
113
117
  canChangeVersion: mode === 'global',
114
118
  };
115
119
  }
120
+ /**
121
+ * Is this something we are willing to hand to `npm i -g @addai/node@…`?
122
+ *
123
+ * npm accepts far more than a version after the `@`: a tarball URL, a
124
+ * `github:user/repo`, a `file:` path — each of which installs code of the
125
+ * caller's choosing, which this daemon then re-execs as its own binary. A
126
+ * remotely-supplied spec is therefore restricted to a plain semver or a
127
+ * dist-tag, and everything else is refused before npm is ever invoked.
128
+ *
129
+ * This is the second lock. The first is the RLS policy on runtime_commands,
130
+ * which was found comparing `input ->> 'restart_only'` (text) while this file
131
+ * compared `=== true` (boolean) — so the string "true" read as a harmless
132
+ * restart to the database and as a roll-to-anything here, giving anyone a node
133
+ * was merely SHARED with arbitrary code execution on the owner's machine.
134
+ * The policy is fixed; this makes the same mistake unexploitable next time.
135
+ */
136
+ function isSafeVersionSpec(v) {
137
+ // 1.2.3 / 1.2.3-beta.1
138
+ if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v))
139
+ return true;
140
+ // a dist-tag: latest, next, canary…
141
+ return /^[a-z][a-z0-9-]{0,31}$/.test(v);
142
+ }
116
143
  /**
117
144
  * Read an `update_runtime` command's input into what this node should do.
118
145
  *
@@ -128,9 +155,11 @@ function planRoll(input, mode, currentVersion) {
128
155
  // "did we come up as what was asked for?" check meaningful instead of
129
156
  // special-cased — and pins npx, whose cache dir is per-version.
130
157
  const target = restartOnly ? currentVersion : (input?.version ?? '').trim() || 'latest';
131
- const refusal = !restartOnly && mode === 'source' && target !== 'latest'
132
- ? 'this node runs from a source checkout pull and rebuild it there; a remote version bump cannot apply'
133
- : null;
158
+ const refusal = !restartOnly && !isSafeVersionSpec(target)
159
+ ? `refusing to install "${target}" a remote roll may name a version or a dist-tag, nothing else`
160
+ : !restartOnly && mode === 'source' && target !== 'latest'
161
+ ? 'this node runs from a source checkout — pull and rebuild it there; a remote version bump cannot apply'
162
+ : null;
134
163
  return { target, restartOnly, refusal };
135
164
  }
136
165
  function handoffPath(runtimeDir) {
@@ -150,22 +179,85 @@ function takeHandoff(runtimeDir) {
150
179
  }
151
180
  catch { /* already gone */ }
152
181
  const parsed = JSON.parse(raw);
153
- return parsed && typeof parsed.command_id === 'string' ? parsed : null;
182
+ // A handoff with no command_id is the auto-update shape, so the id can no
183
+ // longer be what proves the file is one of ours — from_version is.
184
+ return parsed && typeof parsed.from_version === 'string' ? parsed : null;
154
185
  }
155
186
  catch {
156
187
  return null;
157
188
  }
158
189
  }
190
+ /** One `npm i -g` attempt. Resolves the failure reason, or null on success. */
191
+ function npmInstallAttempt(spec, prefix, timeoutMs) {
192
+ const args = ['i', '-g', ...(prefix ? ['--prefix', prefix] : []), spec];
193
+ const inv = (0, win_1.resolveCliInvocation)('npm', args);
194
+ return new Promise((resolve) => {
195
+ let log = '';
196
+ let settled = false;
197
+ const done = (err) => { if (!settled) {
198
+ settled = true;
199
+ resolve({ err, log });
200
+ } };
201
+ try {
202
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, { windowsHide: true, env: process.env });
203
+ const timer = setTimeout(() => { try {
204
+ child.kill('SIGKILL');
205
+ }
206
+ catch { /* gone */ } done(`npm i -g ${spec} timed out`); }, timeoutMs);
207
+ timer.unref?.();
208
+ child.stdout?.on('data', b => { log += b.toString('utf8'); });
209
+ child.stderr?.on('data', b => { log += b.toString('utf8'); });
210
+ child.on('error', err => { clearTimeout(timer); done(`npm i -g failed to start: ${err.message}`); });
211
+ child.on('exit', code => {
212
+ clearTimeout(timer);
213
+ done(code === 0 ? null : `npm i -g ${spec} exited ${code}: ${log.trim().slice(-400)}`);
214
+ });
215
+ }
216
+ catch (err) {
217
+ done(`npm i -g failed to start: ${err.message}`);
218
+ }
219
+ });
220
+ }
159
221
  /**
160
- * `npm i -g <pkg>@<version>`. Returns null on success, else the reason.
222
+ * `npm i -g <spec>` for any package. Returns null on success, else the reason.
161
223
  * Never throws — a failed install must leave the CURRENT daemon running
162
224
  * rather than take the node down.
225
+ *
226
+ * On a classic Mac with a root-owned /usr/local prefix the first attempt dies
227
+ * EACCES, so we retry into the daemon's own prefix — the same fallback
228
+ * command-runner's interactive install uses, and the same one the binary
229
+ * finders already search. Without it, auto-update would fail silently forever
230
+ * on exactly the nodes that needed a human least.
163
231
  */
232
+ async function npmInstallGlobal(spec, timeoutMs = 5 * 60_000) {
233
+ const first = await npmInstallAttempt(spec, null, timeoutMs);
234
+ if (!first.err)
235
+ return null;
236
+ if (!/EACCES/.test(first.log))
237
+ return first.err;
238
+ const toolsPrefix = path.join(paths_1.RUNTIME_HOME, 'tools');
239
+ try {
240
+ fs.mkdirSync(toolsPrefix, { recursive: true });
241
+ }
242
+ catch { /* the spawn will surface it */ }
243
+ const retry = await npmInstallAttempt(spec, toolsPrefix, timeoutMs);
244
+ return retry.err;
245
+ }
246
+ /** `npm i -g @addai/node@<version>`. Thin wrapper kept for the roll paths. */
164
247
  async function installGlobal(targetVersion, timeoutMs = 5 * 60_000) {
165
- const spec = `${exports.PACKAGE_NAME}@${targetVersion || 'latest'}`;
166
- const inv = (0, win_1.resolveCliInvocation)('npm', ['i', '-g', spec]);
248
+ return npmInstallGlobal(`${exports.PACKAGE_NAME}@${targetVersion || 'latest'}`, timeoutMs);
249
+ }
250
+ /**
251
+ * What npm currently calls `latest` for a package, or null if we couldn't ask.
252
+ *
253
+ * Null is deliberately NOT treated as "up to date" by callers — it means the
254
+ * question went unanswered (offline, registry blip), and an unanswered
255
+ * question must never masquerade as a clean bill of health.
256
+ */
257
+ function npmLatestVersion(pkg, timeoutMs = 60_000) {
258
+ const inv = (0, win_1.resolveCliInvocation)('npm', ['view', pkg, 'version']);
167
259
  return new Promise((resolve) => {
168
- let stderr = '';
260
+ let out = '';
169
261
  let settled = false;
170
262
  const done = (v) => { if (!settled) {
171
263
  settled = true;
@@ -176,17 +268,18 @@ async function installGlobal(targetVersion, timeoutMs = 5 * 60_000) {
176
268
  const timer = setTimeout(() => { try {
177
269
  child.kill('SIGKILL');
178
270
  }
179
- catch { /* gone */ } done(`npm i -g ${spec} timed out`); }, timeoutMs);
271
+ catch { /* gone */ } done(null); }, timeoutMs);
180
272
  timer.unref?.();
181
- child.stderr?.on('data', b => { stderr += b.toString('utf8'); });
182
- child.on('error', err => { clearTimeout(timer); done(`npm i -g failed to start: ${err.message}`); });
273
+ child.stdout?.on('data', b => { out += b.toString('utf8'); });
274
+ child.on('error', () => { clearTimeout(timer); done(null); });
183
275
  child.on('exit', code => {
184
276
  clearTimeout(timer);
185
- done(code === 0 ? null : `npm i -g ${spec} exited ${code}: ${stderr.trim().slice(-400)}`);
277
+ const v = out.trim();
278
+ done(code === 0 && /^\d+\.\d+\.\d+/.test(v) ? v : null);
186
279
  });
187
280
  }
188
- catch (err) {
189
- done(`npm i -g failed to start: ${err.message}`);
281
+ catch {
282
+ done(null);
190
283
  }
191
284
  });
192
285
  }
package/dist/store.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { RuntimePairingState } from './types';
2
2
  export declare function readPairing(): RuntimePairingState | null;
3
3
  export declare function writePairing(state: RuntimePairingState): void;
4
- export declare function clearPairing(): void;
4
+ export declare function clearPairing(opts?: {
5
+ stateFile?: string;
6
+ legacy?: string;
7
+ }): void;
5
8
  export declare function isPaired(): boolean;
package/dist/store.js CHANGED
@@ -43,6 +43,7 @@ exports.writePairing = writePairing;
43
43
  exports.clearPairing = clearPairing;
44
44
  exports.isPaired = isPaired;
45
45
  const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
46
47
  const paths_1 = require("./paths");
47
48
  const win_1 = require("./win");
48
49
  function ensureHome() {
@@ -71,11 +72,30 @@ function writePairing(state) {
71
72
  // rename-over-open-file fails EPERM/EBUSY on Windows — retried there.
72
73
  (0, win_1.safeRenameSync)(tmp, paths_1.RUNTIME_STATE_FILE);
73
74
  }
74
- function clearPairing() {
75
+ // `opts` exists so this is testable without touching the real home — same
76
+ // injection migrateLegacyStateDir takes, for the same reason.
77
+ function clearPairing(opts = {}) {
78
+ const stateFile = opts.stateFile ?? paths_1.RUNTIME_STATE_FILE;
79
+ const legacy = opts.legacy ?? paths_1.LEGACY_RUNTIME_HOME;
75
80
  try {
76
- fs.unlinkSync(paths_1.RUNTIME_STATE_FILE);
81
+ fs.unlinkSync(stateFile);
77
82
  }
78
83
  catch { /* already gone */ }
84
+ // The legacy home too. state-migrate COPIES ~/.entities-runtime/state.json
85
+ // into the new home rather than moving it, so the old file outlives the
86
+ // rename on purpose — but nothing ever cleaned it up, and the migration
87
+ // only skips when the new home already has a state.json. Deleting just the
88
+ // new copy therefore didn't unpair anything: the next start copied the old
89
+ // token straight back in, the daemon believed it was already paired (so an
90
+ // explicit `ainode entities <CODE>` never ran the pairing flow at all), the
91
+ // server rejected the dead token, and it cleared state again. That loop
92
+ // survived three releases on a real machine before we found it.
93
+ //
94
+ // Unpairing means unpaired. Both copies go.
95
+ try {
96
+ fs.unlinkSync(path.join(legacy, 'state.json'));
97
+ }
98
+ catch { /* no legacy home, or already gone */ }
79
99
  }
80
100
  function isPaired() {
81
101
  return readPairing() !== null;
@@ -244,6 +244,14 @@ function createDashboardScreen(deps) {
244
244
  if (recent.length)
245
245
  st.recent = recent;
246
246
  st.offline = deps.data.offline();
247
+ // The server can unpair a node out from under a running daemon — the
248
+ // machine removed from the fleet, or its token revoked. The heartbeat
249
+ // notices and clears the local pairing, but this screen was reading
250
+ // `paired` once at startup, so it kept showing the old stats and a header
251
+ // that said Online while every call underneath it came back unauthorized.
252
+ // Re-read it: a node that has lost its pairing should say so and tell you
253
+ // how to come back, not sit there looking healthy and empty.
254
+ st.paired = (0, store_1.isPaired)();
247
255
  // A selected run that has finished is no longer in the band. nowIndex
248
256
  // already reads that as "cursor is in the menu"; drop the id too so the
249
257
  // state doesn't keep pointing at a run nobody can see.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.11.2",
3
+ "version": "0.12.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": [