@addai/node 0.20.0 → 0.22.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.
@@ -47,6 +47,7 @@ const child_process_1 = require("child_process");
47
47
  const fs = __importStar(require("fs"));
48
48
  const os = __importStar(require("os"));
49
49
  const path = __importStar(require("path"));
50
+ const think_split_1 = require("./think-split");
50
51
  const codex_binary_1 = require("./codex-binary");
51
52
  const diskguard_1 = require("./diskguard");
52
53
  const win_1 = require("./win");
@@ -318,6 +319,10 @@ function spawnCodex(input) {
318
319
  kill() { },
319
320
  };
320
321
  }
322
+ // Models do not reliably keep reasoning on their own stream — they drop
323
+ // back to ordinary text mid-run and carry on thinking inside a <think>
324
+ // tag. Split it back out before it reaches the room. See think-split.ts.
325
+ const emitSplit = (0, think_split_1.splittingEmitter)(emit);
321
326
  proc.stdout?.on('data', (chunk) => {
322
327
  touchActivity();
323
328
  buffer += chunk.toString('utf8');
@@ -338,7 +343,7 @@ function spawnCodex(input) {
338
343
  threadId = parsed.thread_id;
339
344
  }
340
345
  for (const ev of lineToEvents(parsed))
341
- emit(ev);
346
+ emitSplit(ev);
342
347
  }
343
348
  });
