@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 +1 -1
- package/src/adapters/codex.js +33 -2
- package/src/adapters/kimi.js +11 -0
- package/src/adapters/pi.js +2 -1
- package/src/cli.js +11 -1
- package/src/config.js +95 -25
- package/src/daemon.js +18 -3
- package/src/installer.js +26 -4
- package/src/node-config.js +55 -18
- package/src/node-pty-perms.js +111 -0
package/package.json
CHANGED
package/src/adapters/codex.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/adapters/kimi.js
CHANGED
|
@@ -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
|
|
package/src/adapters/pi.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
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(
|
package/src/node-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
-
} catch {
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
...
|
|
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 =
|
|
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 =
|
|
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
|
|
118
|
-
function
|
|
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 };
|