@addai/node 0.30.7 → 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.
@@ -92,16 +92,45 @@ function runCmd(cmd, args, timeoutMs = 5000) {
92
92
  }
93
93
  });
94
94
  }
95
- // Curated model lists baked into the daemon. The CLIs don't expose a
96
- // stable "list models" command; the daemon reports whatever the
97
- // installed daemon version knows about, so bumping the daemon refreshes
98
- // the UI's dropdown.
95
+ // Curated model lists baked into the daemon. The CLIs still expose no
96
+ // non-interactive "list models" command, so this stays a constant — but
97
+ // WHAT goes in it is what decides whether the UI's dropdown rots.
98
+ //
99
+ // Do not "fix" this by probing the CLI. Checked against Claude Code
100
+ // 2.1.266: there is no `claude models` subcommand and no `claude config
101
+ // list`. Both strings are read as a PROMPT — the CLI starts a billed
102
+ // session and answers in prose. Probing that on every heartbeat would
103
+ // spend money several times a minute and still return nothing parseable.
104
+ //
105
+ // `claude --help` documents the real contract instead: --model takes
106
+ // "an alias for the latest model (e.g. 'fable', 'opus', or 'sonnet') or
107
+ // a model's full name (e.g. 'claude-fable-5')".
108
+ //
109
+ // So the ALIASES lead the list. They are the only entries that cannot go
110
+ // stale — the CLI resolves each to whatever is current in its tier — and
111
+ // staleness is exactly what went wrong before: this list was still
112
+ // offering claude-opus-4-1 and claude-sonnet-4-5 long after they were
113
+ // superseded, and no amount of heartbeating fixes a wrong constant.
114
+ // Pinned ids follow, newest first, for anyone who must hold one model.
115
+ //
116
+ // Availability is plan-gated (these nodes sign in with claude.ai
117
+ // subscriptions, not API keys), so a listed model can still be refused
118
+ // at spawn time — the CLI stays the source of truth for validity, the
119
+ // same way model-filter.ts already assumes.
99
120
  const CLAUDE_MODELS = [
121
+ // Aliases — always the latest in their tier.
122
+ 'opus',
123
+ 'sonnet',
124
+ 'haiku',
125
+ 'fable',
126
+ // Pinned ids, newest first.
127
+ 'claude-opus-5',
128
+ 'claude-sonnet-5',
129
+ 'claude-fable-5-1',
130
+ 'claude-opus-4-8',
100
131
  'claude-opus-4-7',
101
132
  'claude-sonnet-4-6',
102
133
  'claude-haiku-4-5',
103
- 'claude-opus-4-1',
104
- 'claude-sonnet-4-5',
105
134
  ];