344
349
  proc.stderr?.on('data', (chunk) => {
@@ -354,7 +359,13 @@ function spawnCodex(input) {
354
359
  // SIGINT from kill()/timeout, or async spawn error). DiskGuard is the
355
360
  // backstop for the paths this can't cover — SIGKILL and crashes.
356
361
  proc.once('error', (err) => { void guardSession.dispose(true); reject(err); });
357
- proc.once('exit', (code) => { void guardSession.dispose(true); resolve(code ?? -1); });
362
+ proc.once('exit', (code) => {
363
+ // Release anything the splitter is still holding — a run that ends
364
+ // mid-tag must not lose the tail of its answer.
365
+ emitSplit.flush();
366
+ void guardSession.dispose(true);
367
+ resolve(code ?? -1);
368
+ });
358
369
  });
359
370
  return {
360
371
  pid: proc.pid,
@@ -1,2 +1,7 @@
1
+ /** What to run when the server says there is work. Injected by index.ts so
2
+ * this module stays ignorant of the pumps it is nudging. */
3
+ type WakeKind = 'command' | 'request';
4
+ export declare function onWake(kind: WakeKind, fn: () => void): void;
1
5
  export declare function startRelayClient(): void;
2
6
  export declare function stopRelayClient(): void;
7
+ export {};
@@ -36,16 +36,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.onWake = onWake;
39
40
  exports.startRelayClient = startRelayClient;
40
41
  exports.stopRelayClient = stopRelayClient;
41
- // Dial-out socket to the desktop relay.
42
+ // Dial-out socket to the relay — this node's live link to the server.
42
43
  //
43
- // The daemon connects OUT whenever it has a running desktop, so there is no
44
- // inbound port and no NAT problem - and because the socket is already open,
45
- // attaching a viewer is instant rather than waiting on the 30s heartbeat.
44
+ // The daemon connects OUT, so there is no inbound port and no NAT problem.
45
+ // It is held open ALWAYS, not only while a desktop is running, because it is
46
+ // how the server reaches this machine without being asked: work queued in
47
+ // Studio is pushed down here the moment it exists instead of waiting for the
48
+ // 30s heartbeat to notice it. That heartbeat remains the fallback for a node
49
+ // whose socket is down; this is the fast path, not the only path.
46
50
  //
47
- // It carries RAW RFB bytes. The container serves RFB directly (no websockify),
48
- // so noVNC in the browser speaks RFB across this pipe unchanged.
51
+ // Two kinds of traffic share it:
52
+ // - RAW RFB bytes for desktop viewers. The container serves RFB directly
53
+ // (no websockify), so noVNC in the browser speaks RFB across it unchanged.
54
+ // - `wake` control frames, which cost nothing when idle and turn a ten
55
+ // second wait into a round trip.
49
56
  const ws_1 = __importDefault(require("ws"));
50
57
  const net = __importStar(require("net"));
51
58
  const store_1 = require("../store");
@@ -55,7 +62,7 @@ const RELAY_URL = process.env.AINODE_RELAY_URL
55
62
  ?? 'wss://desktop-relay-29522465016.europe-west2.run.app';
56
63
  const RECONNECT_MIN_MS = 2_000;
57
64
  const RECONNECT_MAX_MS = 60_000;
58
- /** Re-check whether any desktop is running this often while idle. */
65
+ /** Re-check for a pairing token this often when there is not one yet. */
59
66
  const IDLE_CHECK_MS = 60_000;
60
67
  /** How often to prove the socket is still alive. A machine that suspends
61
68
  * leaves a half-open socket that never fires 'close', so without this the
@@ -73,6 +80,10 @@ let missedPongs = 0;
73
80
  * people watching the same screen each need their own connection and their
74
81
  * own handshake. x11vnc runs with -shared precisely so it will serve them. */
75
82
  const bridges = new Map();
83
+ const wakeHooks = new Map();
84
+ function onWake(kind, fn) {
85
+ wakeHooks.set(kind, fn);
86
+ }
76
87
  function dropBridges() {
77
88
  for (const s of bridges.values()) {
78
89
  try {
@@ -82,14 +93,6 @@ function dropBridges() {
82
93
  }
83
94
  bridges.clear();
84
95
  }
85
- async function anyDesktopRunning() {
86
- try {
87
- return (await (0, manager_1.listDesktops)()).some(d => d.status === 'running');
88
- }
89
- catch {
90
- return false;
91
- }
92
- }
93
96
  function scheduleReconnect() {
94
97
  if (stopped)
95
98
  return;
@@ -104,11 +107,6 @@ async function connect() {
104
107
  scheduleReconnect();
105
108
  return;
106
109
  }
107
- // No point holding a socket open for a machine with nothing to show.
108
- if (!(await anyDesktopRunning())) {
109
- idleTimer = setTimeout(() => { void connect(); }, IDLE_CHECK_MS);
110
- return;
111
- }
112
110
  const sock = new ws_1.default(`${RELAY_URL}/node?token=${encodeURIComponent(token)}`);
113
111
  ws = sock;
114
112
  sock.on('open', () => {
@@ -173,6 +171,26 @@ async function connect() {
173
171
  bridges.get(msg.viewerId)?.write(Buffer.from(msg.b64, 'base64'));
174
172
  return;
175
173
  }
174
+ if (msg.type === 'wake') {
175
+ // The whole point of the socket. Costs one function call; saves the
176
+ // wait for the next heartbeat.
177
+ const kind = msg.what;
178
+ const hook = kind ? wakeHooks.get(kind) : undefined;
179
+ if (hook) {
180
+ try {
181
+ hook();
182
+ }
183
+ catch { /* a pump must never kill the link */ }
184
+ }
185
+ else
186
+ for (const h of wakeHooks.values()) {
187
+ try {
188
+ h();
189
+ }
190
+ catch { /* as above */ }
191
+ }
192
+ return;
193
+ }
176
194
  if (msg.type === 'detach') {
177
195
  bridges.get(msg.viewerId)?.destroy();
178
196
  bridges.delete(msg.viewerId);
@@ -54,6 +54,7 @@ const path = __importStar(require("path"));
54
54
  const gemini_binary_1 = require("./gemini-binary");
55
55
  const win_1 = require("./win");
56
56
  const events_1 = require("./events");
57
+ const think_split_1 = require("./think-split");
57
58
  /**
58
59
  * Write a workspace-scope `<cwd>/.gemini/settings.json` for THIS spawn.
59
60
  *
@@ -300,6 +301,10 @@ function spawnGemini(input) {
300
301
  done: Promise.resolve(-1),
301
302
  };
302
303
  }
304
+ // Models do not reliably keep reasoning on their own stream — they drop
305
+ // back to ordinary text mid-run and carry on thinking inside a <think>
306
+ // tag. Split it back out before it reaches the room. See think-split.ts.
307
+ const emitSplit = (0, think_split_1.splittingEmitter)(emit);
303
308
  proc.stdout?.on('data', (chunk) => {
304
309
  buffer += chunk.toString('utf8');
305
310
  let nl;
@@ -318,7 +323,7 @@ function spawnGemini(input) {
318
323
  if (typeof parsed.session_id === 'string' && !sessionId)
319
324
  sessionId = parsed.session_id;
320
325
  for (const ev of lineToEvents(parsed))
321
- emit(ev);
326
+ emitSplit(ev);
322
327
  }
323
328
  });
324
329
  proc.stderr?.on('data', (chunk) => {
@@ -349,10 +354,13 @@ function spawnGemini(input) {
349
354
  if (typeof parsed.session_id === 'string' && !sessionId)
350
355
  sessionId = parsed.session_id;
351
356
  for (const ev of lineToEvents(parsed))
352
- emit(ev);
357
+ emitSplit(ev);
353
358
  }
354
359
  catch { /* ignore a partial/non-JSON tail */ }
355
360
  }
361
+ // Release anything the splitter is still holding — a run that ends
362
+ // mid-tag must not lose the tail of its answer.
363
+ emitSplit.flush();
356
364
  // No HOME-temp-dir to clean up anymore. The workspace settings file in
357
365
  // <cwd>/.gemini/settings.json sits with the rest of the ephemeral cwd
358
366
  // and is cleaned by the session dispose path.
@@ -73,6 +73,7 @@ const path = __importStar(require("path"));
73
73
  const grok_binary_1 = require("./grok-binary");
74
74
  const win_1 = require("./win");
75
75
  const events_1 = require("./events");
76
+ const think_split_1 = require("./think-split");
76
77
  /** Real config dir for the logged-in account. GROK_HOME points here so the
77
78
  * entity uses the same account and token refresh works. */
78
79
  function realGrokHome() {
@@ -415,25 +416,52 @@ function spawnGrok(input) {
415
416
  emit({ type: 'assistant_text', delta });
416
417
  }
417
418
  };
419
+ // grok does not always keep reasoning on the `thought` stream: mid-run it
420
+ // will drop back to ordinary text and carry on thinking inside a <think>
421
+ // tag. Everything visible therefore goes through the splitter first, so
422
+ // private working never lands in the room. See think-split.ts.
423
+ const splitter = (0, think_split_1.createThinkSplitter)();
424
+ /** Visible answer text, already known not to be reasoning. */
425
+ const appendText = (delta) => {
426
+ // Thoughts precede the text they produced — flush them first.
427
+ flushThinking();
428
+ textBuf += delta;
429
+ if (textBuf.length >= MAX_BUF)
430
+ flushText();
431
+ else if (!flushTimer)
432
+ flushTimer = setTimeout(flushText, FLUSH_MS);
433
+ };
434
+ const appendThinking = (delta) => {
435
+ thinkBuf += delta;
436
+ if (thinkBuf.length >= THINK_MAX_BUF)
437
+ flushThinking();
438
+ else if (!thinkTimer)
439
+ thinkTimer = setTimeout(flushThinking, THINK_FLUSH_MS);
440
+ };
418
441
  const emitCoalesced = (e) => {
419
442
  if (e.type === 'assistant_text') {
420
- // Thoughts precede the text they produced flush them first.
421
- flushThinking();
422
- textBuf += e.delta;
423
- if (textBuf.length >= MAX_BUF)
424
- flushText();
425
- else if (!flushTimer)
426
- flushTimer = setTimeout(flushText, FLUSH_MS);
443
+ // A delta may be part answer and part reasoning, or may end halfway
444
+ // through a tag — the splitter holds back whatever is still ambiguous.
445
+ for (const part of splitter.push(e.delta)) {
446
+ if (part.type === 'thinking')
447
+ appendThinking(part.delta);
448
+ else if (part.type === 'assistant_text')
449
+ appendText(part.delta);
450
+ }
427
451
  }
428
452
  else if (e.type === 'thinking') {
429
- thinkBuf += e.delta;
430
- if (thinkBuf.length >= THINK_MAX_BUF)
431
- flushThinking();
432
- else if (!thinkTimer)
433
- thinkTimer = setTimeout(flushThinking, THINK_FLUSH_MS);
453
+ appendThinking(e.delta);
434
454
  }
435
455
  else {
436
- // Any other event must appear AFTER the stream so far flush both.
456
+ // Any other event must appear AFTER the stream so far. Release whatever
457
+ // the splitter is still holding: a run that ends mid-tag must not eat
458
+ // the tail of the answer.
459
+ for (const part of splitter.flush()) {
460
+ if (part.type === 'thinking')
461
+ appendThinking(part.delta);
462
+ else if (part.type === 'assistant_text')
463
+ appendText(part.delta);
464
+ }
437
465
  flushThinking();
438
466
  flushText();
439
467
  emit(e);
package/dist/index.js CHANGED
@@ -210,7 +210,12 @@ async function start(argv = []) {
210
210
  (0, projects_1.start)();
211
211
  // …and to keep desktops and the containers behind them in agreement.
212
212
  (0, manager_1.startDesktopManager)();
213
- // …and dial out to the relay so a running desktop is watchable from Studio.
213
+ // …and dial out to the relay. That socket is this node's live link: the
214
+ // server pushes work down it the instant it exists, so a command no longer
215
+ // waits up to 30s for the next heartbeat to notice it. The heartbeat and
216
+ // the request pump still run — this makes them the fallback, not the clock.
217
+ (0, relay_client_1.onWake)('command', command_runner_1.wake);
218
+ (0, relay_client_1.onWake)('request', request_pump_1.pumpNow);
214
219
  (0, relay_client_1.startRelayClient)();
215
220
  let stopped = false;
216
221
  // Outcome of the last drain, so a remote roll can report whether it left
@@ -51,6 +51,7 @@ const path = __importStar(require("path"));
51
51
  const kimi_binary_1 = require("./kimi-binary");
52
52
  const win_1 = require("./win");
53
53
  const events_1 = require("./events");
54
+ const think_split_1 = require("./think-split");
54
55
  /** Write a sandbox $HOME with an empty .kimi dir so kimi's default
55
56
  * `~/.kimi/mcp.json` resolves to nothing. Caller is responsible for
56
57
  * cleanup (we drop it under os.tmpdir() so the OS reaps it). */
@@ -269,6 +270,10 @@ function spawnKimi(input) {
269
270
  done: Promise.resolve(-1),
270
271
  };
271
272
  }
273
+ // Models do not reliably keep reasoning on their own stream — they drop
274
+ // back to ordinary text mid-run and carry on thinking inside a <think>
275
+ // tag. Split it back out before it reaches the room. See think-split.ts.
276
+ const emitSplit = (0, think_split_1.splittingEmitter)(emit);
272
277
  proc.stdout?.on('data', (chunk) => {
273
278
  touchActivity();
274
279
  buffer += chunk.toString('utf8');
@@ -288,7 +293,7 @@ function spawnKimi(input) {
288
293
  if (typeof parsed.session_id === 'string' && !sessionId)
289
294
  sessionId = parsed.session_id;
290
295
  for (const ev of lineToEvents(parsed))
291
- emit(ev);
296
+ emitSplit(ev);
292
297
  }
293
298
  });
294
299
  proc.stderr?.on('data', (chunk) => {
@@ -300,6 +305,9 @@ function spawnKimi(input) {
300
305
  const done = new Promise((resolve, reject) => {
301
306
  proc.once('error', reject);
302
307
  proc.once('exit', (code) => {
308
+ // Release anything the splitter is still holding — a run that ends
309
+ // mid-tag must not lose the tail of its answer.
310
+ emitSplit.flush();
303
311
  // Best-effort cleanup of sandbox tmp dir
304
312
  try {
305
313
  fs.rmSync(kimiSandboxDir, { recursive: true, force: true });
@@ -2,6 +2,16 @@ export declare function inflightCount(): number;
2
2
  export declare function activeRequestIdList(): string[];
3
3
  export declare function start(): void;
4
4
  export declare function stop(): void;
5
+ /**
6
+ * Pick up work right now, because the server said there is some.
7
+ *
8
+ * Called from the relay socket's `wake`. Deliberately does NOT clear
9
+ * `nextPickAllowedAt`: if the pump is backing off because Supabase is
10
+ * unhealthy, a push is no reason to start hammering it again — the backoff
11
+ * exists to protect the connection pool, and a wake that ignored it would
12
+ * reintroduce exactly the pile-up it was added to stop.
13
+ */
14
+ export declare function pumpNow(): void;
5
15
  /**
6
16
  * Wait for all in-flight requests to finish, with a hard ceiling so a
7
17
  * hung spawn can't block daemon shutdown forever. Resolves when either
@@ -8,6 +8,7 @@ exports.inflightCount = inflightCount;
8
8
  exports.activeRequestIdList = activeRequestIdList;
9
9
  exports.start = start;
10
10
  exports.stop = stop;
11
+ exports.pumpNow = pumpNow;
11
12
  exports.drain = drain;
12
13
  const supabase_client_1 = require("./supabase-client");
13
14
  const store_1 = require("./store");
@@ -236,6 +237,20 @@ function stop() {
236
237
  timer = null;
237
238
  }
238
239
  }
240
+ /**
241
+ * Pick up work right now, because the server said there is some.
242
+ *
243
+ * Called from the relay socket's `wake`. Deliberately does NOT clear
244
+ * `nextPickAllowedAt`: if the pump is backing off because Supabase is
245
+ * unhealthy, a push is no reason to start hammering it again — the backoff
246
+ * exists to protect the connection pool, and a wake that ignored it would
247
+ * reintroduce exactly the pile-up it was added to stop.
248
+ */
249
+ function pumpNow() {
250
+ if (stopped)
251
+ return;
252
+ void tick();
253
+ }
239
254
  /**
240
255
  * Wait for all in-flight requests to finish, with a hard ceiling so a
241
256
  * hung spawn can't block daemon shutdown forever. Resolves when either
@@ -0,0 +1,24 @@
1
+ import type { RuntimeEvent } from './types';
2
+ export interface ThinkSplitter {
3
+ /** Feed one visible-text delta; get back correctly-typed events. */
4
+ push(delta: string): RuntimeEvent[];
5
+ /** End of stream: release anything held back. */
6
+ flush(): RuntimeEvent[];
7
+ /** True while inside a reasoning region — the run ended mid-thought. */
8
+ get open(): boolean;
9
+ }
10
+ export declare function createThinkSplitter(): ThinkSplitter;
11
+ /**
12
+ * Wrap a harness's `emit` so visible text is split before it leaves.
13
+ *
14
+ * Harnesses map one JSON line to events with a pure function and emit them in
15
+ * a loop, so there is no natural place to keep splitter state. This holds it,
16
+ * and releases anything held back as soon as a non-text event arrives — the
17
+ * tail of an answer must never sit hostage behind a tool call.
18
+ */
19
+ export interface SplittingEmit {
20
+ (e: RuntimeEvent): void;
21
+ /** End of stream: release whatever is still held. */
22
+ flush(): void;
23
+ }
24
+ export declare function splittingEmitter(emit: (e: RuntimeEvent) => void): SplittingEmit;
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createThinkSplitter = createThinkSplitter;
4
+ exports.splittingEmitter = splittingEmitter;
5
+ /** Openers seen in the wild across grok, qwen, deepseek-style outputs. The
6
+ * closer is the same word, so one list drives both. */
7
+ const TAGS = ['think', 'thinking', 'thought', 'reason', 'reasoning', 'antml'];
8
+ /** Longest string we might have to hold back: `</reasoning>` plus slack. */
9
+ const MAX_HOLD = 14;
10
+ function openerAt(buf, i) {
11
+ if (buf[i] !== '<')
12
+ return null;
13
+ for (const tag of TAGS) {
14
+ const open = `<${tag}>`;
15
+ if (buf.slice(i, i + open.length).toLowerCase() === open)
16
+ return { tag, len: open.length };
17
+ }
18
+ return null;
19
+ }
20
+ function closerAt(buf, i, tag) {
21
+ const close = `</${tag}>`;
22
+ return buf.slice(i, i + close.length).toLowerCase() === close ? close.length : null;
23
+ }
24
+ /** True when the tail of `buf` from `i` could still grow into a tag. */
25
+ function couldBecomeTag(buf, i) {
26
+ const tail = buf.slice(i).toLowerCase();
27
+ if (!tail.startsWith('<'))
28
+ return false;
29
+ const body = tail.startsWith('</') ? tail.slice(2) : tail.slice(1);
30
+ return TAGS.some(t => t.startsWith(body)) || body.length === 0;
31
+ }
32
+ function createThinkSplitter() {
33
+ let buf = '';
34
+ let inside = null;
35
+ function drain(final) {
36
+ const out = [];
37
+ let text = '';
38
+ let think = '';
39
+ let i = 0;
40
+ while (i < buf.length) {
41
+ if (inside === null) {
42
+ const open = openerAt(buf, i);
43
+ if (open) {
44
+ inside = open.tag;
45
+ i += open.len;
46
+ continue;
47
+ }
48
+ if (!final && couldBecomeTag(buf, i) && buf.length - i < MAX_HOLD)
49
+ break;
50
+ text += buf[i];
51
+ i += 1;
52
+ }
53
+ else {
54
+ const close = closerAt(buf, i, inside);
55
+ if (close !== null) {
56
+ inside = null;
57
+ i += close;
58
+ continue;
59
+ }
60
+ if (!final && couldBecomeTag(buf, i) && buf.length - i < MAX_HOLD)
61
+ break;
62
+ think += buf[i];
63
+ i += 1;
64
+ }
65
+ }
66
+ buf = buf.slice(i);
67
+ if (think)
68
+ out.push({ type: 'thinking', delta: think });
69
+ if (text)
70
+ out.push({ type: 'assistant_text', delta: text });
71
+ return out;
72
+ }
73
+ return {
74
+ push(delta) {
75
+ if (!delta)
76
+ return [];
77
+ buf += delta;
78
+ return drain(false);
79
+ },
80
+ flush() {
81
+ // Whatever is left is real output, tag or not — never silently swallow
82
+ // the tail of an answer because it happened to look like markup.
83
+ return drain(true);
84
+ },
85
+ get open() { return inside !== null; },
86
+ };
87
+ }
88
+ function splittingEmitter(emit) {
89
+ const splitter = createThinkSplitter();
90
+ const release = () => { for (const part of splitter.flush())
91
+ emit(part); };
92
+ const out = ((e) => {
93
+ if (e.type === 'assistant_text') {
94
+ for (const part of splitter.push(e.delta))
95
+ emit(part);
96
+ return;
97
+ }
98
+ release();
99
+ emit(e);
100
+ });
101
+ out.flush = release;
102
+ return out;
103
+ }
package/dist/win.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface CliInvocation {
28
28
  * already route spawn errors into a clean per-request failure.
29
29
  */
30
30
  export declare function resolveCliInvocation(bin: string, args: string[]): CliInvocation;
31
+ /** Parse a package-manager .cmd shim for the `…\node_modules\…js` script it
32
+ * executes. Returns the absolute script path or null. Two shim dialects:
33
+ * npm's cmd-shim writes `"%dp0%\<rel>.js"` (a variable it SETs earlier);
34
+ * pnpm/yarn-classic write `"%~dp0\<rel>.js"` (batch parameter expansion —
35
+ * note: no trailing `%`). */
36
+ declare function resolveNpmShimScript(shimPath: string): string | null;
31
37
  /**
32
38
  * Kill an agent child process. POSIX: SIGINT (graceful — CLIs flush and
33
39
  * exit). win32: `taskkill /T /F` — Node's SIGINT emulation is a hard
@@ -65,3 +71,9 @@ export declare function linkOrCopyFile(real: string, dest: string, maxCopyBytes?
65
71
  /** Directory link that works unprivileged on win32 ('junction' is ignored
66
72
  * by Node on POSIX, where a normal symlink is created). */
67
73
  export declare function linkDir(real: string, dest: string): void;
74
+ /** Exposed for tests only: the shim parsing is pure file-shape logic and is
75
+ * worth exercising on any platform, not just on a Windows box. */
76
+ export declare const __testables: {
77
+ resolveNpmShimScript: typeof resolveNpmShimScript;
78
+ };
79
+ export {};
package/dist/win.js CHANGED
@@ -60,7 +60,7 @@ var __importStar = (this && this.__importStar) || (function () {
60
60
  };
61
61
  })();
62
62
  Object.defineProperty(exports, "__esModule", { value: true });
63
- exports.IS_WINDOWS = void 0;
63
+ exports.__testables = exports.IS_WINDOWS = void 0;
64
64
  exports.whereLookup = whereLookup;
65
65
  exports.npmShimCandidates = npmShimCandidates;
66
66
  exports.findCliBinary = findCliBinary;
@@ -212,11 +212,37 @@ function resolveNpmShimScript(shimPath) {
212
212
  catch {
213
213
  return null;
214
214
  }
215
- const m = body.match(/"%(?:dp0%|~dp0)[\\/]([^"]+\.(?:m|c)?js)"/i);
216
- if (!m)
217
- return null;
218
- const script = path.join(path.dirname(shimPath), m[1]);
219
- return fs.existsSync(script) ? script : null;
215
+ // Two shim shapes, and for a long time only the first was handled.
216
+ //
217
+ // package bin shim "%~dp0\..\pkg\dist\cli.js"
218
+ // npm's OWN npm.cmd SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
219
+ //
220
+ // The old pattern required the quote to sit immediately before %~dp0, which
221
+ // the first shape satisfies and the second never does — after its quote
222
+ // comes NPM_CLI_JS=. So every package shim resolved and npm.cmd itself did
223
+ // not, which is precisely the binary the auto-updater has to spawn. The
224
+ // result was a node that could not update itself on Windows at all: the roll
225
+ // failed with "could not resolve the node script it wraps" and the machine
226
+ // stayed on whatever version it was first installed with.
227
+ //
228
+ // So: find every %~dp0-relative .js in the file, wherever it sits, and take
229
+ // the first that is actually on disk. Order matters — npm.cmd names the
230
+ // local CLI before the global-prefix one, and the local one is the right
231
+ // answer when both exist.
232
+ const candidates = [];
233
+ const re = /%(?:~dp0|dp0%)[\\/]([^"\s]+\.(?:m|c)?js)/gi;
234
+ for (const m of body.matchAll(re))
235
+ candidates.push(m[1]);
236
+ for (const rel of candidates) {
237
+ // Shims always write backslashes. Split on either separator and re-join
238
+ // with the platform's, so the same parsing is exercisable off Windows —
239
+ // path.join treats a backslash as an ordinary character on POSIX, which
240
+ // would otherwise make every one of these silently unresolvable.
241
+ const script = path.join(path.dirname(shimPath), ...rel.split(/[\\/]+/));
242
+ if (fs.existsSync(script))
243
+ return script;
244
+ }
245
+ return null;
220
246
  }
221
247
  /**
222
248
  * Kill an agent child process. POSIX: SIGINT (graceful — CLIs flush and
@@ -315,3 +341,6 @@ function linkDir(real, dest) {
315
341
  }
316
342
  catch { /* target exists or FS refuses — caller treats as best-effort */ }
317
343
  }
344
+ /** Exposed for tests only: the shim parsing is pure file-shape logic and is
345
+ * worth exercising on any platform, not just on a Windows box. */
346
+ exports.__testables = { resolveNpmShimScript };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.20.0",
3
+ "version": "0.22.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": [