@addai/node 0.13.0 → 0.14.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.
@@ -621,6 +621,9 @@ async function runDesktopCommand(cmd) {
621
621
  await (0, manager_1.setStatus)(row.id, {
622
622
  status: 'running', container_id: containerId,
623
623
  engine: provider.id, vnc_port: vncPort, status_message: null,
624
+ // Record the password we actually gave the container, or the viewer
625
+ // will keep presenting the previous one.
626
+ vnc_password: vncPassword,
624
627
  });
625
628
  await (0, creds_1.syncCredentials)(provider, built, onLog);
626
629
  if (row.setup_script) {
@@ -42,8 +42,16 @@ class CliProvider {
42
42
  // payload as its own phase rather than hiding inside `run`.
43
43
  onLog(`pulling ${row.image}\n`);
44
44
  const pull = await this.run(['pull', row.image], onLog);
45
- if (!pull.ok)
46
- throw new Error(`image pull failed: ${pull.stderr.trim().slice(0, 500)}`);
45
+ if (!pull.ok) {
46
+ // A pull failure is only fatal if we have no copy. A locally built or
47
+ // side-loaded image has no registry to pull FROM, and a node that is
48
+ // briefly offline should still be able to start a desktop it already
49
+ // has. Only give up when neither the registry nor the disk has it.
50
+ const local = await this.run(['image', 'inspect', row.image], undefined, 30_000);
51
+ if (!local.ok)
52
+ throw new Error(`image pull failed: ${pull.stderr.trim().slice(0, 500)}`);
53
+ onLog(`pull failed; using the copy already on this machine\n`);
54
+ }
47
55
  onLog(`creating ${(0, provider_1.containerName)(row)}\n`);
48
56
  const res = await this.run((0, provider_1.buildCreateArgs)(row, opts), onLog);
49
57
  if (!res.ok)
@@ -12,6 +12,10 @@ export declare function setStatus(id: string, fields: {
12
12
  container_id?: string | null;
13
13
  engine?: string | null;
14
14
  vnc_port?: number | null;
15
+ /** Written only on create/rebuild, when the daemon mints a fresh one. The
16
+ * viewer authenticates with whatever is recorded here, so a password used
17
+ * but not recorded means "password check failed" at the RFB handshake. */
18
+ vnc_password?: string | null;
15
19
  }): Promise<void>;
16
20
  /** A free loopback port for this desktop's VNC. Asking the OS for port 0 and
17
21
  * reading back what it bound is the only race-free way to pick one. */
@@ -92,6 +92,7 @@ async function setStatus(id, fields) {
92
92
  p_container_id: fields.container_id ?? null,
93
93
  p_engine: fields.engine ?? null,
94
94
  p_vnc_port: fields.vnc_port ?? null,
95
+ p_vnc_password: fields.vnc_password ?? null,
95
96
  });
96
97
  }
97
98
  catch (err) {
@@ -50,15 +50,24 @@ const ws_1 = __importDefault(require("ws"));
50
50
  const net = __importStar(require("net"));
51
51
  const store_1 = require("../store");
52
52
  const manager_1 = require("./manager");
53
- const RELAY_URL = process.env.AINODE_RELAY_URL ?? 'wss://desktop-relay.add.ai';
53
+ // The deployed relay. Overridable so a node can be pointed at a local one.
54
+ const RELAY_URL = process.env.AINODE_RELAY_URL
55
+ ?? 'wss://desktop-relay-29522465016.europe-west2.run.app';
54
56
  const RECONNECT_MIN_MS = 2_000;
55
57
  const RECONNECT_MAX_MS = 60_000;
56
58
  /** Re-check whether any desktop is running this often while idle. */
57
59
  const IDLE_CHECK_MS = 60_000;
60
+ /** How often to prove the socket is still alive. A machine that suspends
61
+ * leaves a half-open socket that never fires 'close', so without this the
62
+ * client sits holding a dead handle and never reconnects. */
63
+ const PING_INTERVAL_MS = 30_000;
58
64
  let ws = null;
59
65
  let stopped = true;
60
66
  let backoff = RECONNECT_MIN_MS;
61
67
  let idleTimer = null;
68
+ let liveTimer = null;
69
+ /** Set on every pong; cleared when a ping goes out. Two misses = dead. */
70
+ let missedPongs = 0;
62
71
  /** desktopId -> the local TCP socket to that desktop's published RFB port. */
63
72
  const bridges = new Map();
64
73
  function dropBridges() {
@@ -99,7 +108,35 @@ async function connect() {
99
108
  }
100
109
  const sock = new ws_1.default(`${RELAY_URL}/node?token=${encodeURIComponent(token)}`);
101
110
  ws = sock;
102
- sock.on('open', () => { backoff = RECONNECT_MIN_MS; });
111
+ sock.on('open', () => {
112
+ backoff = RECONNECT_MIN_MS;
113
+ missedPongs = 0;
114
+ if (liveTimer)
115
+ clearInterval(liveTimer);
116
+ // A suspended Mac comes back with a socket that looks open and is not.
117
+ // Ping until two go unanswered, then tear it down so the normal backoff
118
+ // path reconnects - the daemon's own sleep detector reports the same
119
+ // suspends this is guarding against.
120
+ liveTimer = setInterval(() => {
121
+ if (sock.readyState !== ws_1.default.OPEN) {
122
+ sock.terminate();
123
+ return;
124
+ }
125
+ if (missedPongs >= 2) {
126
+ console.warn('[relay] no pong in 60s — assuming the link died, reconnecting');
127
+ sock.terminate();
128
+ return;
129
+ }
130
+ missedPongs += 1;
131
+ try {
132
+ sock.ping();
133
+ }
134
+ catch {
135
+ sock.terminate();
136
+ }
137
+ }, PING_INTERVAL_MS);
138
+ });
139
+ sock.on('pong', () => { missedPongs = 0; });
103
140
  sock.on('message', async (raw) => {
104
141
  let msg;
105
142
  try {
@@ -139,6 +176,10 @@ async function connect() {
139
176
  }
140
177
  });
141
178
  const retry = () => {
179
+ if (liveTimer) {
180
+ clearInterval(liveTimer);
181
+ liveTimer = null;
182
+ }
142
183
  if (ws !== sock)
143
184
  return; // superseded by a newer socket
144
185
  dropBridges();
@@ -161,6 +202,10 @@ function stopRelayClient() {
161
202
  clearTimeout(idleTimer);
162
203
  idleTimer = null;
163
204
  }
205
+ if (liveTimer) {
206
+ clearInterval(liveTimer);
207
+ liveTimer = null;
208
+ }
164
209
  dropBridges();
165
210
  try {
166
211
  ws?.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": [