@addai/node 0.9.0 → 0.11.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 entities the same, pairing into +Ai Entities rather than Vault
7
+ // ainode entities CODE pair straight away with a code the page minted
7
8
  // ainode harnesses interactive harness manager (install / login)
8
9
  // ainode startup … start this node when the machine starts
9
10
  // ainode unpair --yes disconnect from +Ai, cancel in-flight work
@@ -54,7 +55,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
54
55
  const lockfile_1 = require("./lockfile");
55
56
  const index_1 = require("./index");
56
57
  const store_1 = require("./store");
57
- const supabase_client_1 = require("./supabase-client");
58
+ const unpair_1 = require("./unpair");
58
59
  const run_1 = require("./tui/run");
59
60
  const autostart = __importStar(require("./autostart"));
60
61
  const args = process.argv.slice(2);
@@ -73,21 +74,16 @@ async function cmdUnpair() {
73
74
  }
74
75
  const confirm = args.includes('--yes') || args.includes('-y');
75
76
  if (!confirm) {
76
- console.log('This will disconnect the runtime from +Ai and cancel any in-flight requests.');
77
+ console.log('This disconnects the node from +Ai and cancels any in-flight requests.');
78
+ console.log((0, unpair_1.describeDisconnect)(0));
77
79
  console.log('Re-run with --yes to confirm.');
78
80
  return;
79
81
  }
80
- try {
81
- const r = await (0, supabase_client_1.rpc)('runtime_self_unpair', { p_token: token() });
82
- if (r?.cancelled_requests) {
83
- console.log(`Cancelled ${r.cancelled_requests} in-flight request(s).`);
84
- }
85
- }
86
- catch (err) {
87
- console.error(`server unpair failed (continuing with local cleanup): ${err.message}`);
88
- }
89
- (0, store_1.clearPairing)();
90
- console.log('Unpaired. Run `ainode` to pair again.');
82
+ const r = await (0, unpair_1.disconnectNode)();
83
+ console.log((0, unpair_1.summariseDisconnect)(r));
84
+ if (!r.ok)
85
+ process.exit(1);
86
+ console.log('Run `ainode` to pair again.');
91
87
  }
92
88
  function cmdStartup() {
93
89
  const sub = args[1] ?? 'status';
@@ -199,6 +195,7 @@ async function main() {
199
195
  usage:
200
196
  ainode run this +Ai Node — daemon + live dashboard
201
197
  ainode entities the same, but pair into +Ai Entities
198
+ ainode entities <CODE> pair with a code from the page — no browser
202
199
  ainode harnesses install / log in agent harnesses (interactive)
203
200
  ainode startup enable start this node whenever you log in
204
201
  ainode startup disable stop starting at login
@@ -210,8 +207,8 @@ Piped or headless output falls back to plain log lines. Set AINODE_NO_TUI=1
210
207
  to force that on a terminal. \`ainode run --ensure\` starts a node only if one
211
208
  isn't already running — what the Windows startup task uses.
212
209
 
213
- Pair the node by running it with no args and following the prompt
214
- to vault.add.ai/entity/connect/<CODE>.
210
+ Pairing opens your browser to confirm the machine; nothing to type. Over SSH
211
+ or on a headless box it prints a code and a link instead.
215
212
  `);
216
213
  return;
217
214
  }
@@ -18,3 +18,13 @@ export interface BrowserEnv {
18
18
  }
19
19
  /** Would opening a browser here actually show the user anything? */
20
20
  export declare function canOpenBrowser({ isTTY, platform, env }: BrowserEnv): boolean;
21
+ /**
22
+ * The pairing code someone pasted into the command, if there is one.
23
+ *
24
+ * `npx -y @addai/node@latest entities WXYZ4` is the flow where the page mints
25
+ * the code and the browser never opens. Everything else here — no argument, a
26
+ * flag, something that is not code-shaped — falls through to the normal flow.
27
+ * Refusing to guess matters: a mistyped code that we accepted anyway would
28
+ * sit in the finalize loop for fifteen minutes before admitting it.
29
+ */
30
+ export declare function pairCodeFromArgv(argv: string[]): string | null;
@@ -15,6 +15,7 @@
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.pairDestination = pairDestination;
17
17
  exports.canOpenBrowser = canOpenBrowser;
18
+ exports.pairCodeFromArgv = pairCodeFromArgv;
18
19
  const config_1 = require("./config");
