@addai/node 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@
4
4
  //
5
5
  // ainode run this node: daemon + live dashboard
6
6
  // ainode harnesses interactive harness manager (install / login)
7
+ // ainode startup … start this node when the machine starts
7
8
  // ainode unpair --yes disconnect from +Ai, cancel in-flight work
8
9
  // ainode version print version
9
10
  //
@@ -15,12 +16,46 @@
15
16
  //
16
17
  // Unpair reaches Supabase via the daemon token in ~/.ainode/state.json and
17
18
  // does not require the daemon to be running.
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
18
52
  Object.defineProperty(exports, "__esModule", { value: true });
19
53
  const lockfile_1 = require("./lockfile");
20
54
  const index_1 = require("./index");
21
55
  const store_1 = require("./store");
22
56
  const supabase_client_1 = require("./supabase-client");
23
57
  const run_1 = require("./tui/run");
58
+ const autostart = __importStar(require("./autostart"));
24
59
  const args = process.argv.slice(2);
25
60
  function token() {
26
61
  const p = (0, store_1.readPairing)();
@@ -53,9 +88,53 @@ async function cmdUnpair() {
53
88
  (0, store_1.clearPairing)();
54
89
  console.log('Unpaired. Run `ainode` to pair again.');
55
90
  }
91
+ function cmdStartup() {
92
+ const sub = args[1] ?? 'status';
93
+ if (sub === 'status') {
94
+ console.log(autostart.describeStatus(autostart.status()));
95
+ return;
96
+ }
97
+ if (sub === 'enable') {
98
+ const r = autostart.enable({ force: args.includes('--force') });
99
+ if (!r.ok) {
100
+ console.error(r.error ?? 'could not enable startup');
101
+ process.exit(1);
102
+ }
103
+ for (const w of r.warnings)
104
+ console.log(` note: ${w}`);
105
+ console.log('This node now starts when you log in.');
106
+ if (r.path)
107
+ console.log(` definition: ${r.path}`);
108
+ return;
109
+ }
110
+ if (sub === 'disable') {
111
+ const before = autostart.status();
112
+ const r = autostart.disable();
113
+ if (!r.ok) {
114
+ console.error(r.error ?? 'could not disable startup');
115
+ process.exit(1);
116
+ }
117
+ console.log('This node no longer starts at login.');
118
+ // launchd's bootout stops the job it was supervising, so the node just
119
+ // went offline. Saying so beats letting the user discover it later.
120
+ if (before.supervisor === 'launchd' && before.enabled) {
121
+ console.log(' the supervised node was stopped — run `ainode` to bring it back');
122
+ }
123
+ return;
124
+ }
125
+ console.error(`unknown startup command: ${sub}`);
126
+ console.error('try: ainode startup enable | disable | status');
127
+ process.exit(2);
128
+ }
56
129
  async function main() {
57
130
  const cmd = args[0];
58
131
  if (!cmd || cmd === 'run' || cmd === 'start' || cmd === '--run') {
132
+ // `--ensure` means "make sure a node is running here, then get out of the
133
+ // way". Windows' startup task repeats every five minutes to catch a
134
+ // crashed daemon; without this it would stack a second one every time.
135
+ if (args.includes('--ensure') && (0, lockfile_1.lockfileAlive)((0, lockfile_1.readLockfile)())) {
136
+ return;
137
+ }
59
138
  const tui = (0, run_1.shouldRenderTui)({
60
139
  isTTY: Boolean(process.stdout.isTTY),
61
140
  stdinTTY: Boolean(process.stdin.isTTY),
@@ -99,6 +178,9 @@ async function main() {
99
178
  case 'harnesses':
100
179
  await (0, run_1.runHarnessesTui)();
101
180
  return;
181
+ case 'startup':
182
+ cmdStartup();
183
+ return;
102
184
  case 'unpair':
103
185
  await cmdUnpair();
104
186
  return;
@@ -115,11 +197,15 @@ async function main() {
115
197
  usage:
116
198
  ainode run this +Ai Node — daemon + live dashboard
117
199
  ainode harnesses install / log in agent harnesses (interactive)
200
+ ainode startup enable start this node whenever you log in
201
+ ainode startup disable stop starting at login
202
+ ainode startup status where the startup entry is and what it runs
118
203
  ainode unpair --yes disconnect from +Ai
119
204
  ainode version print version
120
205
 
121
206
  Piped or headless output falls back to plain log lines. Set AINODE_NO_TUI=1
122
- to force that on a terminal.
207
+ to force that on a terminal. \`ainode run --ensure\` starts a node only if one
208
+ isn't already running — what the Windows startup task uses.
123
209
 
124
210
  Pair the node by running it with no args and following the prompt
125
211
  to vault.add.ai/entity/connect/<CODE>.
@@ -56,6 +56,7 @@ const heartbeat_1 = require("./heartbeat");
56
56
  const win_1 = require("./win");
57
57
  const harness_registry_1 = require("./harness-registry");
58
58
  const self_update_1 = require("./self-update");
59
+ const autostart_1 = require("./autostart");
59
60
  const paths_1 = require("./paths");
60
61
  const pty_helper_1 = require("./pty-helper");
61
62
  const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
@@ -505,6 +506,38 @@ async function runUpdateRuntime(cmd) {
505
506
  await update(cmd.id, 'failed', { mode }, `restart failed: ${err.message}`);
506
507
  }
507
508
  }
509
+ /* ── set_autostart ───────────────────────────────────────────────────────
510
+ * Arm (or disarm) "start when the machine starts" on a node you are not
511
+ * sitting at. Unlike a roll, this one CAN honestly report its own outcome:
512
+ * nothing restarts, so the process that did the work is still here to say
513
+ * what happened — and it reports the state it actually READ BACK afterwards,
514
+ * not the state it intended.
515
+ *
516
+ * Enabling under launchd hands the node over: a daemon started by hand is
517
+ * asked to drain and stop so launchd can own it. That means this command can
518
+ * legitimately stop and restart the daemon underneath the caller. It does not
519
+ * restart THIS process, so the reporting contract still holds. */
520
+ async function runSetAutostart(cmd) {
521
+ const want = cmd.input?.enabled !== false;
522
+ await update(cmd.id, 'running', { step: want ? 'enabling' : 'disabling' });
523
+ if (want) {
524
+ const r = (0, autostart_1.enable)({ force: cmd.input?.force === true });
525
+ const state = (0, autostart_1.status)();
526
+ if (!r.ok) {
527
+ await update(cmd.id, 'failed', { autostart: state }, r.error ?? 'could not enable startup');
528
+ return;
529
+ }
530
+ await update(cmd.id, 'completed', { autostart: state, path: r.path, warnings: r.warnings });
531
+ return;
532
+ }
533
+ const r = (0, autostart_1.disable)();
534
+ const state = (0, autostart_1.status)();
535
+ if (!r.ok) {
536
+ await update(cmd.id, 'failed', { autostart: state }, r.error ?? 'could not disable startup');
537
+ return;
538
+ }
539
+ await update(cmd.id, 'completed', { autostart: state });
540
+ }
508
541
  /* ── dispatcher ──────────────────────────────────────────────────────── */
509
542
  async function execute(cmd) {
510
543
  // Not a harness command — dispatch before the harness lookup, which would
@@ -513,6 +546,10 @@ async function execute(cmd) {
513
546
  await runUpdateRuntime(cmd);
514
547
  return;
515
548
  }
549
+ if (cmd.kind === 'set_autostart') {
550
+ await runSetAutostart(cmd);
551
+ return;
552
+ }
516
553
  const spec = (0, harness_registry_1.harness)(cmd.harness ?? '');
517
554
  if (!spec) {
518
555
  await update(cmd.id, 'failed', {}, `unknown harness: ${cmd.harness}`);
package/dist/index.js CHANGED
@@ -49,6 +49,7 @@ 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 autostart_1 = require("./autostart");
52
53
  const sleep_detector_1 = require("./sleep-detector");
53
54
  const claude_config_1 = require("./claude-config");
54
55
  const request_pump_1 = require("./request-pump");
@@ -236,13 +237,21 @@ async function start() {
236
237
  (0, lockfile_1.releaseLockfile)();
237
238
  };
238
239
  // Remote roll: drain like a clean shutdown, install the target version if
239
- // this launch mode can, then hand over to a detached replacement. The
240
- // handoff file is written only AFTER the replacement is spawned, so a
241
- // failure anywhere before that leaves this daemon running and the command
242
- // is failed by the caller instead of silently taking the node down.
240
+ // this launch mode can, then hand over to a replacement. The handoff file is
241
+ // written only AFTER the replacement is arranged, so a failure anywhere
242
+ // before that leaves this daemon running and the command is failed by the
243
+ // caller instead of silently taking the node down.
244
+ //
245
+ // WHO starts the replacement depends on whether anything is supervising us.
246
+ // Historically nothing was, so this spawned a detached child of its own. On
247
+ // a node with `ainode startup enable` under launchd that would be a second
248
+ // daemon for one node — the failure the lockfile-identity fix was about. So
249
+ // when launchd is holding us up, the roll simply STANDS DOWN and lets
250
+ // KeepAlive do the starting.
243
251
  (0, command_runner_1.setRestartHook)(async ({ commandId, version }) => {
244
252
  const mode = (0, self_update_1.detectLaunchMode)(process.argv[1] ?? '');
245
253
  const plan = (0, self_update_1.planRespawn)(mode, process.argv[1] ?? '', version, process.execPath, process.argv.slice(2));
254
+ const supervised = (0, autostart_1.isSupervised)();
246
255
  if (plan.installFirst) {
247
256
  const err = await (0, self_update_1.installGlobal)(version);
248
257
  if (err)
@@ -251,11 +260,13 @@ async function start() {
251
260
  // Drain generously — a restart is elective, so it should never guillotine
252
261
  // a live entity turn the way a 15s SIGTERM drain would.
253
262
  await stop(RESTART_DRAIN_MS);
254
- // Point of no return. Past this line a replacement daemon exists, so we
255
- // MUST exit — two live daemons on one node is worse than a command left
256
- // unreported. Hence the handoff write (which only affects reporting) can
257
- // never abort the exit.
258
- (0, self_update_1.spawnReplacement)(plan, path.join(paths_1.RUNTIME_HOME, 'daemon.log'), process.cwd());
263
+ // Point of no return. Past this line a replacement daemon either exists or
264
+ // is guaranteed by the supervisor, so we MUST exit — two live daemons on
265
+ // one node is worse than a command left unreported. Hence the handoff
266
+ // write (which only affects reporting) can never abort the exit.
267
+ if (!supervised) {
268
+ (0, self_update_1.spawnReplacement)(plan, path.join(paths_1.RUNTIME_HOME, 'daemon.log'), process.cwd());
269
+ }
259
270
  try {
260
271
  (0, self_update_1.writeHandoff)(paths_1.RUNTIME_HOME, {
261
272
  command_id: commandId,
@@ -270,7 +281,9 @@ async function start() {
270
281
  catch (err) {
271
282
  console.error('[restart] handoff write failed — the roll still happened, it just cannot self-report:', err.message);
272
283
  }
273
- console.log(`[restart] handed over to ${plan.file} (${mode} → ${version}); exiting`);
284
+ console.log(supervised
285
+ ? `[restart] standing down for launchd to restart us (${mode} → ${version}); exiting`
286
+ : `[restart] handed over to ${plan.file} (${mode} → ${version}); exiting`);
274
287
  setTimeout(() => process.exit(0), 250).unref?.();
275
288
  });
276
289
  process.once('SIGINT', () => { stop().finally(() => process.exit(0)); });
@@ -1,5 +1,6 @@
1
1
  import { type AppHost, type Screen } from './app';
2
2
  import type { DataLayer, NodeSelf, NodeStats, RequestRow } from './data';
3
+ import { type AutostartState } from '../autostart';
3
4
  export interface DashboardState {
4
5
  self: NodeSelf | null;
5
6
  stats: NodeStats | null;
@@ -18,6 +19,11 @@ export interface DashboardState {
18
19
  version: string;
19
20
  /** How many daemon log lines have been captured this session. */
20
21
  logCount: number;
22
+ /** Does this node come back after a reboot? Null until first read. */
23
+ autostart: AutostartState | null;
24
+ /** Set while a toggle is in flight, and to whatever it had to say after —
25
+ * enabling can stop and restart the daemon, which deserves a sentence. */
26
+ autostartNote: string | null;
21
27
  }
22
28
  /** The landing screen's menu. */
23
29
  export declare const MENU: Array<{
@@ -38,6 +44,14 @@ export declare function liveRequests(rows: RequestRow[]): RequestRow[];
38
44
  * second of every session claimed the node was in an unknown state.
39
45
  */
40
46
  export declare function stateLabel(st: DashboardState): string;
47
+ /**
48
+ * Whether the box comes back on its own.
49
+ *
50
+ * On the header, not buried in a menu: "will this node survive a reboot" is a
51
+ * property of the node in the same way "is it online" is, and the answer was
52
+ * previously knowable only by reading the docs and looking for a plist.
53
+ */
54
+ export declare function autostartLine(st: DashboardState): string;
41
55
  /** 1204 reads as a year; 1,204 reads as a count. */
42
56
  export declare function fmtCount(n: number | null | undefined): string;
43
57
  export declare function nowLines(st: DashboardState, width: number, rows: number): string[];
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.MAX_NOW_ROWS = exports.MENU = void 0;
10
10
  exports.liveRequests = liveRequests;
11
11
  exports.stateLabel = stateLabel;
12
+ exports.autostartLine = autostartLine;
12
13
  exports.fmtCount = fmtCount;
13
14
  exports.nowLines = nowLines;
14
15
  exports.renderDashboard = renderDashboard;
@@ -16,6 +17,7 @@ exports.createDashboardScreen = createDashboardScreen;
16
17
  const render_1 = require("./render");
17
18
  const request_row_1 = require("./request-row");
18
19
  const app_1 = require("./app");
20
+ const autostart_1 = require("../autostart");
19
21
  /** The landing screen's menu. */
20
22
  exports.MENU = [
21
23
  { key: 'activity', label: 'Activity', hint: 'Every request this node has handled', shortcut: 'a' },
@@ -59,7 +61,27 @@ function headerLines(st) {
59
61
  ? `${(0, render_1.cyan)('⏺')} Viewer ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)} ${(0, render_1.dim)('· another process owns this node')}`
60
62
  : `${(0, render_1.green)('⏺')} Running ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)} ${(0, render_1.dim)(`up ${up}`)} ${(0, render_1.dim)(`${st.inflight} in flight`)}`;
61
63
  const chip = st.offline ? ` ${(0, render_1.yellow)('⚠ Offline · retrying')}` : '';
62
- return [first, daemon + chip];
64
+ return [first, daemon + chip, autostartLine(st)];
65
+ }
66
+ /**
67
+ * Whether the box comes back on its own.
68
+ *
69
+ * On the header, not buried in a menu: "will this node survive a reboot" is a
70
+ * property of the node in the same way "is it online" is, and the answer was
71
+ * previously knowable only by reading the docs and looking for a plist.
72
+ */
73
+ function autostartLine(st) {
74
+ if (st.autostartNote)
75
+ return (0, render_1.dim)(` ${st.autostartNote}`);
76
+ const a = st.autostart;
77
+ if (!a)
78
+ return (0, render_1.dim)(' Starts at login …');
79
+ if (!a.supported)
80
+ return (0, render_1.dim)(` Starts at login ${(0, render_1.grey)('not supported on this platform')}`);
81
+ const issue = a.issues?.length ? ` ${(0, render_1.yellow)(`⚠ ${a.issues[0]}`)}` : '';
82
+ return a.enabled
83
+ ? ` ${(0, render_1.dim)('Starts at login')} ${(0, render_1.green)('yes')}${(0, render_1.dim)(a.supervisor ? ` · ${a.supervisor}` : '')}${issue}`
84
+ : ` ${(0, render_1.dim)('Starts at login')} ${(0, render_1.yellow)('no')} ${(0, render_1.dim)('press s to turn it on')}`;
63
85
  }
64
86
  /** 1204 reads as a year; 1,204 reads as a count. */
65
87
  function fmtCount(n) {
@@ -141,6 +163,7 @@ function renderDashboard(st, width, height) {
141
163
  const foot = (0, app_1.footerHint)([
142
164
  { keys: '↑↓', label: 'move' },
143
165
  { keys: '⏎', label: 'open' },
166
+ { keys: 's', label: st.autostart?.enabled ? 'startup off' : 'startup on' },
144
167
  { keys: 'r', label: 'refresh' },
145
168
  { keys: '?', label: 'keys' },
146
169
  { keys: 'q', label: 'quit' },
@@ -179,8 +202,46 @@ function createDashboardScreen(deps) {
179
202
  if (recent.length)
180
203
  st.recent = recent;
181
204
  st.offline = deps.data.offline();
205
+ // Reads a file (and, on Windows, a cached schtasks query) — cheap enough
206
+ // to ride the same poll, so the header can't disagree with reality after
207
+ // someone changes it from Studio or the CLI.
208
+ st.autostart = (0, autostart_1.status)();
182
209
  deps.host.redraw();
183
210
  };
211
+ /**
212
+ * Turn "start at login" on or off from here.
213
+ *
214
+ * Enabling can stop a daemon — a node started by hand has to stand down so
215
+ * launchd can own it — so the outcome is reported in the header rather than
216
+ * left for the user to infer. The work is deferred a tick so the "Working…"
217
+ * line is actually on screen before the blocking call begins.
218
+ */
219
+ const toggleAutostart = async () => {
220
+ const on = st.autostart?.enabled === true;
221
+ if (st.autostart && !st.autostart.supported) {
222
+ st.autostartNote = `Starts at login isn't supported on ${process.platform}`;
223
+ deps.host.redraw();
224
+ return;
225
+ }
226
+ st.autostartNote = on ? 'Turning off start at login…' : 'Turning on start at login…';
227
+ deps.host.redraw();
228
+ await new Promise(r => setTimeout(r, 0));
229
+ if (on) {
230
+ const r = (0, autostart_1.disable)();
231
+ st.autostartNote = r.ok ? 'This node no longer starts at login' : `Could not turn it off: ${r.error}`;
232
+ }
233
+ else {
234
+ const r = (0, autostart_1.enable)();
235
+ st.autostartNote = r.ok
236
+ ? ['This node now starts when you log in', ...r.warnings].join(' — ')
237
+ : `Could not turn it on: ${r.error}`;
238
+ }
239
+ st.autostart = (0, autostart_1.status)();
240
+ deps.host.redraw();
241
+ // Let the sentence sit long enough to read, then fall back to the state
242
+ // line — a note that never clears becomes furniture.
243
+ setTimeout(() => { st.autostartNote = null; deps.host.redraw(); }, 8000).unref?.();
244
+ };
184
245
  return {
185
246
  id: 'dashboard',
186
247
  title: '+Ai Node',
@@ -202,6 +263,7 @@ function createDashboardScreen(deps) {
202
263
  { keys: 'a', label: 'activity — full request history' },
203
264
  { keys: 'h', label: 'harnesses — install / log in agent CLIs' },
204
265
  { keys: 'l', label: 'logs — daemon output' },
266
+ { keys: 's', label: 'start this node at login (on / off)' },
205
267
  { keys: 'r', label: 'refresh now' },
206
268
  ],
207
269
  async onKey(key) {
@@ -227,6 +289,10 @@ function createDashboardScreen(deps) {
227
289
  deps.openLogs();
228
290
  return;
229
291
  }
292
+ if (key.name === 's') {
293
+ await toggleAutostart();
294
+ return;
295
+ }
230
296
  if (key.name === 'r') {
231
297
  await refresh();
232
298
  return;
@@ -2,6 +2,15 @@ export interface RequestRow {
2
2
  id: string;
3
3
  entity_name: string | null;
4
4
  agent: string;
5
+ /** Where the row ENDED up. `agent` is what this node actually spawned — the
6
+ * ladder mutates the request's agent column in place, so the two differ on
7
+ * a run that hopped. */
8
+ current_agent?: string | null;
9
+ ladder_hops?: number | null;
10
+ /** This node ran it but no longer owns it — it was handed to another
11
+ * machine, and that machine finished it. */
12
+ handed_off?: boolean | null;
13
+ to_runtime_name?: string | null;
5
14
  mode: string;
6
15
  model: string | null;
7
16
  issued_via: string | null;
@@ -19,7 +19,7 @@ export declare function shortModel(model: string | null | undefined): string;
19
19
  export declare function statusLabel(status: string): string;
20
20
  export declare function statusColour(status: string): (s: string) => string;
21
21
  /** Live rows get a moving spinner; settled rows get a dot. */
22
- export declare function marker(status: string, spin: number): string;
22
+ export declare function marker(status: string, spin: number, handedOff?: boolean): string;
23
23
  export declare function durationOf(r: RequestRow, now: number): number | null;
24
24
  /**
25
25
  * Column widths for the request table.
@@ -78,7 +78,12 @@ function statusColour(status) {
78
78
  return render_1.red;
79
79
  }
80
80
  /** Live rows get a moving spinner; settled rows get a dot. */
81
- function marker(status, spin) {
81
+ function marker(status, spin, handedOff = false) {
82
+ // A run this node handed on didn't finish here, whatever the row's terminal
83
+ // status says. Marking it as an outcome would credit this machine with
84
+ // another machine's work.
85
+ if (handedOff)
86
+ return (0, render_1.grey)('↗');
82
87
  const colour = statusColour(status);
83
88
  return colour(exports.ACTIVE.has(status) ? render_1.SPIN[spin % render_1.SPIN.length] : '⏺');
84
89
  }
@@ -106,7 +111,7 @@ function requestColumns(width) {
106
111
  // status, which is the one column that must never be ambiguous.
107
112
  { min: 9 },
108
113
  { min: 10, grow: 1 },
109
- { min: 12 },
114
+ { min: 13 },
110
115
  // 'haiku-4-5' is nine columns once the claude- prefix is stripped.
111
116
  { min: 9 },
112
117
  { min: 8 },
@@ -122,18 +127,27 @@ function requestHeader(width) {
122
127
  function requestLine(r, now, selected, width, spin = 0) {
123
128
  const w = requestColumns(width);
124
129
  const colour = statusColour(r.status);
130
+ const handedOff = r.handed_off === true;
125
131
  // A run that failed once and then succeeded on retry still carries the
126
132
  // old error_code. Showing it on a DONE row reads as "this broke" when
127
133
  // the work actually landed — so the code is only for rows that ended badly.
128
134
  const endedBadly = r.status !== 'completed' && !exports.ACTIVE.has(r.status);
129
- const tail = r.error_code && endedBadly
130
- ? (0, render_1.red)(`[${r.error_code}]`)
131
- : (r.prompt ?? '').replace(/\s+/g, ' ');
135
+ const tail = handedOff
136
+ // Say where it went. Without this the row reads as a run that simply
137
+ // stopped, when in fact it continued somewhere else.
138
+ ? (0, render_1.grey)(`→ ${r.to_runtime_name ?? 'another node'} `) + (0, render_1.dim)((r.prompt ?? '').replace(/\s+/g, ' '))
139
+ : (r.error_code && endedBadly
140
+ ? (0, render_1.red)(`[${r.error_code}]`)
141
+ : (r.prompt ?? '').replace(/\s+/g, ' '));
142
+ // `agent` is what this node spawned. A run that laddered ended somewhere
143
+ // else, and saying so is the difference between a history and a guess.
144
+ const hopped = (r.ladder_hops ?? 0) > 0 && r.current_agent && r.current_agent !== r.agent;
145
+ const agentCell = hopped ? `${r.agent}→${r.current_agent}` : `${r.agent}/${r.mode}`;
132
146
  const cells = [
133
- marker(r.status, spin),
134
- colour(statusLabel(r.status)),
147
+ marker(r.status, spin, handedOff),
148
+ handedOff ? (0, render_1.grey)('Moved') : colour(statusLabel(r.status)),
135
149
  r.entity_name ?? '—',
136
- (0, render_1.dim)(`${r.agent}/${r.mode}`),
150
+ (0, render_1.dim)(agentCell),
137
151
  (0, render_1.dim)(shortModel(r.model)),
138
152
  (0, render_1.dim)(shortVia(r.issued_via)),
139
153
  (0, render_1.dim)((0, render_1.fmtDuration)(durationOf(r, now))),
package/dist/tui/run.js CHANGED
@@ -197,6 +197,9 @@ async function runDashboard(opts) {
197
197
  inflight: 0, paired: (0, store_1.isPaired)(), viewerMode: opts.viewerMode,
198
198
  offline: false, now: Date.now(), version: opts.version,
199
199
  logCount: 0,
200
+ // Read on the first poll rather than here — the first frame must paint
201
+ // before anything touches the filesystem.
202
+ autostart: null, autostartNote: null,
200
203
  };
201
204
  const ui = createConsole((host, suspend) => {
202
205
  const openTranscript = (r) => host.push((0, transcript_1.createTranscriptScreen)({ data, host, request: r }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.5.1",
3
+ "version": "0.6.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": [