@openagents-org/agent-launcher 0.2.191 → 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.191",
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/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/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(
@@ -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 };