@addai/node 0.5.1 → 0.7.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/autostart-mac.d.ts +62 -0
- package/dist/autostart-mac.js +306 -0
- package/dist/autostart-win.d.ts +46 -0
- package/dist/autostart-win.js +290 -0
- package/dist/autostart.d.ts +81 -0
- package/dist/autostart.js +224 -0
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +87 -1
- package/dist/command-runner.d.ts +3 -0
- package/dist/command-runner.js +63 -7
- package/dist/index.js +34 -12
- package/dist/self-update.d.ts +25 -0
- package/dist/self-update.js +21 -0
- package/dist/tui/dashboard.d.ts +14 -0
- package/dist/tui/dashboard.js +67 -1
- package/dist/tui/data.d.ts +9 -0
- package/dist/tui/request-row.d.ts +1 -1
- package/dist/tui/request-row.js +22 -8
- package/dist/tui/run.js +3 -0
- package/package.json +1 -1
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>.
|
package/dist/command-runner.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export type RestartHook = (opts: {
|
|
2
2
|
commandId: string;
|
|
3
3
|
version: string;
|
|
4
|
+
/** Bounce this daemon on the version it is already running: no npm
|
|
5
|
+
* install, no version change. See runUpdateRuntime. */
|
|
6
|
+
restartOnly?: boolean;
|
|
4
7
|
}) => Promise<void>;
|
|
5
8
|
export declare function setRestartHook(fn: RestartHook): void;
|
|
6
9
|
/** Called from index.ts when a heartbeat reports pending_commands > 0.
|
package/dist/command-runner.js
CHANGED
|
@@ -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;
|
|
@@ -478,26 +479,45 @@ async function runLogout(cmd, spec) {
|
|
|
478
479
|
/* ── update_runtime ──────────────────────────────────────────────────────
|
|
479
480
|
* Roll THIS node to a version (default: npm latest) and come back up.
|
|
480
481
|
*
|
|
482
|
+
* Two shapes share the command:
|
|
483
|
+
*
|
|
484
|
+
* {} roll to npm latest
|
|
485
|
+
* {"version": "0.6.0"} roll to a pinned version
|
|
486
|
+
* {"restart_only": true} bounce the daemon on the version it is already
|
|
487
|
+
* running — no npm install, no version change
|
|
488
|
+
*
|
|
489
|
+
* restart_only exists because the reasons to bounce a node are mostly not
|
|
490
|
+
* version reasons: a credential edit the running daemon has cached, a wedged
|
|
491
|
+
* harness, a machine that has been up for a fortnight. Doing that through a
|
|
492
|
+
* version roll meant an unwanted upgrade every time, and could not be done at
|
|
493
|
+
* all on a node running from a source checkout — where a restart is exactly
|
|
494
|
+
* the safe half of the operation.
|
|
495
|
+
*
|
|
496
|
+
* An older daemon that has never heard of restart_only still does the right
|
|
497
|
+
* thing, because the caller also pins `version` to the version it can see the
|
|
498
|
+
* node running: the old code installs the version already installed and
|
|
499
|
+
* restarts. Same destination, one wasted npm call.
|
|
500
|
+
*
|
|
481
501
|
* The command is deliberately left `running` here: this process is about to
|
|
482
502
|
* stop existing, so it cannot honestly report the outcome. index.ts's boot
|
|
483
503
|
* path completes it from the handoff file once the replacement daemon is
|
|
484
504
|
* actually up, with whatever version it actually came up as. */
|
|
485
505
|
async function runUpdateRuntime(cmd) {
|
|
486
|
-
const target = (cmd.input?.version ?? '').trim() || 'latest';
|
|
487
506
|
if (!restartHook) {
|
|
488
507
|
await update(cmd.id, 'failed', {}, 'this daemon is too old to restart itself');
|
|
489
508
|
return;
|
|
490
509
|
}
|
|
491
510
|
const mode = (0, self_update_1.detectLaunchMode)(process.argv[1] ?? '');
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
await update(cmd.id, 'failed', { mode }, 'this node runs from a source checkout — pull and rebuild it there; a remote version bump cannot apply');
|
|
511
|
+
const { target, restartOnly, refusal } = (0, self_update_1.planRoll)(cmd.input, mode, VERSION);
|
|
512
|
+
if (refusal) {
|
|
513
|
+
await update(cmd.id, 'failed', { mode }, refusal);
|
|
496
514
|
return;
|
|
497
515
|
}
|
|
498
|
-
await update(cmd.id, 'running', {
|
|
516
|
+
await update(cmd.id, 'running', {
|
|
517
|
+
step: 'draining', mode, target_version: target, from_version: VERSION, restart_only: restartOnly,
|
|
518
|
+
});
|
|
499
519
|
try {
|
|
500
|
-
await restartHook({ commandId: cmd.id, version: target });
|
|
520
|
+
await restartHook({ commandId: cmd.id, version: target, restartOnly });
|
|
501
521
|
}
|
|
502
522
|
catch (err) {
|
|
503
523
|
// The hook owns the point of no return (it writes the handoff only once
|
|
@@ -505,6 +525,38 @@ async function runUpdateRuntime(cmd) {
|
|
|
505
525
|
await update(cmd.id, 'failed', { mode }, `restart failed: ${err.message}`);
|
|
506
526
|
}
|
|
507
527
|
}
|
|
528
|
+
/* ── set_autostart ───────────────────────────────────────────────────────
|
|
529
|
+
* Arm (or disarm) "start when the machine starts" on a node you are not
|
|
530
|
+
* sitting at. Unlike a roll, this one CAN honestly report its own outcome:
|
|
531
|
+
* nothing restarts, so the process that did the work is still here to say
|
|
532
|
+
* what happened — and it reports the state it actually READ BACK afterwards,
|
|
533
|
+
* not the state it intended.
|
|
534
|
+
*
|
|
535
|
+
* Enabling under launchd hands the node over: a daemon started by hand is
|
|
536
|
+
* asked to drain and stop so launchd can own it. That means this command can
|
|
537
|
+
* legitimately stop and restart the daemon underneath the caller. It does not
|
|
538
|
+
* restart THIS process, so the reporting contract still holds. */
|
|
539
|
+
async function runSetAutostart(cmd) {
|
|
540
|
+
const want = cmd.input?.enabled !== false;
|
|
541
|
+
await update(cmd.id, 'running', { step: want ? 'enabling' : 'disabling' });
|
|
542
|
+
if (want) {
|
|
543
|
+
const r = (0, autostart_1.enable)({ force: cmd.input?.force === true });
|
|
544
|
+
const state = (0, autostart_1.status)();
|
|
545
|
+
if (!r.ok) {
|
|
546
|
+
await update(cmd.id, 'failed', { autostart: state }, r.error ?? 'could not enable startup');
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
await update(cmd.id, 'completed', { autostart: state, path: r.path, warnings: r.warnings });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const r = (0, autostart_1.disable)();
|
|
553
|
+
const state = (0, autostart_1.status)();
|
|
554
|
+
if (!r.ok) {
|
|
555
|
+
await update(cmd.id, 'failed', { autostart: state }, r.error ?? 'could not disable startup');
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
await update(cmd.id, 'completed', { autostart: state });
|
|
559
|
+
}
|
|
508
560
|
/* ── dispatcher ──────────────────────────────────────────────────────── */
|
|
509
561
|
async function execute(cmd) {
|
|
510
562
|
// Not a harness command — dispatch before the harness lookup, which would
|
|
@@ -513,6 +565,10 @@ async function execute(cmd) {
|
|
|
513
565
|
await runUpdateRuntime(cmd);
|
|
514
566
|
return;
|
|
515
567
|
}
|
|
568
|
+
if (cmd.kind === 'set_autostart') {
|
|
569
|
+
await runSetAutostart(cmd);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
516
572
|
const spec = (0, harness_registry_1.harness)(cmd.harness ?? '');
|
|
517
573
|
if (!spec) {
|
|
518
574
|
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,14 +237,28 @@ 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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
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.
|
|
251
|
+
//
|
|
252
|
+
// A restart-only bounce takes the same path minus the install: `version` is
|
|
253
|
+
// already this process's version, so the respawn plan pins the replacement
|
|
254
|
+
// to the same bits (which matters for npx, whose cache dir is per-version)
|
|
255
|
+
// and npm is never called — nothing to fetch, and nothing that can fail
|
|
256
|
+
// offline.
|
|
257
|
+
(0, command_runner_1.setRestartHook)(async ({ commandId, version, restartOnly }) => {
|
|
244
258
|
const mode = (0, self_update_1.detectLaunchMode)(process.argv[1] ?? '');
|
|
245
259
|
const plan = (0, self_update_1.planRespawn)(mode, process.argv[1] ?? '', version, process.execPath, process.argv.slice(2));
|
|
246
|
-
|
|
260
|
+
const supervised = (0, autostart_1.isSupervised)();
|
|
261
|
+
if (plan.installFirst && !restartOnly) {
|
|
247
262
|
const err = await (0, self_update_1.installGlobal)(version);
|
|
248
263
|
if (err)
|
|
249
264
|
throw new Error(err);
|
|
@@ -251,11 +266,13 @@ async function start() {
|
|
|
251
266
|
// Drain generously — a restart is elective, so it should never guillotine
|
|
252
267
|
// a live entity turn the way a 15s SIGTERM drain would.
|
|
253
268
|
await stop(RESTART_DRAIN_MS);
|
|
254
|
-
// Point of no return. Past this line a replacement daemon exists
|
|
255
|
-
// MUST exit — two live daemons on
|
|
256
|
-
//
|
|
257
|
-
// never abort the exit.
|
|
258
|
-
|
|
269
|
+
// Point of no return. Past this line a replacement daemon either exists or
|
|
270
|
+
// is guaranteed by the supervisor, so we MUST exit — two live daemons on
|
|
271
|
+
// one node is worse than a command left unreported. Hence the handoff
|
|
272
|
+
// write (which only affects reporting) can never abort the exit.
|
|
273
|
+
if (!supervised) {
|
|
274
|
+
(0, self_update_1.spawnReplacement)(plan, path.join(paths_1.RUNTIME_HOME, 'daemon.log'), process.cwd());
|
|
275
|
+
}
|
|
259
276
|
try {
|
|
260
277
|
(0, self_update_1.writeHandoff)(paths_1.RUNTIME_HOME, {
|
|
261
278
|
command_id: commandId,
|
|
@@ -265,12 +282,16 @@ async function start() {
|
|
|
265
282
|
at: new Date().toISOString(),
|
|
266
283
|
drained: lastDrain.drained,
|
|
267
284
|
timed_out: lastDrain.timedOut,
|
|
285
|
+
restart_only: restartOnly === true,
|
|
268
286
|
});
|
|
269
287
|
}
|
|
270
288
|
catch (err) {
|
|
271
289
|
console.error('[restart] handoff write failed — the roll still happened, it just cannot self-report:', err.message);
|
|
272
290
|
}
|
|
273
|
-
|
|
291
|
+
const what = restartOnly ? `${mode} → restart on ${version}` : `${mode} → ${version}`;
|
|
292
|
+
console.log(supervised
|
|
293
|
+
? `[restart] standing down for launchd to restart us (${what}); exiting`
|
|
294
|
+
: `[restart] handed over to ${plan.file} (${what}); exiting`);
|
|
274
295
|
setTimeout(() => process.exit(0), 250).unref?.();
|
|
275
296
|
});
|
|
276
297
|
process.once('SIGINT', () => { stop().finally(() => process.exit(0)); });
|
|
@@ -382,6 +403,7 @@ async function finalizeRestartHandoff() {
|
|
|
382
403
|
version: VERSION,
|
|
383
404
|
mode: h.mode,
|
|
384
405
|
restarted: true,
|
|
406
|
+
restart_only: h.restart_only === true,
|
|
385
407
|
version_changed: changed,
|
|
386
408
|
drained: h.drained ?? 0,
|
|
387
409
|
// >0 means the drain ceiling expired with work still running — the
|
package/dist/self-update.d.ts
CHANGED
|
@@ -45,6 +45,27 @@ export declare function planRespawn(mode: LaunchMode, entryPath: string, targetV
|
|
|
45
45
|
* slice(2)) — carried across the restart so a node launched as
|
|
46
46
|
* `… cli.js run --foo` doesn't silently come back up without them. */
|
|
47
47
|
userArgs?: string[]): RespawnPlan;
|
|
48
|
+
export interface RollPlan {
|
|
49
|
+
/** Version the replacement must come up as. */
|
|
50
|
+
target: string;
|
|
51
|
+
/** A bounce on the running version: nothing is installed, nothing moves. */
|
|
52
|
+
restartOnly: boolean;
|
|
53
|
+
/** Non-null = refuse before anything drains, with this explanation. */
|
|
54
|
+
refusal: string | null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Read an `update_runtime` command's input into what this node should do.
|
|
58
|
+
*
|
|
59
|
+
* Pure so the one rule that is easy to get wrong stays testable: a source
|
|
60
|
+
* checkout may not be version-bumped remotely (its version comes from the
|
|
61
|
+
* working tree, so it would restart and report the same version — a silent
|
|
62
|
+
* no-op dressed up as success) but it MAY be restarted, which is the half of
|
|
63
|
+
* the operation that works there.
|
|
64
|
+
*/
|
|
65
|
+
export declare function planRoll(input: {
|
|
66
|
+
version?: string;
|
|
67
|
+
restart_only?: boolean;
|
|
68
|
+
} | null, mode: LaunchMode, currentVersion: string): RollPlan;
|
|
48
69
|
export interface RestartHandoff {
|
|
49
70
|
command_id: string;
|
|
50
71
|
from_version: string;
|
|
@@ -56,6 +77,10 @@ export interface RestartHandoff {
|
|
|
56
77
|
* work unfinished rather than claiming a clean restart either way. */
|
|
57
78
|
drained?: number;
|
|
58
79
|
timed_out?: number;
|
|
80
|
+
/** True when this was a bounce on the running version rather than a roll,
|
|
81
|
+
* so the completed command says which one actually happened instead of
|
|
82
|
+
* leaving it to be inferred from two versions that match. */
|
|
83
|
+
restart_only?: boolean;
|
|
59
84
|
}
|
|
60
85
|
export declare function handoffPath(runtimeDir: string): string;
|
|
61
86
|
export declare function writeHandoff(runtimeDir: string, h: RestartHandoff): void;
|
package/dist/self-update.js
CHANGED
|
@@ -55,6 +55,7 @@ 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.planRoll = planRoll;
|
|
58
59
|
exports.handoffPath = handoffPath;
|
|
59
60
|
exports.writeHandoff = writeHandoff;
|
|
60
61
|
exports.takeHandoff = takeHandoff;
|
|
@@ -112,6 +113,26 @@ userArgs = []) {
|
|
|
112
113
|
canChangeVersion: mode === 'global',
|
|
113
114
|
};
|
|
114
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Read an `update_runtime` command's input into what this node should do.
|
|
118
|
+
*
|
|
119
|
+
* Pure so the one rule that is easy to get wrong stays testable: a source
|
|
120
|
+
* checkout may not be version-bumped remotely (its version comes from the
|
|
121
|
+
* working tree, so it would restart and report the same version — a silent
|
|
122
|
+
* no-op dressed up as success) but it MAY be restarted, which is the half of
|
|
123
|
+
* the operation that works there.
|
|
124
|
+
*/
|
|
125
|
+
function planRoll(input, mode, currentVersion) {
|
|
126
|
+
const restartOnly = input?.restart_only === true;
|
|
127
|
+
// A restart targets the version already running, which keeps the boot-side
|
|
128
|
+
// "did we come up as what was asked for?" check meaningful instead of
|
|
129
|
+
// special-cased — and pins npx, whose cache dir is per-version.
|
|
130
|
+
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;
|
|
134
|
+
return { target, restartOnly, refusal };
|
|
135
|
+
}
|
|
115
136
|
function handoffPath(runtimeDir) {
|
|
116
137
|
return path.join(runtimeDir, 'restart.json');
|
|
117
138
|
}
|
package/dist/tui/dashboard.d.ts
CHANGED
|
@@ -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[];
|
package/dist/tui/dashboard.js
CHANGED
|
@@ -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;
|
package/dist/tui/data.d.ts
CHANGED
|
@@ -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.
|
package/dist/tui/request-row.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
130
|
-
|
|
131
|
-
|
|
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)(
|
|
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))),
|