@addai/node 0.30.9 → 0.30.10

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
@@ -202,6 +202,10 @@ async function main() {
202
202
  pid: runtime.pid,
203
203
  startedAt: Date.now(),
204
204
  version: index_1.VERSION,
205
+ // The daemon is running in this process, so the dashboard needs a way to
206
+ // stop it properly when the user actually asks — and, just as important,
207
+ // a way to tell that case apart from the terminal disappearing.
208
+ stop: runtime.stop,
205
209
  });
206
210
  return;
207
211
  }
package/dist/index.js CHANGED
@@ -376,7 +376,7 @@ async function sweepStaleSessionDirs() {
376
376
  // eslint-disable-next-line @typescript-eslint/no-require-imports
377
377
  const path = require('path');
378
378
  // eslint-disable-next-line @typescript-eslint/no-require-imports
379
- const { RUNTIME_SESSIONS_DIR } = require('./paths');
379
+ const { RUNTIME_SESSIONS_DIR, RUNTIME_LOG_FILE } = require('./paths');
380
380
  if (!fs.existsSync(RUNTIME_SESSIONS_DIR))
381
381
  return;
382
382
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
@@ -404,30 +404,63 @@ async function sweepStaleSessionDirs() {
404
404
  catch { /* unreadable dir — fall back to its own mtime */ }
405
405
  return newest;
406
406
  };
407
- let removed = 0;
407
+ // Deleting a session dir un-homes any chat still resuming a session keyed to
408
+ // it, so the names have to be recoverable — a bare count made that class of
409
+ // breakage invisible. But a node that has been up for months sweeps hundreds
410
+ // at once, and one console line each buried the dashboard under a screenful
411
+ // of scrollback on every single start. So: the full list goes to the daemon
412
+ // log, and the terminal gets a sample and the totals.
413
+ const SHOWN = 5;
414
+ const swept = [];
415
+ const failures = [];
408
416
  let kept = 0;
409
417
  for (const name of fs.readdirSync(RUNTIME_SESSIONS_DIR)) {
410
418
  const p = path.join(RUNTIME_SESSIONS_DIR, name);
419
+ let st;
411
420
  try {
412
- const st = fs.statSync(p);
413
- if (!st.isDirectory())
414
- continue;
415
- if (effectiveMtimeMs(p, st.mtimeMs) < cutoff) {
416
- // Deleting a session dir un-homes any chat still resuming a session
417
- // keyed to it, so say which ones went — the old count-only line made
418
- // that class of breakage invisible.
419
- console.log(`[startup] sweeping stale session dir: ${p}`);
420
- fs.rmSync(p, { recursive: true, force: true });
421
- removed++;
422
- }
423
- else {
424
- kept++;
425
- }
421
+ st = fs.statSync(p);
422
+ }
423
+ catch {
424
+ continue; /* unreadable entry */
425
+ }
426
+ if (!st.isDirectory())
427
+ continue;
428
+ if (effectiveMtimeMs(p, st.mtimeMs) >= cutoff) {
429
+ kept++;
430
+ continue;
431
+ }
432
+ try {
433
+ fs.rmSync(p, { recursive: true, force: true });
434
+ swept.push(p);
435
+ }
436
+ catch (err) {
437
+ // A dir we cannot delete comes back every start. It used to be announced
438
+ // as swept each time, forever, with the real error swallowed — so the
439
+ // one line that would explain a home disk filling up never appeared.
440
+ failures.push(`${p}: ${err.message}`);
426
441
  }
427
- catch { /* skip unreadable entry */ }
428
442
  }
429
- if (removed > 0) {
430
- console.log(`[startup] swept ${removed} session dir(s) unused for 7d; ${kept} kept`);
443
+ if (swept.length === 0 && failures.length === 0)
444
+ return;
445
+ // Append before logging: the console line points at this file, so it had
446
+ // better already say what happened by the time anyone opens it.
447
+ try {
448
+ const stamp = new Date().toISOString();
449
+ const body = [
450
+ ...swept.map(p => `${stamp} [startup] swept stale session dir: ${p}`),
451
+ ...failures.map(l => `${stamp} [startup] could NOT sweep session dir ${l}`),
452
+ ].join('\n');
453
+ fs.appendFileSync(RUNTIME_LOG_FILE, `${body}\n`);
454
+ }
455
+ catch { /* the sweep still happened; losing the record is not fatal */ }
456
+ if (swept.length > 0) {
457
+ const sample = swept.slice(0, SHOWN).map(p => path.basename(p));
458
+ const more = swept.length > SHOWN ? `, +${swept.length - SHOWN} more` : '';
459
+ console.log(`[startup] swept ${swept.length} session dir(s) unused for 7d; ${kept} kept`);
460
+ console.log(`[startup] ${sample.join(', ')}${more} — full list in ${RUNTIME_LOG_FILE}`);
461
+ }
462
+ if (failures.length > 0) {
463
+ console.error(`[startup] ${failures.length} stale session dir(s) could not be deleted — first: ${failures[0]}`);
431
464
  }
432
465
  }
433
466
  /** How long a remote restart waits for live turns to finish before handing
package/dist/tui/run.d.ts CHANGED
@@ -3,11 +3,38 @@ export declare function shouldRenderTui(env: {
3
3
  stdinTTY: boolean;
4
4
  noTui: boolean;
5
5
  }): boolean;
6
+ /** Why the console gave the terminal back. Only `quit` is the user asking
7
+ * to stop; the rest are the terminal being taken away underneath us. */
8
+ export type LeaveReason = 'quit' | 'SIGHUP' | 'SIGTTIN' | 'SIGTTOU' | 'stdin-error';
9
+ /** What giving the terminal back should cost. */
10
+ export type LeaveAction =
11
+ /** Close the screen and go — nothing of ours is running here. */
12
+ 'exit'
13
+ /** Drain the in-process daemon first, then go. */
14
+ | 'shutdown'
15
+ /** Close the screen and keep the daemon running headless. */
16
+ | 'detach';
17
+ /**
18
+ * The rule that decides whether this node survives the night.
19
+ *
20
+ * `ainode` runs the daemon in the SAME process as the dashboard, and every
21
+ * teardown used to end in process.exit(0). So a terminal that went away while
22
+ * nobody was watching — a closed window, an ssh drop, a stray read from a
23
+ * background process group — stopped the node, and it stayed stopped until
24
+ * someone re-ran `ainode` the next morning. Only `quit` is a person asking.
25
+ *
26
+ * Pure and exported so the rule is checkable without a TTY.
27
+ */
28
+ export declare function leaveAction(reason: LeaveReason, ownsDaemon: boolean): LeaveAction;
6
29
  export declare function runDashboard(opts: {
7
30
  viewerMode: boolean;
8
31
  pid: number | null;
9
32
  startedAt: number | null;
10
33
  version: string;
34
+ /** The in-process daemon's graceful shutdown, when this process owns one.
35
+ * Its presence is also how the screen knows quitting means stopping the
36
+ * node rather than closing a window onto someone else's. */
37
+ stop?: () => Promise<void>;
11
38
  }): Promise<void>;
12
39
  /** `ainode harnesses` — straight to the harness screen. */
13
40
  export declare function runHarnessesTui(): Promise<void>;
package/dist/tui/run.js CHANGED
@@ -39,6 +39,7 @@ var __importStar = (this && this.__importStar) || (function () {
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
41
  exports.shouldRenderTui = shouldRenderTui;
42
+ exports.leaveAction = leaveAction;
42
43
  exports.runDashboard = runDashboard;
43
44
  exports.runHarnessesTui = runHarnessesTui;
44
45
  const readline = __importStar(require("readline"));
@@ -62,6 +63,22 @@ function shouldRenderTui(env) {
62
63
  const TICK_MS = 250;
63
64
  /** Spinner cadence. Cheap now that frames are painted as a diff. */
64
65
  const SPIN_MS = 120;
66
+ /**
67
+ * The rule that decides whether this node survives the night.
68
+ *
69
+ * `ainode` runs the daemon in the SAME process as the dashboard, and every
70
+ * teardown used to end in process.exit(0). So a terminal that went away while
71
+ * nobody was watching — a closed window, an ssh drop, a stray read from a
72
+ * background process group — stopped the node, and it stayed stopped until
73
+ * someone re-ran `ainode` the next morning. Only `quit` is a person asking.
74
+ *
75
+ * Pure and exported so the rule is checkable without a TTY.
76
+ */
77
+ function leaveAction(reason, ownsDaemon) {
78
+ if (!ownsDaemon)
79
+ return 'exit';
80
+ return reason === 'quit' ? 'shutdown' : 'detach';
81
+ }
65
82
  /**
66
83
  * Own the terminal on behalf of a screen stack.
67
84
  *
@@ -69,7 +86,13 @@ const SPIN_MS = 120;
69
86
  * screen can hand the TTY to a vendor CLI. It returns the root screen.
70
87
  */
71
88
  function createConsole(build, hooks = {}) {
72
- const painter = (0, render_1.createPainter)(s => process.stdout.write(s));
89
+ // A terminal that has gone away makes every write fail with EIO/EPIPE. In
90
+ // daemon mode the node is running in this very process, so a dead screen
91
+ // must never be the thing that takes it down.
92
+ const painter = (0, render_1.createPainter)(s => { try {
93
+ process.stdout.write(s);
94
+ }
95
+ catch { /* screen gone */ } });
73
96
  let quitting = false;
74
97
  let inputAttached = false;
75
98
  // Building the root screen redraws, and that happens before run() has
@@ -82,7 +105,7 @@ function createConsole(build, hooks = {}) {
82
105
  // silent: its timers keep firing otherwise and repaint straight over the
83
106
  // child's output.
84
107
  let suspended = false;
85
- const app = (0, app_1.createApp)({ id: 'boot', title: '+Ai Node', render: () => [] }, { onQuit: () => teardown(), onRedraw: () => draw() });
108
+ const app = (0, app_1.createApp)({ id: 'boot', title: '+Ai Node', render: () => [] }, { onQuit: () => teardown('quit'), onRedraw: () => draw() });
86
109
  function draw() {
87
110
  if (!started || quitting || suspended)
88
111
  return;
@@ -92,7 +115,7 @@ function createConsole(build, hooks = {}) {
92
115
  void app.handleKey(key);
93
116
  }
94
117
  function onStdinError() {
95
- teardown();
118
+ teardown('stdin-error');
96
119
  }
97
120
  function attachInput() {
98
121
  if (inputAttached)
@@ -116,14 +139,24 @@ function createConsole(build, hooks = {}) {
116
139
  return;
117
140
  process.stdin.off('keypress', onKeypress);
118
141
  process.stdin.off('error', onStdinError);
119
- if (process.stdin.isTTY)
120
- process.stdin.setRawMode(false);
121
- process.stdin.pause();
142
+ // Putting a terminal back that no longer exists throws — and this runs
143
+ // from the very signal handler that means it no longer exists. Unguarded,
144
+ // that throw skips the rest of teardown, so onLeave never gets to say the
145
+ // node is still up and the reason is lost.
146
+ try {
147
+ if (process.stdin.isTTY)
148
+ process.stdin.setRawMode(false);
149
+ }
150
+ catch { /* gone */ }
151
+ try {
152
+ process.stdin.pause();
153
+ }
154
+ catch { /* gone */ }
122
155
  inputAttached = false;
123
156
  }
124
157
  let pollTimer = null;
125
158
  let spinTimer = null;
126
- function teardown() {
159
+ function teardown(reason) {
127
160
  if (quitting)
128
161
  return;
129
162
  quitting = true;
@@ -132,9 +165,14 @@ function createConsole(build, hooks = {}) {
132
165
  if (spinTimer)
133
166
  clearInterval(spinTimer);
134
167
  detachInput();
135
- (0, render_1.altOff)();
136
- hooks.onTeardown?.();
137
- process.exit(0);
168
+ try {
169
+ (0, render_1.altOff)();
170
+ }
171
+ catch { /* the terminal is already gone */ }
172
+ if (hooks.onLeave)
173
+ hooks.onLeave(reason);
174
+ else
175
+ process.exit(0);
138
176
  }
139
177
  const suspend = async (fn) => {
140
178
  suspended = true;
@@ -157,12 +195,21 @@ function createConsole(build, hooks = {}) {
157
195
  // background process group earns SIGTTIN, and the default action is to STOP
158
196
  // the process — which is what "zsh: suspended (tty input)" is. Stopped mid-
159
197
  // draw it still holds the terminal in raw mode, so the session it was
160
- // sharing degrades too. Stop reading and leave instead; the daemon is a
161
- // separate process and keeps running either way.
162
- process.on('SIGTTIN', teardown);
163
- process.on('SIGTTOU', teardown);
198
+ // sharing degrades too. Stop reading and leave instead.
199
+ //
200
+ // Leaving is NOT exiting: under `ainode` the daemon IS this process, so what
201
+ // the screen does when it loses its terminal decides whether the node
202
+ // survives the night. onLeave settles that — see runDashboard.
203
+ process.on('SIGTTIN', () => teardown('SIGTTIN'));
204
+ process.on('SIGTTOU', () => teardown('SIGTTOU'));
164
205
  // The terminal went away without the read failing first.
165
- process.on('SIGHUP', teardown);
206
+ process.on('SIGHUP', () => teardown('SIGHUP'));
207
+ // An async write to a dead terminal surfaces as an 'error' event, and an
208
+ // 'error' with no listener is a throw. Same reasoning as the write guard.
209
+ process.stdout.on('error', () => { });
210
+ // Same for stdin, which keeps its listener past detachInput's removal so a
211
+ // late EIO on a torn-down terminal has somewhere to land.
212
+ process.stdin.on('error', () => { });
166
213
  app.replace(build(app, suspend));
167
214
  return {
168
215
  host: app,
@@ -205,6 +252,9 @@ function createConsole(build, hooks = {}) {
205
252
  },
206
253
  };
207
254
  }
255
+ /** How long a deliberate quit waits for the daemon to drain before it stops
256
+ * being a courtesy and starts being a hung terminal. */
257
+ const QUIT_DRAIN_CEILING_MS = 20_000;
208
258
  async function runDashboard(opts) {
209
259
  const pairing = (0, store_1.readPairing)();
210
260
  const data = (0, data_1.createDataLayer)({ token: pairing?.daemonToken ?? '' });
@@ -240,11 +290,38 @@ async function runDashboard(opts) {
240
290
  })),
241
291
  });
242
292
  }, {
243
- onTeardown: () => {
293
+ // The screen and the daemon used to share one fate: every teardown ended
294
+ // in process.exit(0), so a terminal that went away overnight — SIGHUP from
295
+ // a closed window, an ssh drop, a stray background read — took the node
296
+ // down with it and left it dead until someone re-ran `ainode` in the
297
+ // morning. Losing a screen is not a reason to stop working.
298
+ onLeave: (reason) => {
299
+ const stop = opts.stop;
300
+ const action = leaveAction(reason, !opts.viewerMode && Boolean(stop));
301
+ if (action === 'detach') {
302
+ // The terminal is gone, the daemon is not. Keep capturing rather than
303
+ // restoring: the real console writes to an fd that no longer exists,
304
+ // whereas the capture keeps appending to the daemon log — which is now
305
+ // the only place anyone can watch this node from.
306
+ console.log(`[tui] terminal gone (${reason}) — dashboard closed; the node is still running (pid ${process.pid})`);
307
+ console.log(`[tui] follow it with: tail -f ${paths_1.RUNTIME_LOG_FILE}`);
308
+ return;
309
+ }
244
310
  logs.restore();
245
311
  if (logs.count()) {
246
312
  console.log(`${logs.count()} daemon log lines this session — ${paths_1.RUNTIME_LOG_FILE}`);
247
313
  }
314
+ if (action === 'exit' || !stop) {
315
+ process.exit(0);
316
+ return;
317
+ }
318
+ // Asked for, so drain properly: in-flight turns get to post their final
319
+ // status and the server is told we are going, rather than discovering it
320
+ // 90s later through a missed heartbeat. The ceiling is there because a
321
+ // wedged drain must not become a terminal that never comes back.
322
+ console.log('stopping the node…');
323
+ setTimeout(() => process.exit(0), QUIT_DRAIN_CEILING_MS).unref?.();
324
+ void stop().finally(() => process.exit(0));
248
325
  },
249
326
  });
250
327
  // The daemon's own numbers don't come from an RPC.
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "@addai/node",
3
- "version": "0.30.9",
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
- "license": "MIT",
6
- "keywords": [
7
- "addai",
8
- "entity-studio",
9
- "claude",
10
- "codex",
11
- "kimi",
12
- "gemini",
13
- "agent",
14
- "daemon",
15
- "supabase",
16
- "mcp"
17
- ],
18
- "repository": {
19
- "type": "git",
20
- "url": "git+https://github.com/just-AddAi/addai-entity-runtime.git"
21
- },
22
- "homepage": "https://github.com/just-AddAi/addai-entity-runtime#readme",
23
- "bugs": {
24
- "url": "https://github.com/just-AddAi/addai-entity-runtime/issues"
25
- },
26
- "type": "commonjs",
27
- "main": "dist/index.js",
28
- "bin": {
29
- "ainode": "dist/cli.js"
30
- },
31
- "publishConfig": {
32
- "access": "public"
33
- },
34
- "files": [
35
- "dist",
36
- "scripts",
37
- "README.md",
38
- "assets"
39
- ],
40
- "scripts": {
41
- "build": "tsc -p tsconfig.json && node scripts/copy-assets.js",
42
- "start": "node dist/cli.js",
43
- "dev": "tsc -p tsconfig.json && node dist/cli.js",
44
- "test": "npm run build && node --test test/*.test.mjs",
45
- "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
46
- "postinstall": "node scripts/fix-pty-helper.js",
47
- "prepublishOnly": "npm run build"
48
- },
49
- "engines": {
50
- "node": ">=18"
51
- },
52
- "dependencies": {
53
- "@addai/node-flows": "^1.0.0",
54
- "node-pty": "^1.1.0",
55
- "ws": "^8.21.3"
56
- },
57
- "devDependencies": {
58
- "@types/node": "^20.0.0",
59
- "@types/ws": "^8.18.1",
60
- "typescript": "^5.6.0"
61
- }
62
- }
1
+ {
2
+ "name": "@addai/node",
3
+ "version": "0.30.10",
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
+ "license": "MIT",
6
+ "keywords": [
7
+ "addai",
8
+ "entity-studio",
9
+ "claude",
10
+ "codex",
11
+ "kimi",
12
+ "gemini",
13
+ "agent",
14
+ "daemon",
15
+ "supabase",
16
+ "mcp"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/just-AddAi/addai-entity-runtime.git"
21
+ },
22
+ "homepage": "https://github.com/just-AddAi/addai-entity-runtime#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/just-AddAi/addai-entity-runtime/issues"
25
+ },
26
+ "type": "commonjs",
27
+ "main": "dist/index.js",
28
+ "bin": {
29
+ "ainode": "dist/cli.js"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "scripts",
37
+ "README.md",
38
+ "assets"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.json && node scripts/copy-assets.js",
42
+ "start": "node dist/cli.js",
43
+ "dev": "tsc -p tsconfig.json && node dist/cli.js",
44
+ "test": "npm run build && node --test test/*.test.mjs",
45
+ "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
46
+ "postinstall": "node scripts/fix-pty-helper.js",
47
+ "prepublishOnly": "npm run build"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "dependencies": {
53
+ "@addai/node-flows": "^1.0.0",
54
+ "node-pty": "^1.1.0",
55
+ "ws": "^8.21.3"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^20.0.0",
59
+ "@types/ws": "^8.18.1",
60
+ "typescript": "^5.6.0"
61
+ }
62
+ }