106
135
  const CODEX_MODELS = [
107
136
  'gpt-5',
@@ -11,6 +11,7 @@ exports.spawnClaudePrint = spawnClaudePrint;
11
11
  const child_process_1 = require("child_process");
12
12
  const claude_binary_1 = require("./claude-binary");
13
13
  const win_1 = require("./win");
14
+ const claude_system_prompt_1 = require("./claude-system-prompt");
14
15
  /**
15
16
  * The text to emit for one flushed block, given what came before.
16
17
  *
@@ -174,9 +175,13 @@ function spawnClaudePrint(input) {
174
175
  // its node script instead (identity on POSIX). It can throw; so can
175
176
  // spawn() itself (synchronously, on win32). Surface either as a normal
176
177
  // rejected `done` so callers keep their single error path.
178
+ // An entity system prompt of tens of thousands of characters does not fit
179
+ // on a Windows command line — see claude-system-prompt.ts. No-op when it
180
+ // fits, which is every case that works today.
181
+ const sysPrompt = (0, claude_system_prompt_1.offloadLongSystemPrompt)(bin, args);
177
182
  let proc;
178
183
  try {
179
- const inv = (0, win_1.resolveCliInvocation)(bin, args);
184
+ const inv = (0, win_1.resolveCliInvocation)(bin, sysPrompt.args);
180
185
  proc = (0, child_process_1.spawn)(inv.file, inv.args, {
181
186
  cwd: input.workingDirectory,
182
187
  env: { ...process.env, TERM: 'dumb' },
@@ -187,6 +192,7 @@ function spawnClaudePrint(input) {
187
192
  });
188
193
  }
189
194
  catch (err) {
195
+ sysPrompt.cleanup();
190
196
  const done = Promise.reject(err instanceof Error ? err : new Error(String(err)));
191
197
  // Mark handled now — the caller awaits `done` a few ticks later, and an
192
198
  // already-rejected promise with no handler would fire unhandledRejection.
@@ -332,6 +338,7 @@ function spawnClaudePrint(input) {
332
338
  const done = new Promise((resolve, reject) => {
333
339
  proc.once('error', reject);
334
340
  proc.once('exit', (code) => {
341
+ sysPrompt.cleanup();
335
342
  flushThinking();
336
343
  flushText();
337
344
  const trimmed = stderrBuf.trim();
@@ -47,6 +47,7 @@ const path = __importStar(require("path"));
47
47
  const crypto_1 = require("crypto");
48
48
  const claude_binary_1 = require("./claude-binary");
49
49
  const win_1 = require("./win");
50
+ const claude_system_prompt_1 = require("./claude-system-prompt");
50
51
  const mcp_config_1 = require("./mcp-config");
51
52
  const paths_1 = require("./paths");
52
53
  function resolveMcpFilter(workingDirectory, enabled) {
@@ -150,6 +151,11 @@ function spawnClaudeForRuntime(input) {
150
151
  ...entityFlags,
151
152
  ...claudeArgs,
152
153
  ];
154
+ // Same overflow as claude-print: a long --append-system-prompt does not fit
155
+ // on a Windows command line. Applied before the branch so both the ConPTY
156
+ // and the POSIX shell form get the shortened argv; cleaned up in onExit
157
+ // beside the temp MCP config.
158
+ const sysPrompt = (0, claude_system_prompt_1.offloadLongSystemPrompt)(claudeBin, effectiveArgs);
153
159
  let shell;
154
160
  let shellArgs;
155
161
  if (isWin) {
@@ -160,14 +166,14 @@ function spawnClaudeForRuntime(input) {
160
166
  // node script (run via process.execPath) or passes an .exe straight
161
167
  // through; either target parses argv via CommandLineToArgvW, matching
162
168
  // node-pty's quoting. ConPTY handles both fine.
163
- const inv = (0, win_1.resolveCliInvocation)(claudeBin, effectiveArgs);
169
+ const inv = (0, win_1.resolveCliInvocation)(claudeBin, sysPrompt.args);
164
170
  shell = inv.file;
165
171
  shellArgs = inv.args;
166
172
  }
167
173
  else {
168
174
  // `exec "$@"` passes positional args straight through without shell escaping.
169
175
  shell = process.env.SHELL || '/bin/bash';
170
- shellArgs = ['-l', '-c', 'exec "$@"', '_', claudeBin, ...effectiveArgs];
176
+ shellArgs = ['-l', '-c', 'exec "$@"', '_', claudeBin, ...sysPrompt.args];
171
177
  }
172
178
  const proc = pty.spawn(shell, shellArgs, {
173
179
  name: 'xterm-256color',
@@ -196,6 +202,7 @@ function spawnClaudeForRuntime(input) {
196
202
  l(exitCode);
197
203
  }
198
204
  catch { }
205
+ sysPrompt.cleanup();
199
206
  // best-effort cleanup of any temp MCP config
200
207
  if (mcpConfigPath) {
201
208
  try {
@@ -0,0 +1,18 @@
1
+ /** Rough rendered length of `file` + `args` as one command line. Counts two
2
+ * quotes and a separating space per argument — the shape a renderer uses for
3
+ * anything that might contain whitespace. */
4
+ export declare function commandLineLength(file: string, args: string[]): number;
5
+ export interface OffloadResult {
6
+ args: string[];
7
+ /** Removes the temp file, if one was written. Safe to call more than once. */
8
+ cleanup: () => void;
9
+ }
10
+ /**
11
+ * Swap an oversized inline system prompt for a file-backed one.
12
+ *
13
+ * Returns the args unchanged (and a no-op cleanup) whenever the command line
14
+ * already fits, whenever there is no --append-system-prompt to move, or when
15
+ * the temp file cannot be written — in that last case the spawn is left to
16
+ * fail with its own error rather than a worse one invented here.
17
+ */
18
+ export declare function offloadLongSystemPrompt(file: string, args: string[], isWindows?: boolean): OffloadResult;
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ // A long --append-system-prompt does not fit on a Windows command line.
3
+ //
4
+ // CreateProcess caps the entire command line at 32,767 characters, and an
5
+ // entity's system prompt is routinely tens of thousands — Hannah's was 31,004,
6
+ // which with the user prompt and the rest of the flags overflowed, and every
7
+ // run died with a bare `spawn ENAMETOOLONG` and no hint of which argument was
8
+ // to blame. POSIX is nowhere near its limit at these sizes (ARG_MAX is ~256KB
9
+ // on macOS, ~2MB on Linux), which is why this only ever bit on Windows — and
10
+ // only once claude could be spawned there at all.
11
+ //
12
+ // claude takes the same text from a file, so when the rendered command line
13
+ // would not fit, the prompt is written to a temp file and the flag becomes
14
+ // --append-system-prompt-file. ONLY when it would not fit: the inline form is
15
+ // what every working install uses today and an older claude may not know the
16
+ // -file variant, so nothing that currently works changes shape.
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.commandLineLength = commandLineLength;
52
+ exports.offloadLongSystemPrompt = offloadLongSystemPrompt;
53
+ const fs = __importStar(require("fs"));
54
+ const os = __importStar(require("os"));
55
+ const path = __importStar(require("path"));
56
+ const crypto_1 = require("crypto");
57
+ const INLINE_FLAG = '--append-system-prompt';
58
+ const FILE_FLAG = '--append-system-prompt-file';
59
+ /** Head-room under the real 32,767 limit. Both node and node-pty escape quotes
60
+ * and backslashes when they render argv into a command line, so what actually
61
+ * reaches CreateProcess is longer than the sum of the arguments. */
62
+ const WINDOWS_BUDGET = 30_000;
63
+ /** POSIX only needs catching if a prompt has gone genuinely pathological. */
64
+ const POSIX_BUDGET = 120_000;
65
+ /** Rough rendered length of `file` + `args` as one command line. Counts two
66
+ * quotes and a separating space per argument — the shape a renderer uses for
67
+ * anything that might contain whitespace. */
68
+ function commandLineLength(file, args) {
69
+ return args.reduce((n, a) => n + a.length + 3, file.length + 3);
70
+ }
71
+ /**
72
+ * Swap an oversized inline system prompt for a file-backed one.
73
+ *
74
+ * Returns the args unchanged (and a no-op cleanup) whenever the command line
75
+ * already fits, whenever there is no --append-system-prompt to move, or when
76
+ * the temp file cannot be written — in that last case the spawn is left to
77
+ * fail with its own error rather than a worse one invented here.
78
+ */
79
+ function offloadLongSystemPrompt(file, args, isWindows = process.platform === 'win32') {
80
+ const noop = { args, cleanup: () => { } };
81
+ const budget = isWindows ? WINDOWS_BUDGET : POSIX_BUDGET;
82
+ if (commandLineLength(file, args) <= budget)
83
+ return noop;
84
+ const i = args.indexOf(INLINE_FLAG);
85
+ if (i < 0 || i + 1 >= args.length)
86
+ return noop;
87
+ const promptPath = path.join(os.tmpdir(), `ainode-sysprompt-${(0, crypto_1.randomUUID)()}.txt`);
88
+ try {
89
+ fs.writeFileSync(promptPath, args[i + 1], 'utf8');
90
+ }
91
+ catch {
92
+ return noop;
93
+ }
94
+ const next = args.slice();
95
+ next[i] = FILE_FLAG;
96
+ next[i + 1] = promptPath;
97
+ let removed = false;
98
+ return {
99
+ args: next,
100
+ cleanup: () => {
101
+ if (removed)
102
+ return;
103
+ removed = true;
104
+ try {
105
+ fs.unlinkSync(promptPath);
106
+ }
107
+ catch { /* swept from tmp anyway */ }
108
+ },
109
+ };
110
+ }
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
  }