19
20
  const destination = (product, url) => ({
20
21
  product,
@@ -57,3 +58,30 @@ function canOpenBrowser({ isTTY, platform, env }) {
57
58
  return false;
58
59
  return true;
59
60
  }
61
+ /** Pairing codes are five characters of A–Z and 0–9. */
62
+ const CODE_SHAPE = /^[A-Z0-9]{5}$/;
63
+ /**
64
+ * The pairing code someone pasted into the command, if there is one.
65
+ *
66
+ * `npx -y @addai/node@latest entities WXYZ4` is the flow where the page mints
67
+ * the code and the browser never opens. Everything else here — no argument, a
68
+ * flag, something that is not code-shaped — falls through to the normal flow.
69
+ * Refusing to guess matters: a mistyped code that we accepted anyway would
70
+ * sit in the finalize loop for fifteen minutes before admitting it.
71
+ */
72
+ function pairCodeFromArgv(argv) {
73
+ for (const raw of argv) {
74
+ if (raw.startsWith('-'))
75
+ continue;
76
+ // Skip the subcommand itself.
77
+ if (['entities', 'run', 'start'].includes(raw))
78
+ continue;
79
+ const cleaned = raw.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
80
+ if (CODE_SHAPE.test(cleaned))
81
+ return cleaned;
82
+ // A non-flag argument that is not a code is a mistake, not a code we
83
+ // should keep hunting for behind it.
84
+ return null;
85
+ }
86
+ return null;
87
+ }
package/dist/pairing.js CHANGED
@@ -82,7 +82,11 @@ async function awaitPairing(code) {
82
82
  const deadline = Date.now() + config_1.PAIR_TIMEOUT_MS;
83
83
  while (Date.now() < deadline) {
84
84
  try {
85
- const rows = await (0, supabase_client_1.rpc)('runtime_pair_finalize', { p_code: code });
85
+ const rows = await (0, supabase_client_1.rpc)('runtime_pair_finalize',
86
+ // The machine is the authority on what it is called. A code minted by
87
+ // the browser carries no hostname, so without this every node paired
88
+ // from the page arrives called "Untitled runtime".
89
+ { p_code: code, p_hostname: os.hostname(), p_platform: platform() });
86
90
  // PostgREST returns a `setof record` as an array of objects.
87
91
  const row = Array.isArray(rows) ? rows[0] : rows;
88
92
  if (row?.daemon_token) {
@@ -131,6 +135,16 @@ async function awaitPairing(code) {
131
135
  async function runPairing(argv) {
132
136
  const dest = (0, pair_destination_1.pairDestination)(argv);
133
137
  const hostname = os.hostname();
138
+ // A code handed to us on the command line was minted by a page the user was
139
+ // already looking at, and claimed by them as it was made. There is nothing
140
+ // to ask and nobody to send anywhere: finalize it and get on with running.
141
+ const given = (0, pair_destination_1.pairCodeFromArgv)(argv);
142
+ if (given) {
143
+ process.stdout.write(`\n Connecting ${hostname} to ${dest.product}…\n`);
144
+ const result = await awaitPairing(given);
145
+ process.stdout.write(` ✓ Connected as ${hostname}\n\n`);
146
+ return result;
147
+ }
134
148
  const interactive = (0, pair_destination_1.canOpenBrowser)({
135
149
  isTTY: Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY),
136
150
  platform: process.platform,
@@ -22,6 +22,8 @@ export interface DashboardState {
22
22
  inflight: number;
23
23
  paired: boolean;
24
24
  viewerMode: boolean;
25
+ /** First press of 'd' asks; the second one does it. */
26
+ disconnectArmed?: boolean;
25
27
  offline: boolean;
26
28
  now: number;
27
29
  version: string;
@@ -19,6 +19,8 @@ exports.createDashboardScreen = createDashboardScreen;
19
19
  const render_1 = require("./render");
20
20
  const request_row_1 = require("./request-row");
21
21
  const app_1 = require("./app");
22
+ const unpair_1 = require("../unpair");
23
+ const store_1 = require("../store");
22
24
  const autostart_1 = require("../autostart");
23
25
  /** The landing screen's menu. */
24
26
  exports.MENU = [
@@ -71,7 +73,7 @@ function headerLines(st) {
71
73
  if (!st.paired) {
72
74
  return [
73
75
  (0, render_1.yellow)('Not paired'),
74
- (0, render_1.dim)('run `ainode` and follow the prompt to vault.add.ai/entity/connect/<CODE>'),
76
+ (0, render_1.dim)('run `ainode` to pair again it opens your browser to finish up'),
75
77
  ];
76
78
  }
77
79
  const self = st.self;
@@ -174,7 +176,13 @@ function nowLines(st, width, rows) {
174
176
  function renderDashboard(st, width, height) {
175
177
  const head = (0, render_1.panel)(`+Ai Node ${(0, render_1.dim)(`@addai/node v${st.version}`)}`, headerLines(st), width);
176
178
  if (!st.paired) {
177
- return [...head, '', (0, app_1.footerHint)([{ keys: 'q', label: 'quit' }])].slice(0, height);
179
+ // The note has to survive the transition into this screen. Disconnecting
180
+ // flips `paired` to false, and without this the sentence saying how it
181
+ // went — including "+Ai could not be told" — was rendered for a frame and
182
+ // then replaced by a screen that had no room for it. The outcome of the
183
+ // one destructive action in here must not be the thing you cannot read.
184
+ const note = st.autostartNote ? ['', ` ${st.autostartNote}`] : [];
185
+ return [...head, ...note, '', (0, app_1.footerHint)([{ keys: 'q', label: 'quit' }])].slice(0, height);
178
186
  }
179
187
  const stats = statsLines(st).map(l => ` ${l}`);
180
188
  // The shortcut letters line up in a column of their own, so the eye can
@@ -198,6 +206,10 @@ function renderDashboard(st, width, height) {
198
206
  { keys: '?', label: 'keys' },
199
207
  { keys: 'q', label: 'quit' },
200
208
  ]);
209
+ // 'd' is deliberately absent from this row. The footer is the set of things
210
+ // it is safe to press to find out what they do, and disconnect is not one
211
+ // of them; it lives in the `?` list, where you go when you are looking for
212
+ // something specific.
201
213
  // Everything but the NOW band is fixed height, so the band takes what is
202
214
  // left: heading + header row + rows + the "+N more" line.
203
215
  const fixed = head.length + 1 + stats.length + 1 + 3 + 1 + menu.length + 1 + 1;
@@ -277,6 +289,40 @@ function createDashboardScreen(deps) {
277
289
  // line — a note that never clears becomes furniture.
278
290
  setTimeout(() => { st.autostartNote = null; deps.host.redraw(); }, 8000).unref?.();
279
291
  };
292
+ /* ── disconnect ────────────────────────────────────────────────────────
293
+ The one key in here that takes work away from people, so it is armed
294
+ rather than instant: the first press asks, naming what it will cancel,
295
+ and the second does it. Any other key disarms, which means a stray 'd'
296
+ costs nothing.
297
+
298
+ Afterwards the daemon has no token and cannot do anything useful, so the
299
+ console closes with it — except in viewer mode, where the daemon is a
300
+ different process this console does not own and can only report on. */
301
+ const disconnect = async () => {
302
+ if (!st.disconnectArmed) {
303
+ st.disconnectArmed = true;
304
+ st.autostartNote = `Disconnect this node? ${(0, unpair_1.describeDisconnect)(st.inflight)} Press d again to confirm.`;
305
+ deps.host.redraw();
306
+ return;
307
+ }
308
+ st.disconnectArmed = false;
309
+ st.autostartNote = 'Disconnecting…';
310
+ deps.host.redraw();
311
+ await new Promise(r => setTimeout(r, 0));
312
+ const r = await (0, unpair_1.disconnectNode)();
313
+ st.paired = (0, store_1.isPaired)();
314
+ st.autostartNote = (0, unpair_1.summariseDisconnect)(r);
315
+ deps.host.redraw();
316
+ if (!r.ok)
317
+ return;
318
+ if (st.viewerMode) {
319
+ st.autostartNote = `${(0, unpair_1.summariseDisconnect)(r)} The daemon (pid ${st.pid ?? '?'}) is still running — stop it to finish.`;
320
+ deps.host.redraw();
321
+ return;
322
+ }
323
+ // Let the sentence land before the screen goes away.
324
+ setTimeout(() => deps.host.quit(), 1600).unref?.();
325
+ };
280
326
  /**
281
327
  * One cursor over two stacked lists: the NOW band sits above the menu, so
282
328
  * ↑ off the top of the menu lands on the LAST live row (the one nearest the
@@ -337,6 +383,7 @@ function createDashboardScreen(deps) {
337
383
  { keys: 'l', label: 'logs — daemon output' },
338
384
  { keys: 's', label: 'start this node at login (on / off)' },
339
385
  { keys: 'r', label: 'refresh now' },
386
+ { keys: 'd', label: 'disconnect this node from +Ai' },
340
387
  ],
341
388
  async onKey(key) {
342
389
  if (key.name === 'up' || key.name === 'k') {
@@ -369,6 +416,17 @@ function createDashboardScreen(deps) {
369
416
  await refresh();
370
417
  return;
371
418
  }
419
+ if (key.name === 'd') {
420
+ await disconnect();
421
+ return;
422
+ }
423
+ // Anything else disarms a pending disconnect: an armed destructive
424
+ // action must not survive the user walking away and coming back.
425
+ if (st.disconnectArmed) {
426
+ st.disconnectArmed = false;
427
+ st.autostartNote = null;
428
+ deps.host.redraw();
429
+ }
372
430
  if (key.name === 'return') {
373
431
  // A selected run wins over the menu: the cursor is visibly on it.
374
432
  const live = selectableNow(st)[nowIndex(st)];
@@ -0,0 +1,20 @@
1
+ export interface DisconnectResult {
2
+ ok: boolean;
3
+ cancelled: number;
4
+ /** The local pairing was cleared but +Ai was not told. */
5
+ serverFailed?: boolean;
6
+ error?: string;
7
+ }
8
+ /** The question to ask before doing it. */
9
+ export declare function describeDisconnect(inflight: number): string;
10
+ /** What to say once it is done. */
11
+ export declare function summariseDisconnect(r: DisconnectResult): string;
12
+ /**
13
+ * Tell +Ai, then forget the pairing locally.
14
+ *
15
+ * Local cleanup happens even when the server call fails. A node whose token
16
+ * the user has decided to abandon should not keep using it because the network
17
+ * was down at the wrong moment — and the alternative, refusing to disconnect,
18
+ * strands them on a machine they may be trying to hand back.
19
+ */
20
+ export declare function disconnectNode(): Promise<DisconnectResult>;
package/dist/unpair.js ADDED
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ // Disconnecting a node from +Ai.
3
+ //
4
+ // Shared by `ainode unpair` and the console's disconnect key, because the two
5
+ // must not drift: this is the one action that takes work away from people, and
6
+ // a version of it that forgets to cancel in-flight runs would leave whoever
7
+ // was waiting on them waiting forever.
8
+ //
9
+ // The server soft-disables rather than deletes: the runtime row, its history
10
+ // and its chats all survive, the token is scrubbed, and the status becomes
11
+ // 'unpaired'. Reconnect on the AiNodes page mints a fresh code and the same
12
+ // machine comes back as itself. That is worth saying out loud in the prompt —
13
+ // someone who believes this is permanent will not press it when they should.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.describeDisconnect = describeDisconnect;
16
+ exports.summariseDisconnect = summariseDisconnect;
17
+ exports.disconnectNode = disconnectNode;
18
+ const supabase_client_1 = require("./supabase-client");
19
+ const store_1 = require("./store");
20
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
21
+ /** The question to ask before doing it. */
22
+ function describeDisconnect(inflight) {
23
+ const cost = inflight > 0
24
+ ? `This cancels ${plural(inflight, 'run')} still in flight. `
25
+ : '';
26
+ return `${cost}The node keeps its history and can reconnect later.`;
27
+ }
28
+ /** What to say once it is done. */
29
+ function summariseDisconnect(r) {
30
+ if (!r.ok)
31
+ return `Could not disconnect: ${r.error ?? 'unknown error'}`;
32
+ if (r.serverFailed) {
33
+ // Honest about the half that worked: this machine has forgotten its
34
+ // pairing, but +Ai still lists the node as paired until it is removed
35
+ // there. Saying only "disconnected" leaves that to be discovered — and
36
+ // saying it without the reason leaves nobody able to act on it, which is
37
+ // how a transient 4s RPC timeout and a revoked grant look identical.
38
+ const why = r.error ? ` (${r.error})` : '';
39
+ return `Disconnected on this machine, but +Ai could not be told${why}. Remove the node there too.`;
40
+ }
41
+ const tail = r.cancelled > 0 ? ` ${plural(r.cancelled, 'run')} cancelled.` : '';
42
+ return `Disconnected from +Ai.${tail}`;
43
+ }
44
+ /**
45
+ * Tell +Ai, then forget the pairing locally.
46
+ *
47
+ * Local cleanup happens even when the server call fails. A node whose token
48
+ * the user has decided to abandon should not keep using it because the network
49
+ * was down at the wrong moment — and the alternative, refusing to disconnect,
50
+ * strands them on a machine they may be trying to hand back.
51
+ */
52
+ async function disconnectNode() {
53
+ if (!(0, store_1.isPaired)())
54
+ return { ok: false, cancelled: 0, error: 'this node is not paired' };
55
+ const token = (0, store_1.readPairing)()?.daemonToken;
56
+ if (!token)
57
+ return { ok: false, cancelled: 0, error: 'this node is not paired' };
58
+ let cancelled = 0;
59
+ let serverFailed = false;
60
+ let serverError;
61
+ try {
62
+ const r = await (0, supabase_client_1.rpc)('runtime_self_unpair', { p_token: token });
63
+ cancelled = r?.cancelled_requests ?? 0;
64
+ }
65
+ catch (err) {
66
+ serverFailed = true;
67
+ serverError = err?.message || String(err);
68
+ }
69
+ (0, store_1.clearPairing)();
70
+ return { ok: true, cancelled, serverFailed, error: serverError };
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.9.0",
3
+ "version": "0.11.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": [