@openagents-org/agent-launcher 0.2.190 → 0.2.192

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.190",
3
+ "version": "0.2.192",
4
4
  "description": "OpenAgents Launcher \u2014 install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -343,6 +343,13 @@ class CodexAdapter extends BaseAdapter {
343
343
 
344
344
  if (result.responseText) {
345
345
  await this.sendResponse(msgChannel, result.responseText);
346
+ // A run that said something and then died (turn.failed, non-zero
347
+ // exit) used to post only that opening line as the finished reply,
348
+ // so it read as "Codex says one sentence and stops" with the real
349
+ // error left in daemon.log. Say it stopped, and why.
350
+ if (CodexAdapter._endedEarly(result)) {
351
+ await this._sendRunFailure(msgChannel, result, { partial: true });
352
+ }
346
353
  return;
347
354
  } else if (result.exitCode !== 0 && threadId && attempt === 0) {
348
355
  // Stale thread — clear and retry fresh
@@ -384,6 +391,7 @@ class CodexAdapter extends BaseAdapter {
384
391
  let lineBuffer = '';
385
392
  let stderrBuf = '';
386
393
  let lastErrorMessage = '';
394
+ let turnFailed = false;
387
395
  let _pendingLines = Promise.resolve();
388
396
 
389
397
  if (proc.stderr) {
@@ -433,7 +441,11 @@ class CodexAdapter extends BaseAdapter {
433
441
  this._log(`Command: ${cmdText} → exit ${exitCode}`);
434
442
  } else if (item.type === 'file_change') {
435
443
  hasToolUseSinceLastText = true;
436
- const filename = item.filename || '';
444
+ // codex reports the edited files in `changes`; `filename` is the
445
+ // legacy shape and was always empty on current CLIs.
446
+ const filename = Array.isArray(item.changes) && item.changes.length
447
+ ? item.changes.map((c) => c && c.path).filter(Boolean).join(', ')
448
+ : (item.filename || '');
437
449
  try { await this.sendStatus(msgChannel, `**Editing:** \`${filename}\``); } catch {}
438
450
  this._log(`File change: ${filename}`);
439
451
  }
@@ -441,6 +453,7 @@ class CodexAdapter extends BaseAdapter {
441
453
  const error = event.error || {};
442
454
  const errMsg = error.message || JSON.stringify(error);
443
455
  lastErrorMessage = errMsg;
456
+ turnFailed = true;
444
457
  this._log(`Turn failed: ${errMsg}`);
445
458
  } else if (eventType === 'error') {
446
459
  const errMsg = event.message || JSON.stringify(event);
@@ -481,6 +494,7 @@ class CodexAdapter extends BaseAdapter {
481
494
  exitCode: code,
482
495
  stderr: stderrBuf,
483
496
  errorMessage: lastErrorMessage,
497
+ turnFailed,
484
498
  });
485
499
  });
486
500
 
@@ -569,14 +583,31 @@ class CodexAdapter extends BaseAdapter {
569
583
  * Post a user-visible failure carrying the actual reason a run produced no
570
584
  * reply, instead of a generic "No response generated".
571
585
  */
572
- async _sendRunFailure(msgChannel, result) {
586
+ async _sendRunFailure(msgChannel, result, { partial = false } = {}) {
573
587
  const detail = CodexAdapter._failureDetail(result);
588
+ if (partial) {
589
+ const body = detail
590
+ ? `It stopped before finishing this task.\n\n> ${detail}`
591
+ : 'It stopped before finishing this task. Please try again.';
592
+ await this.sendError(msgChannel, `⚠️ **Codex stopped partway** — ${body}`);
593
+ return;
594
+ }
574
595
  const body = detail
575
596
  ? `Codex failed to complete this run.\n\n> ${detail}`
576
597
  : 'Codex finished without producing a reply. Please try again.';
577
598
  await this.sendError(msgChannel, `⚠️ **Codex couldn't run** — ${body}`);
578
599
  }
579
600
 
601
+ /**
602
+ * True when a run that produced some text did not actually finish. Only a
603
+ * turn.failed or a non-zero exit counts: codex also emits `error` events
604
+ * for retries it recovers from ("Reconnecting... 2/5"), and a run that
605
+ * reconnected and then completed is fine.
606
+ */
607
+ static _endedEarly({ exitCode, turnFailed } = {}) {
608
+ return Boolean(turnFailed) || (exitCode !== 0 && exitCode !== undefined);
609
+ }
610
+
580
611
  /**
581
612
  * Pick the most informative failure detail from a run result:
582
613
  * turn.failed / error event message → stderr tail → exit code. Redacted.
@@ -38,6 +38,7 @@ const { spawn } = require('../wsl');
38
38
  const LlmDirectAdapter = require('./llm-direct');
39
39
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
40
40
  const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
41
+ const { fixSpawnHelpersFor } = require('../node-pty-perms');
41
42
  const {
42
43
  KimiStreamParser,
43
44
  interpretKimiMessage,
@@ -70,6 +71,7 @@ const WATCHDOG_MAX = 20; // ~5 min of silence → kill
70
71
  // don't re-spawn `kimi --version`, yet an install/upgrade is re-detected.
71
72
  const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
72
73
  const _kimiVersionCache = new Map(); // binPath -> { version, product, at }
74
+ const _ptyFixedBins = new Set(); // binPaths whose node-pty spawn-helper was checked
73
75
 
74
76
  /** Newest mtime (ms) of a directory and everything under it, `depth` levels deep. */
75
77
  function newestMtimeMs(dir, depth) {
@@ -522,6 +524,15 @@ class KimiAdapter extends LlmDirectAdapter {
522
524
  return;
523
525
  }
524
526
 
527
+ // An install from before the installer restored node-pty's spawn-helper
528
+ // (or one the user made) still has it at 0644, and every terminal Kimi
529
+ // opens fails with "posix_spawnp failed." Once per binary per daemon.
530
+ if (!_ptyFixedBins.has(kimiBin)) {
531
+ _ptyFixedBins.add(kimiBin);
532
+ const fixed = fixSpawnHelpersFor(kimiBin);
533
+ if (fixed.length) this._log(`Made node-pty spawn-helper executable: ${fixed.join(', ')}`);
534
+ }
535
+
525
536
  await this._handleMessageCli(msg, kimiBin);
526
537
  }
527
538
 
@@ -24,7 +24,8 @@
24
24
  *
25
25
  * Verified against pi 0.83.0 (engines.node >= 22.19.0). Because that engine
26
26
  * floor is above what a user's ambient Node may satisfy, a resolved JS entry
27
- * point is launched with the launcher's own portable Node (v22.22.3) and
27
+ * point is launched with the launcher's own portable Node (v24, or v22 on
28
+ * macOS before 13.5) and
28
29
  * preflight refuses to start on anything older.
29
30
  */
30
31
 
package/src/cli.js CHANGED
@@ -582,7 +582,17 @@ async function cmdNode(connector, flags, positional) {
582
582
  process.exitCode = 1;
583
583
  return;
584
584
  }
585
- const info = nodeCfg.gatherDeviceInfo();
585
+ let info;
586
+ try {
587
+ info = nodeCfg.gatherDeviceInfo();
588
+ } catch (e) {
589
+ // Pairing on top of an unreadable node.json would replace the pairings
590
+ // it holds; stop instead, so nothing is lost while it is sorted out.
591
+ print(`Could not read ${nodeCfg.NODE_FILE}: ${e.message}`);
592
+ print('Close anything that may have it open and try again.');
593
+ process.exitCode = 1;
594
+ return;
595
+ }
586
596
  if (flags.type) info.deviceType = flags.type; // server | laptop | desktop
587
597
  if (flags.name) info.name = flags.name;
588
598
 
package/src/config.js CHANGED
@@ -180,48 +180,62 @@ class Config {
180
180
 
181
181
  getLogs(agentName, lines = 200) {
182
182
  try {
183
- if (!fs.existsSync(this.logFile)) return [];
184
- const content = fs.readFileSync(this.logFile, 'utf-8');
185
- let allLines = content.split('\n').filter(Boolean);
186
- if (agentName) {
187
- allLines = allLines.filter(
188
- (l) => l.includes(agentName) || l.includes('daemon') || l.includes('Daemon')
189
- );
190
- }
191
- return allLines.slice(-lines);
183
+ return this.tailLogs({ agent: agentName, lines }).lines;
192
184
  } catch {
193
185
  return [];
194
186
  }
195
187
  }
196
188
 
197
189
  /**
198
- * Tail log file with optional filter. Returns { lines, size }.
190
+ * Tail log file with optional filter. Returns { lines, size, reset }.
191
+ * Reads only the end of the file (or what was appended since `offset`), so
192
+ * the cost tracks what is returned rather than how large daemon.log grew.
199
193
  * @param {Object} opts - { agent, lines, offset }
200
194
  * @param {string} [opts.agent] - Filter by agent name
201
195
  * @param {number} [opts.lines=100] - Number of lines to return
202
196
  * @param {number} [opts.offset=0] - Byte offset for incremental reads (0 = from end)
197
+ * @returns {{ lines: string[], size: number, reset: boolean }} `size` is the
198
+ * offset for the next call; `reset` means the file shrank (cleared or
199
+ * replaced) and `lines` replace what the caller had.
203
200
  */
204
201
  tailLogs(opts = {}) {
205
202
  const { agent, lines = 100, offset = 0 } = opts;
203
+ const keep = agent
204
+ ? (l) => l.includes(agent) || l.includes('daemon') || l.includes('Daemon')
205
+ : () => true;
206
+ let size;
206
207
  try {
207
- if (!fs.existsSync(this.logFile)) return { lines: [], size: 0 };
208
- const stat = fs.statSync(this.logFile);
209
- if (offset > 0 && offset < stat.size) {
210
- // Incremental read from offset
211
- const fd = fs.openSync(this.logFile, 'r');
212
- const buf = Buffer.alloc(stat.size - offset);
213
- fs.readSync(fd, buf, 0, buf.length, offset);
214
- fs.closeSync(fd);
215
- let newLines = buf.toString('utf-8').split('\n').filter(Boolean);
216
- if (agent) {
217
- newLines = newLines.filter(l => l.includes(agent) || l.includes('Daemon'));
208
+ size = fs.statSync(this.logFile).size;
209
+ } catch {
210
+ return { lines: [], size: 0, reset: offset > 0 };
211
+ }
212
+ // Nothing appended since the last read.
213
+ if (offset > 0 && offset === size) return { lines: [], size, reset: false };
214
+
215
+ try {
216
+ const fd = fs.openSync(this.logFile, 'r');
217
+ try {
218
+ if (offset > 0 && offset < size) {
219
+ const start = Math.max(offset, size - MAX_INCREMENT_BYTES);
220
+ const buf = readRange(fd, start, size);
221
+ const lastNewline = buf.lastIndexOf(0x0a);
222
+ // A line still being written is left for the next read.
223
+ if (lastNewline < 0) {
224
+ return { lines: [], size: start > offset ? size : offset, reset: false };
225
+ }
226
+ let body = buf.subarray(0, lastNewline + 1);
227
+ // Skipped part of the backlog: drop the fragment the read landed in.
228
+ if (start > offset) body = body.subarray(body.indexOf(0x0a) + 1);
229
+ return { lines: splitLines(body, keep), size: start + lastNewline + 1, reset: false };
218
230
  }
219
- return { lines: newLines, size: stat.size };
231
+ // First read, or the file shrank under us (cleared, replaced).
232
+ const tail = readLastLines(fd, size, lines, keep);
233
+ return { lines: tail.lines, size: tail.end, reset: offset > 0 };
234
+ } finally {
235
+ fs.closeSync(fd);
220
236
  }
221
- // Full read, return last N
222
- return { lines: this.getLogs(agent, lines), size: stat.size };
223
237
  } catch {
224
- return { lines: [], size: 0 };
238
+ return { lines: [], size: 0, reset: offset > 0 };
225
239
  }
226
240
  }
227
241
 
@@ -323,6 +337,62 @@ function parseYaml(text) {
323
337
  return result;
324
338
  }
325
339
 
340
+ /** Bytes read per step when walking back from the end of the log. */
341
+ const TAIL_CHUNK = 64 * 1024;
342
+ /** How far back a first read may walk looking for lines matching the agent filter. */
343
+ const MAX_SCAN_BYTES = 16 * 1024 * 1024;
344
+ /** The most an incremental read takes in one go; older backlog is skipped. */
345
+ const MAX_INCREMENT_BYTES = 4 * 1024 * 1024;
346
+
347
+ function readRange(fd, start, end) {
348
+ const buf = Buffer.alloc(end - start);
349
+ let read = 0;
350
+ while (read < buf.length) {
351
+ const n = fs.readSync(fd, buf, read, buf.length - read, start + read);
352
+ if (n === 0) break;
353
+ read += n;
354
+ }
355
+ return read === buf.length ? buf : buf.subarray(0, read);
356
+ }
357
+
358
+ function splitLines(buf, keep) {
359
+ return buf.toString('utf-8').split('\n').filter((l) => l && keep(l));
360
+ }
361
+
362
+ /**
363
+ * The last `count` matching lines, read backwards in chunks. Stops at the last
364
+ * newline: a line still being written is left for the next incremental read.
365
+ */
366
+ function readLastLines(fd, size, count, keep) {
367
+ const lastChunk = readRange(fd, Math.max(0, size - TAIL_CHUNK), size);
368
+ const lastNewline = lastChunk.lastIndexOf(0x0a);
369
+ const end = lastNewline >= 0 ? size - lastChunk.length + lastNewline + 1 : size;
370
+
371
+ let lines = [];
372
+ let pos = end;
373
+ let carry = Buffer.alloc(0);
374
+ while (pos > 0 && lines.length < count && end - pos < MAX_SCAN_BYTES) {
375
+ const start = Math.max(0, pos - TAIL_CHUNK);
376
+ const block = Buffer.concat([readRange(fd, start, pos), carry]);
377
+ pos = start;
378
+ let body = block;
379
+ if (pos > 0) {
380
+ // The block starts mid-line; that fragment joins the next block.
381
+ const firstNewline = block.indexOf(0x0a);
382
+ if (firstNewline < 0) {
383
+ carry = block;
384
+ continue;
385
+ }
386
+ carry = block.subarray(0, firstNewline);
387
+ body = block.subarray(firstNewline + 1);
388
+ } else {
389
+ carry = Buffer.alloc(0);
390
+ }
391
+ lines = [...splitLines(body, keep), ...lines];
392
+ }
393
+ return { lines: lines.slice(-count), end };
394
+ }
395
+
326
396
  function normalizeTimeValue(value) {
327
397
  if (value instanceof Date) {
328
398
  return Number.isNaN(value.getTime()) ? null : value;
package/src/daemon.js CHANGED
@@ -86,6 +86,15 @@ function _sentKey(args) {
86
86
  return present && !args.useDeviceCredentials;
87
87
  }
88
88
 
89
+ /**
90
+ * Whether a node heartbeat failure is the workspace saying the node is gone:
91
+ * a 404 carrying the backend's own message (routers/nodes.py), not any 404.
92
+ */
93
+ const NODE_GONE_MESSAGES = new Set(['Node not found', 'Workspace not found']);
94
+ function _nodeGone(err) {
95
+ return !!err && err.status === 404 && NODE_GONE_MESSAGES.has(err.message);
96
+ }
97
+
89
98
  class Daemon {
90
99
  constructor(config, envManager, registry) {
91
100
  this.config = config;
@@ -206,7 +215,9 @@ class Daemon {
206
215
  const nodeCfg = require('./node-config');
207
216
  const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
208
217
  if (!pairings.length) return; // no node connected
209
- const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimes || [], fs: this._buildFs() };
218
+ // deviceInfo, not gatherDeviceInfo: the heartbeat sends no device key, and
219
+ // must never be the thing that writes node.json.
220
+ const info = { ...nodeCfg.deviceInfo(), runtimes: this._runtimes || [], fs: this._buildFs() };
210
221
  await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
211
222
  }
212
223
 
@@ -264,8 +275,12 @@ class Daemon {
264
275
  // Without this the launcher kept showing "connected" long after a remote
265
276
  // removal, since node.json was only ever reconciled by the UI on demand.
266
277
  // Transient failures (timeouts, 5xx, auth blips) say nothing about the
267
- // row's existence, so those are swallowed and retried next tick.
268
- if (err && err.status === 404) {
278
+ // row's existence, so those are swallowed and retried next tick. So is a
279
+ // 404 the backend did not write: the hosting platform answers every
280
+ // request with its own 404 ("Application not found") while the service
281
+ // is unreachable, and taking that as the workspace's word unpaired every
282
+ // workspace on the device at once.
283
+ if (_nodeGone(err)) {
269
284
  try { nodeCfg.clearPairing(n.workspace_id); } catch {}
270
285
  this._nodeClients.delete(n.workspace_id);
271
286
  this._log(`node ${n.node_id} no longer recognized by workspace ${n.workspace_slug || n.workspace_id} — pairing cleared`);
package/src/installer.js CHANGED
@@ -23,6 +23,7 @@ const { nodeDistUrls, installRegistry } = require('./mirrors');
23
23
  const { readinessReason, REASON } = require('./adapters/health-status');
24
24
  const { checkInstallPrereqs, missingPrereqError } = require('./install-preflight');
25
25
  const { detectShadowedNodeShims, shadowedNodeWarning } = require('./node-shims');
26
+ const { fixSpawnHelpers } = require('./node-pty-perms');
26
27
 
27
28
  const STATUS_CACHE_TTL_MS = 10000;
28
29
  const statusCache = new Map();
@@ -1130,14 +1131,16 @@ class Installer {
1130
1131
  await this._bootstrapManagedUv(agentType, bootstrapEnv, null);
1131
1132
 
1132
1133
  // Use bundled node/npm if system npm not available
1133
- if (cmd.startsWith('npm install')) {
1134
- const prefixDir = getRuntimePrefix(agentType);
1134
+ const npmPrefix = cmd.startsWith('npm install') ? getRuntimePrefix(agentType) : null;
1135
+ if (npmPrefix) {
1136
+ const prefixDir = npmPrefix;
1135
1137
  fs.mkdirSync(prefixDir, { recursive: true });
1136
1138
  const args = cmd.replace('npm install', 'install --save').replace(' -g ', ` --prefix "${prefixDir}" `);
1137
1139
  cmd = this._resolveNpmCommand(args);
1138
1140
  }
1139
1141
 
1140
1142
  const output = await this._execShell(cmd, 300000, installEnv);
1143
+ if (npmPrefix) fixSpawnHelpers(npmPrefix);
1141
1144
 
1142
1145
  // Aider-only: the curl/uv/pipx installer can exit 0 without landing a
1143
1146
  // runnable (or genuine) binary, so verify the real CLI exists BEFORE
@@ -1419,6 +1422,10 @@ class Installer {
1419
1422
  }
1420
1423
  if (onData) onData(`\nCursor CLI resolved: ${cursor.path}\n`);
1421
1424
  }
1425
+ if (rawCmd.startsWith('npm install')) {
1426
+ const fixed = fixSpawnHelpers(installCwd);
1427
+ if (fixed.length && onData) onData(`\nMade ${fixed.length} node-pty spawn-helper(s) executable.\n`);
1428
+ }
1422
1429
  this._markInstalled(agentType);
1423
1430
  if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
1424
1431
  resolve({ success: true, command: displayCmd });
@@ -2153,6 +2160,20 @@ class Installer {
2153
2160
  return false;
2154
2161
  }
2155
2162
 
2163
+ /**
2164
+ * The Node.js the portable runtime gets — the same pin as the launcher's
2165
+ * bootstrap/node-version.ts, which owns upgrading an existing runtime; this
2166
+ * copy only ever installs onto a machine with no Node at all. Node 24 needs
2167
+ * macOS 13.5 (Darwin 22.6), so older Macs get the last Node 22 instead.
2168
+ */
2169
+ static portableNodeVersion(platform = process.platform, release = require('os').release()) {
2170
+ if (platform === 'darwin') {
2171
+ const [major, minor] = String(release).split('.').map(Number);
2172
+ if (major < 22 || (major === 22 && minor < 6)) return 'v22.22.3';
2173
+ }
2174
+ return 'v24.21.0';
2175
+ }
2176
+
2156
2177
  /**
2157
2178
  * Download and install Node.js LTS. Streams progress via onData callback.
2158
2179
  * After install, updates PATH so npm is available for subsequent commands.
@@ -2163,14 +2184,15 @@ class Installer {
2163
2184
  const { spawn: spawnProc } = require('child_process');
2164
2185
  const https = require('https');
2165
2186
  const os = require('os');
2166
- const nodeVersion = 'v22.22.3';
2167
2187
  const plat = Installer.platform();
2188
+ const nodeVersion = Installer.portableNodeVersion();
2168
2189
 
2169
2190
  if (onData) onData(`Node.js not found. Installing Node.js ${nodeVersion}...\n\n`);
2170
2191
 
2171
2192
  if (plat === 'windows') {
2172
2193
  // Download portable zip — no admin required
2173
- const arch = os.arch() === 'x64' ? 'x64' : 'x86';
2194
+ // No 32-bit Windows build exists past Node 22.
2195
+ const arch = os.arch() === 'arm64' ? 'arm64' : 'x64';
2174
2196
  const zipPath = path.join(os.tmpdir(), `node-${nodeVersion}.zip`);
2175
2197
 
2176
2198
  await this._downloadFirst(
@@ -16,30 +16,64 @@ const crypto = require('crypto');
16
16
  const NODE_DIR = path.join(os.homedir(), '.openagents');
17
17
  const NODE_FILE = path.join(NODE_DIR, 'node.json');
18
18
 
19
- function loadNode() {
19
+ /**
20
+ * The record on disk: null when there is no file, and a throw when there is
21
+ * one that cannot be read or parsed (half-written by another process, or held
22
+ * by an antivirus scan on Windows). Every writer reads through this, so an
23
+ * unreadable file is left alone rather than rebuilt from nothing — which
24
+ * dropped every pairing and minted a new device key.
25
+ */
26
+ function readNode() {
27
+ let raw;
20
28
  try {
21
- if (fs.existsSync(NODE_FILE)) return JSON.parse(fs.readFileSync(NODE_FILE, 'utf-8'));
22
- } catch {}
23
- return null;
29
+ raw = fs.readFileSync(NODE_FILE, 'utf-8');
30
+ } catch (err) {
31
+ if (err && err.code === 'ENOENT') return null;
32
+ throw err;
33
+ }
34
+ return JSON.parse(raw);
24
35
  }
25
36
 
37
+ /** Lenient read for display and heartbeat: an unreadable file counts as none. */
38
+ function loadNode() {
39
+ try { return readNode(); } catch { return null; }
40
+ }
41
+
42
+ /**
43
+ * Written through a temp file and a rename, so a reader in the other process
44
+ * (launcher or daemon) sees the old record or the new one, never a torn one.
45
+ */
26
46
  function saveNode(data) {
27
47
  try {
28
48
  fs.mkdirSync(NODE_DIR, { recursive: true });
29
- fs.writeFileSync(NODE_FILE, JSON.stringify(data, null, 2));
30
- try { fs.chmodSync(NODE_FILE, 0o600); } catch {} // holds the workspace token
49
+ const tmp = `${NODE_FILE}.${process.pid}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); // holds the workspace token
51
+ try {
52
+ fs.renameSync(tmp, NODE_FILE);
53
+ } catch {
54
+ // Windows refuses the rename while another process holds the target.
55
+ try { fs.unlinkSync(tmp); } catch {}
56
+ fs.writeFileSync(NODE_FILE, JSON.stringify(data, null, 2));
57
+ }
58
+ try { fs.chmodSync(NODE_FILE, 0o600); } catch {}
31
59
  } catch {}
32
60
  }
33
61
 
34
- /** Stable per-device id, generated once and persisted. */
62
+ /** Stable per-device id, generated once and persisted. Throws if node.json is unreadable. */
35
63
  function getOrCreateNodeKey() {
36
- const existing = loadNode();
64
+ const existing = readNode();
37
65
  if (existing && existing.node_key) return existing.node_key;
38
66
  const key = (crypto.randomUUID && crypto.randomUUID()) || crypto.randomBytes(16).toString('hex');
39
67
  saveNode({ ...(existing || {}), node_key: key });
40
68
  return key;
41
69
  }
42
70
 
71
+ function pairingsOf(record) {
72
+ if (!record) return [];
73
+ if (record.pairings && record.pairings.length) return record.pairings;
74
+ return record.workspace_id ? [record] : [];
75
+ }
76
+
43
77
  /**
44
78
  * Every workspace this device is paired to, most recent first.
45
79
  *
@@ -49,10 +83,7 @@ function getOrCreateNodeKey() {
49
83
  * existed carry a single top-level pairing, which is reconstructed here.
50
84
  */
51
85
  function listPairings() {
52
- const record = loadNode();
53
- if (!record) return [];
54
- if (record.pairings && record.pairings.length) return record.pairings;
55
- return record.workspace_id ? [record] : [];
86
+ return pairingsOf(loadNode());
56
87
  }
57
88
 
58
89
  /**
@@ -67,7 +98,7 @@ function recordPairing(nodeKey, pairing) {
67
98
  const stamped = { ...pairing, paired_at: new Date().toISOString() };
68
99
  const pairings = [
69
100
  stamped,
70
- ...listPairings().filter((p) => p.workspace_id !== pairing.workspace_id),
101
+ ...pairingsOf(readNode()).filter((p) => p.workspace_id !== pairing.workspace_id),
71
102
  ];
72
103
  saveNode({ node_key: nodeKey, ...stamped, pairings });
73
104
  }
@@ -86,12 +117,12 @@ function recordPairing(nodeKey, pairing) {
86
117
  * @returns the pairing that was dropped, or null if there was no such pairing.
87
118
  */
88
119
  function clearPairing(workspaceId) {
89
- const record = loadNode();
120
+ const record = readNode();
90
121
  if (!record) return null;
91
122
  const target = workspaceId || record.workspace_id;
92
123
  if (!target) return null;
93
124
 
94
- const pairings = listPairings();
125
+ const pairings = pairingsOf(record);
95
126
  const dropped = pairings.find((p) => p.workspace_id === target);
96
127
  if (!dropped) return null;
97
128
 
@@ -114,12 +145,11 @@ function inferDeviceType() {
114
145
  return 'server'; // linux is typically a server/VM in this context
115
146
  }
116
147
 
117
- /** Device info sent on redeem/heartbeat. */
118
- function gatherDeviceInfo() {
148
+ /** Device info sent on every heartbeat. Reads and writes nothing on disk. */
149
+ function deviceInfo() {
119
150
  let launcherVersion = '';
120
151
  try { launcherVersion = require('../package.json').version; } catch {}
121
152
  return {
122
- nodeKey: getOrCreateNodeKey(),
123
153
  hostname: os.hostname(),
124
154
  os: os.platform(),
125
155
  deviceType: inferDeviceType(),
@@ -127,7 +157,13 @@ function gatherDeviceInfo() {
127
157
  };
128
158
  }
129
159
 
160
+ /** Device info sent on redeem, with the device key (minted on first pairing). */
161
+ function gatherDeviceInfo() {
162
+ return { nodeKey: getOrCreateNodeKey(), ...deviceInfo() };
163
+ }
164
+
130
165
  module.exports = {
166
+ readNode,
131
167
  loadNode,
132
168
  saveNode,
133
169
  listPairings,
@@ -135,6 +171,7 @@ module.exports = {
135
171
  clearPairing,
136
172
  getOrCreateNodeKey,
137
173
  inferDeviceType,
174
+ deviceInfo,
138
175
  gatherDeviceInfo,
139
176
  NODE_FILE,
140
177
  NODE_DIR,
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Restore the execute bit on node-pty's `spawn-helper`.
3
+ *
4
+ * node-pty 1.1.0 publishes its macOS/Linux prebuilds with
5
+ * `prebuilds/<platform>/spawn-helper` at mode 0644, and npm does not fix it
6
+ * (the package has no install script to). pty.spawn() execs that helper, so
7
+ * every terminal the CLI opens dies with "posix_spawnp failed." — seen with
8
+ * Kimi Code, whose optional `node-pty` dependency resolves to 1.1.0. Every
9
+ * node-pty fork that ships a spawn-helper is treated the same, so the next CLI
10
+ * that pulls one in is covered too.
11
+ *
12
+ * Nothing to do on Windows: there is no spawn-helper and no mode bits.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const IS_WINDOWS = process.platform === 'win32';
21
+
22
+ /** Nested node_modules deeper than this are not worth walking. */
23
+ const MAX_DEPTH = 6;
24
+
25
+ /** Packages whose spawn-helper this module restores. */
26
+ const PTY_PACKAGES = [
27
+ 'node-pty',
28
+ // @lydell keeps its prebuilds in a per-platform package.
29
+ `@lydell/node-pty-${process.platform}-${process.arch}`,
30
+ ];
31
+
32
+ function readDir(dir) {
33
+ try { return fs.readdirSync(dir); } catch { return []; }
34
+ }
35
+
36
+ /**
37
+ * chmod +x every spawn-helper inside one package directory.
38
+ * @returns {string[]} the helpers that were fixed
39
+ */
40
+ function fixPackage(pkgDir) {
41
+ const helpers = [path.join(pkgDir, 'build', 'Release', 'spawn-helper')];
42
+ const prebuilds = path.join(pkgDir, 'prebuilds');
43
+ for (const plat of readDir(prebuilds)) {
44
+ helpers.push(path.join(prebuilds, plat, 'spawn-helper'));
45
+ }
46
+ const fixed = [];
47
+ for (const helper of helpers) {
48
+ try {
49
+ const { mode } = fs.statSync(helper);
50
+ if ((mode & 0o111) === 0o111) continue;
51
+ fs.chmodSync(helper, mode | 0o755);
52
+ fixed.push(helper);
53
+ } catch {}
54
+ }
55
+ return fixed;
56
+ }
57
+
58
+ /**
59
+ * Walk an npm prefix's node_modules (nested ones included) and fix every
60
+ * package that carries a spawn-helper. Run after an npm install.
61
+ * @param {string} prefixDir - the `--prefix` the agent was installed into
62
+ * @returns {string[]} the helpers that were fixed
63
+ */
64
+ function fixSpawnHelpers(prefixDir) {
65
+ if (IS_WINDOWS) return [];
66
+ const fixed = [];
67
+ const visit = (modulesDir, depth) => {
68
+ if (depth > MAX_DEPTH) return;
69
+ for (const entry of readDir(modulesDir)) {
70
+ if (entry.startsWith('.')) continue;
71
+ const dirs = entry.startsWith('@')
72
+ ? readDir(path.join(modulesDir, entry)).map((s) => path.join(modulesDir, entry, s))
73
+ : [path.join(modulesDir, entry)];
74
+ for (const pkgDir of dirs) {
75
+ fixed.push(...fixPackage(pkgDir));
76
+ visit(path.join(pkgDir, 'node_modules'), depth + 1);
77
+ }
78
+ }
79
+ };
80
+ visit(path.join(prefixDir, 'node_modules'), 0);
81
+ return fixed;
82
+ }
83
+
84
+ /**
85
+ * Fix the node-pty a CLI would load, resolved the way Node resolves it from
86
+ * the CLI's own location — for an install that predates fixSpawnHelpers, or
87
+ * one the user made themselves.
88
+ * @param {string} fromPath - the CLI binary or entry script
89
+ * @returns {string[]} the helpers that were fixed
90
+ */
91
+ function fixSpawnHelpersFor(fromPath) {
92
+ if (IS_WINDOWS || !fromPath) return [];
93
+ let start;
94
+ try { start = path.dirname(fs.realpathSync(fromPath)); } catch { return []; }
95
+ // Node's lookup by hand: the nearest node_modules/<name> going up. Not
96
+ // require.resolve — a package with an `exports` map refuses package.json.
97
+ const fixed = [];
98
+ for (const name of PTY_PACKAGES) {
99
+ for (let dir = start; ; dir = path.dirname(dir)) {
100
+ const pkgDir = path.join(dir, 'node_modules', name);
101
+ if (fs.existsSync(path.join(pkgDir, 'package.json'))) {
102
+ fixed.push(...fixPackage(pkgDir));
103
+ break;
104
+ }
105
+ if (path.dirname(dir) === dir) break;
106
+ }
107
+ }
108
+ return fixed;
109
+ }
110
+
111
+ module.exports = { fixSpawnHelpers, fixSpawnHelpersFor };