@@ -71,13 +71,25 @@ const IDLE_CHECK_MS = 60_000;
71
71
  * leaves a half-open socket that never fires 'close', so without this the
72
72
  * client sits holding a dead handle and never reconnects. */
73
73
  const PING_INTERVAL_MS = 30_000;
74
+ /** How often to ask the relay which deploy is answering. Cloud Run keeps a
75
+ * retiring instance alive until its in-flight connections end — for this
76
+ * socket, the hourly cut — so after a relay deploy this node sat attached to
77
+ * the OLD instance, pongs and all, while every viewer landed on the new one
78
+ * and was told the machine was offline. The relay's greeting names the
79
+ * instance this socket reached; /health is answered by the live one. When
80
+ * the two differ, move. */
81
+ const REVISION_CHECK_MS = 60_000;
82
+ const RELAY_HTTP_URL = RELAY_URL.replace(/^ws/, 'http');
74
83
  let ws = null;
75
84
  let stopped = true;
76
85
  let backoff = RECONNECT_MIN_MS;
77
86
  let idleTimer = null;
78
- let liveTimer = null;
79
- /** Set on every pong; cleared when a ping goes out. Two misses = dead. */
80
- let missedPongs = 0;
87
+ /** Silences the CURRENT socket's timers; set when a socket opens so
88
+ * stopRelayClient can reach them. Each socket owns its own timers and miss
89
+ * count: a socket closing late used to clear the module-wide timer, which by
90
+ * then belonged to its replacement — leaving the live socket with no ping
91
+ * loop and no way to notice a dead link. */
92
+ let clearCurrentTimers = null;
81
93
  /** viewerId -> that viewer's OWN TCP socket to the desktop's RFB port.
82
94
  * Keyed per viewer, not per desktop: RFB is a stateful 1:1 protocol, so two
83
95
  * people watching the same screen each need their own connection and their
@@ -157,16 +169,51 @@ async function connect() {
157
169
  }
158
170
  const sock = new ws_1.default(`${RELAY_URL}/node?token=${encodeURIComponent(token)}`);
159
171
  ws = sock;
172
+ // Which deploy of the relay this socket reached, from its greeting. Null
173
+ // until the relay says — an older relay never does, and then there is
174
+ // nothing to compare and the check stays quiet.
175
+ let attachedRevision = null;
176
+ /** Set on every pong; cleared when a ping goes out. Two misses = dead. */
177
+ let missedPongs = 0;
178
+ let pingTimer = null;
179
+ let revisionTimer = null;
180
+ const clearTimers = () => {
181
+ if (pingTimer) {
182
+ clearInterval(pingTimer);
183
+ pingTimer = null;
184
+ }
185
+ if (revisionTimer) {
186
+ clearInterval(revisionTimer);
187
+ revisionTimer = null;
188
+ }
189
+ if (clearCurrentTimers === clearTimers)
190
+ clearCurrentTimers = null;
191
+ };
192
+ const checkRevision = async () => {
193
+ if (!attachedRevision || sock.readyState !== ws_1.default.OPEN)
194
+ return;
195
+ let live;
196
+ try {
197
+ const res = await fetch(`${RELAY_HTTP_URL}/health`, { signal: AbortSignal.timeout(8_000) });
198
+ live = (await res.json()).revision;
199
+ }
200
+ catch {
201
+ return;
202
+ } // could not ask; the ping loop still guards the link
203
+ if (live && live !== attachedRevision && sock.readyState === ws_1.default.OPEN) {
204
+ console.warn(`[relay] the relay moved from ${attachedRevision} to ${live} — reconnecting`);
205
+ sock.terminate(); // 'close' → retry → redial, onto the live instance
206
+ }
207
+ };
160
208
  sock.on('open', () => {
161
209
  backoff = RECONNECT_MIN_MS;
162
- missedPongs = 0;
163
- if (liveTimer)
164
- clearInterval(liveTimer);
210
+ clearCurrentTimers?.();
211
+ clearCurrentTimers = clearTimers;
165
212
  // A suspended Mac comes back with a socket that looks open and is not.
166
213
  // Ping until two go unanswered, then tear it down so the normal backoff
167
214
  // path reconnects - the daemon's own sleep detector reports the same
168
215
  // suspends this is guarding against.
169
- liveTimer = setInterval(() => {
216
+ pingTimer = setInterval(() => {
170
217
  if (sock.readyState !== ws_1.default.OPEN) {
171
218
  sock.terminate();
172
219
  return;
@@ -184,6 +231,7 @@ async function connect() {
184
231
  sock.terminate();
185
232
  }
186
233
  }, PING_INTERVAL_MS);
234
+ revisionTimer = setInterval(() => { void checkRevision(); }, REVISION_CHECK_MS);
187
235
  });
188
236
  sock.on('pong', () => { missedPongs = 0; });
189
237
  sock.on('message', async (raw) => {
@@ -208,6 +256,11 @@ async function connect() {
208
256
  sock.send(JSON.stringify({ type: 'gone', viewerId, reason }));
209
257
  }
210
258
  };
259
+ if (msg.type === 'hello') {
260
+ attachedRevision = msg.revision ?? null;
261
+ console.log(`[relay] connected — relay revision ${attachedRevision ?? '?'}`);
262
+ return;
263
+ }
211
264
  if (msg.type === 'attach') {
212
265
  // Claim the viewer BEFORE the await, so a packet arriving during the
213
266
  // lookup is held rather than treated as arriving after a lost bridge.
@@ -312,10 +365,7 @@ async function connect() {
312
365
  }
313
366
  });
314
367
  const retry = () => {
315
- if (liveTimer) {
316
- clearInterval(liveTimer);
317
- liveTimer = null;
318
- }
368
+ clearTimers(); // only ever this socket's own
319
369
  if (ws !== sock)
320
370
  return; // superseded by a newer socket
321
371
  dropBridges();
@@ -338,10 +388,7 @@ function stopRelayClient() {
338
388
  clearTimeout(idleTimer);
339
389
  idleTimer = null;
340
390
  }
341
- if (liveTimer) {
342
- clearInterval(liveTimer);
343
- liveTimer = null;
344
- }
391
+ clearCurrentTimers?.();
345
392
  dropBridges();
346
393
  try {
347
394
  ws?.close();
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/dist/win.d.ts CHANGED
@@ -34,6 +34,11 @@ export declare function resolveCliInvocation(bin: string, args: string[]): CliIn
34
34
  * pnpm/yarn-classic write `"%~dp0\<rel>.js"` (batch parameter expansion —
35
35
  * note: no trailing `%`). */
36
36
  declare function resolveNpmShimScript(shimPath: string): string | null;
37
+ /** Parse a .cmd shim for a `%~dp0`-relative NATIVE executable, for packages
38
+ * that ship one instead of a node script. Same two dialects and the same
39
+ * on-disk check as resolveNpmShimScript; deliberately a separate function so
40
+ * the npm path the auto-updater depends on is untouched by this. */
41
+ declare function resolveShimExecutable(shimPath: string): string | null;
37
42
  /**
38
43
  * Kill an agent child process AND everything it spawned.
39
44
  *
@@ -99,5 +104,6 @@ export declare function linkDir(real: string, dest: string): void;
99
104
  * worth exercising on any platform, not just on a Windows box. */
100
105
  export declare const __testables: {
101
106
  resolveNpmShimScript: typeof resolveNpmShimScript;
107
+ resolveShimExecutable: typeof resolveShimExecutable;
102
108
  };
103
109
  export {};
package/dist/win.js CHANGED
@@ -203,11 +203,24 @@ function resolveCliInvocation(bin, args) {
203
203
  }
204
204
  if (/\.(cmd|bat)$/i.test(target)) {
205
205
  const script = resolveNpmShimScript(target);
206
- if (!script) {
207
- throw new Error(`cannot spawn npm shim safely: ${target} — could not resolve the node script it wraps. ` +
208
- `Install the CLI's native Windows build (an .exe) or reinstall via npm.`);
209
- }
210
- return { file: process.execPath, args: [script, ...args] };
206
+ if (script)
207
+ return { file: process.execPath, args: [script, ...args] };
208
+ // Not every npm shim wraps a node script. A package may ship a native
209
+ // Windows binary and have its shim exec THAT:
210
+ //
211
+ // "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*
212
+ //
213
+ // which is what @anthropic-ai/claude-code does now. There is no .js to
214
+ // find, so this threw — and the capabilities probe swallows the throw and
215
+ // reports the harness `available: false`. On a machine where `claude
216
+ // --version` worked in every shell, claude was simply missing from the
217
+ // picker. The old message's advice ('install the native Windows build')
218
+ // was already satisfied: the .exe sits inside the package the shim names.
219
+ const exe = resolveShimExecutable(target);
220
+ if (exe)
221
+ return { file: exe, args };
222
+ throw new Error(`cannot spawn npm shim safely: ${target} — it wraps neither a node script ` +
223
+ `nor an executable this can find. Reinstall the CLI via npm.`);
211
224
  }
212
225
  return { file: target, args };
213
226
  }
@@ -278,6 +291,26 @@ function resolveNpmShimScript(shimPath) {
278
291
  }
279
292
  return null;
280
293
  }
294
+ /** Parse a .cmd shim for a `%~dp0`-relative NATIVE executable, for packages
295
+ * that ship one instead of a node script. Same two dialects and the same
296
+ * on-disk check as resolveNpmShimScript; deliberately a separate function so
297
+ * the npm path the auto-updater depends on is untouched by this. */
298
+ function resolveShimExecutable(shimPath) {
299
+ let body;
300
+ try {
301
+ body = fs.readFileSync(shimPath, 'utf8');
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ const re = /%(?:~dp0|dp0%)[\\/]([^"\s]+\.exe)/gi;
307
+ for (const m of body.matchAll(re)) {
308
+ const exe = path.join(path.dirname(shimPath), ...m[1].split(/[\\/]+/));
309
+ if (fs.existsSync(exe))
310
+ return exe;
311
+ }
312
+ return null;
313
+ }
281
314
  /** How long a POSIX agent gets to honour SIGINT before we stop asking. */
282
315
  const KILL_ESCALATE_MS = 3000;
283
316
  /** One `ps` snapshot as (pid, ppid) pairs. Empty on any failure — callers
@@ -466,4 +499,4 @@ function linkDir(real, dest) {
466
499
  }
467
500
  /** Exposed for tests only: the shim parsing is pure file-shape logic and is
468
501
  * worth exercising on any platform, not just on a Windows box. */
469
- exports.__testables = { resolveNpmShimScript };
502
+ exports.__testables = { resolveNpmShimScript, resolveShimExecutable };
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "@addai/node",
3
- "version": "0.30.7",
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
+ }