@openagents-org/agent-launcher 0.2.191 → 0.2.193

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.193",
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": {
@@ -35,6 +35,7 @@ const {
35
35
  classifyHeartbeatError,
36
36
  httpStatusOf,
37
37
  } = require('./health-status');
38
+ const { FailureStreak, isTransientNetworkError, formatDuration } = require('./failure-streak');
38
39
 
39
40
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
40
41
 
@@ -104,7 +105,7 @@ class BaseAdapter {
104
105
  * @param {string} opts.agentName
105
106
  * @param {string} [opts.endpoint]
106
107
  */
107
- constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir, onStatus }) {
108
+ constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir, onStatus, logSink }) {
108
109
  this.workspaceId = workspaceId;
109
110
  this.channelName = channelName;
110
111
  this.token = token;
@@ -198,10 +199,17 @@ class BaseAdapter {
198
199
  // (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
199
200
  // restart" rather than the long-running daemon's process uptime.
200
201
  this._startedAt = Date.now();
201
- this._log = (msg) => {
202
+ // Inside the daemon, `logSink` routes lines through its daemon.log writer
203
+ // (batching, repeat suppression, rotation). Standalone, they go to stdout.
204
+ const sink = typeof logSink === 'function' ? logSink : null;
205
+ this._log = (msg, level = 'info') => {
206
+ if (sink) return sink(level, msg);
202
207
  const ts = new Date().toISOString();
203
- console.log(`${ts} INFO adapter [${this.agentName}]: ${msg}`);
208
+ console.log(`${ts} ${String(level).toUpperCase()} adapter [${this.agentName}]: ${msg}`);
204
209
  };
210
+ // Runs of identical background failures (see failure-streak.js).
211
+ this._pollFailures = new FailureStreak();
212
+ this._heartbeatFailures = new FailureStreak();
205
213
  }
206
214
 
207
215
  // ------------------------------------------------------------------
@@ -337,7 +345,7 @@ class BaseAdapter {
337
345
  this._log(`Synced model from workspace: ${this.workspaceModel}`);
338
346
  }
339
347
  } catch (e) {
340
- this._log(`Warning: skill sync failed (non-fatal): ${e.message}`);
348
+ this._log(`Warning: skill sync failed (non-fatal): ${e.message}`, 'warn');
341
349
  }
342
350
 
343
351
  // Fast-path operations (control-event cursor + heartbeat + control poll)
@@ -352,7 +360,7 @@ class BaseAdapter {
352
360
  try {
353
361
  // Send initial heartbeat
354
362
  try { await this._heartbeat(); } catch (e) {
355
- this._log(`Heartbeat failed (non-fatal): ${e.message}`);
363
+ this._log(`Heartbeat failed (non-fatal): ${e.message}`, 'warn');
356
364
  }
357
365
  // Slow path: only the message-poll loop waits for this.
358
366
  await this._skipExistingEvents();
@@ -412,7 +420,7 @@ class BaseAdapter {
412
420
  } catch (e) {
413
421
  if (!this._warnedCursorPersist) {
414
422
  this._warnedCursorPersist = true;
415
- this._log(`Could not persist poll cursor (${e.message}) — a restart will skip messages that arrive while offline`);
423
+ this._log(`Could not persist poll cursor (${e.message}) — a restart will skip messages that arrive while offline`, 'warn');
416
424
  }
417
425
  }
418
426
  }
@@ -457,10 +465,14 @@ class BaseAdapter {
457
465
  try {
458
466
  await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
459
467
  this._heartbeatFailStreak = 0;
468
+ const recovered = this._heartbeatFailures.succeed();
469
+ if (recovered) {
470
+ this._log(`Workspace heartbeat recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
471
+ }
460
472
  this._reportStatus(null); // alive → clear any prior connectivity error
461
473
  } catch (e) {
462
474
  if (e instanceof SessionRevokedError) {
463
- this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
475
+ this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`, 'warn');
464
476
  // Terminal (not a user stop): record so the daemon can show why it ended.
465
477
  this._setExitInfo(REASON.SESSION_REVOKED, 'Workspace session revoked — another client joined with the same agent name');
466
478
  this._reportStatus(REASON.SESSION_REVOKED, 'Workspace session revoked');
@@ -471,7 +483,9 @@ class BaseAdapter {
471
483
  // single transient blip (or an expected brief reconnect) isn't mislabeled.
472
484
  this._heartbeatFailStreak++;
473
485
  const { reason, message } = classifyHeartbeatError(e);
474
- this._log(`${message} (consecutive failures: ${this._heartbeatFailStreak})`);
486
+ if (this._heartbeatFailures.fail()) {
487
+ this._log(`${message} (consecutive failures: ${this._heartbeatFailStreak})`, 'warn');
488
+ }
475
489
  if (this._heartbeatFailStreak >= HEARTBEAT_ERROR_THRESHOLD) {
476
490
  this._reportStatus(reason, message);
477
491
  }
@@ -593,7 +607,7 @@ class BaseAdapter {
593
607
  }
594
608
  if (!channel) {
595
609
  try { await this._stopSharedWork(); } catch (e) {
596
- this._log(`Stop: shared work did not stop cleanly: ${e && e.message ? e.message : e}`);
610
+ this._log(`Stop: shared work did not stop cleanly: ${e && e.message ? e.message : e}`, 'warn');
597
611
  }
598
612
  }
599
613
  for (const ch of channels) {
@@ -601,7 +615,7 @@ class BaseAdapter {
601
615
  try {
602
616
  outcome = await this._stopChannelWork(ch);
603
617
  } catch (e) {
604
- this._log(`Stop: ${ch} did not stop cleanly: ${e && e.message ? e.message : e}`);
618
+ this._log(`Stop: ${ch} did not stop cleanly: ${e && e.message ? e.message : e}`, 'warn');
605
619
  outcome = 'failed';
606
620
  }
607
621
  // The pressed channel is always answered, so its UI settles even when
@@ -788,7 +802,7 @@ class BaseAdapter {
788
802
  skillId, state: 'installing',
789
803
  });
790
804
  } catch (e) {
791
- this._log(`skill.install: could not report 'installing' (non-fatal): ${e && e.message ? e.message : e}`);
805
+ this._log(`skill.install: could not report 'installing' (non-fatal): ${e && e.message ? e.message : e}`, 'warn');
792
806
  }
793
807
 
794
808
  try {
@@ -822,19 +836,19 @@ class BaseAdapter {
822
836
  skillId, state: 'installed', path: result.path, partial: result.partial === true,
823
837
  });
824
838
  } catch (e) {
825
- this._log(`skill.install: installed on disk but failed to report 'installed': ${e && e.message ? e.message : e}`);
839
+ this._log(`skill.install: installed on disk but failed to report 'installed': ${e && e.message ? e.message : e}`, 'warn');
826
840
  }
827
841
  this._log(`skill.install: SUCCESS "${skillId}" → ${result.path}${result.partial ? ' (partial)' : ''}`);
828
842
  await this._onSkillsChanged();
829
843
  } catch (e) {
830
844
  const msg = e && e.message ? e.message : String(e);
831
- this._log(`skill.install: FAILED "${skillId}": ${msg}`);
845
+ this._log(`skill.install: FAILED "${skillId}": ${msg}`, 'error');
832
846
  try {
833
847
  await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, {
834
848
  skillId, state: 'failed', error: msg,
835
849
  });
836
850
  } catch (e2) {
837
- this._log(`skill.install: also failed to report 'failed': ${e2 && e2.message ? e2.message : e2}`);
851
+ this._log(`skill.install: also failed to report 'failed': ${e2 && e2.message ? e2.message : e2}`, 'warn');
838
852
  }
839
853
  }
840
854
  }
@@ -863,12 +877,12 @@ class BaseAdapter {
863
877
  skillId, state: 'uninstalled',
864
878
  });
865
879
  } catch (e) {
866
- this._log(`skill.uninstall: failed to report status: ${e && e.message ? e.message : e}`);
880
+ this._log(`skill.uninstall: failed to report status: ${e && e.message ? e.message : e}`, 'warn');
867
881
  }
868
882
  await this._onSkillsChanged();
869
883
  } catch (e) {
870
884
  const msg = e && e.message ? e.message : String(e);
871
- this._log(`skill.uninstall: FAILED "${skillId}": ${msg}`);
885
+ this._log(`skill.uninstall: FAILED "${skillId}": ${msg}`, 'error');
872
886
  }
873
887
  }
874
888
 
@@ -923,7 +937,7 @@ class BaseAdapter {
923
937
  sessionId: this._sessionId,
924
938
  });
925
939
  } catch (e) {
926
- this._log(`Status: failed to post: ${e && e.message ? e.message : e}`);
940
+ this._log(`Status: failed to post: ${e && e.message ? e.message : e}`, 'warn');
927
941
  }
928
942
  }
929
943
 
@@ -950,7 +964,7 @@ class BaseAdapter {
950
964
  (r) => r.created_by === prefixed || r.created_by === this.agentName,
951
965
  );
952
966
  } catch (e) {
953
- this._log(`Routines: failed to list: ${e && e.message ? e.message : e}`);
967
+ this._log(`Routines: failed to list: ${e && e.message ? e.message : e}`, 'warn');
954
968
  try {
955
969
  await this.client.sendMessage(
956
970
  this.workspaceId, channel, this.token,
@@ -991,7 +1005,7 @@ class BaseAdapter {
991
1005
  sessionId: this._sessionId,
992
1006
  });
993
1007
  } catch (e) {
994
- this._log(`Routines: failed to post: ${e && e.message ? e.message : e}`);
1008
+ this._log(`Routines: failed to post: ${e && e.message ? e.message : e}`, 'warn');
995
1009
  }
996
1010
  }
997
1011
 
@@ -1049,11 +1063,26 @@ class BaseAdapter {
1049
1063
  rawCursor = result.cursor;
1050
1064
  composingActive = !!result.composing;
1051
1065
  this._pollAuthFailStreak = 0;
1066
+ const recovered = this._pollFailures.succeed();
1067
+ if (recovered) {
1068
+ this._log(`Poll recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
1069
+ }
1052
1070
  if (pollCount <= 3 || pollCount % 20 === 0) {
1053
1071
  this._log(`Poll #${pollCount}: ${messages.length} messages, cursor=${rawCursor || 'none'}${composingActive ? ' composing' : ''}`);
1054
1072
  }
1055
1073
  } catch (e) {
1056
- this._log(`Poll #${pollCount} failed: ${e.message} \nStack: ${e.stack}`);
1074
+ // A workspace that is down or slow fails every poll until it is back;
1075
+ // log the streak, not every attempt (see failure-streak.js).
1076
+ if (this._pollFailures.fail()) {
1077
+ const count = this._pollFailures.count;
1078
+ const streak = count > 1 ? ` (consecutive failures: ${count})` : '';
1079
+ if (isTransientNetworkError(e)) {
1080
+ this._log(`Poll #${pollCount} failed: ${e.message}${streak} — retrying every 5s`, 'warn');
1081
+ } else {
1082
+ const stack = count === 1 && e && e.stack ? `\nStack: ${e.stack}` : '';
1083
+ this._log(`Poll #${pollCount} failed: ${e && e.message}${streak}${stack}`, 'error');
1084
+ }
1085
+ }
1057
1086
  // A rejected credential never heals by retrying with the same token —
1058
1087
  // it means the device was re-paired/unpaired and this adapter is
1059
1088
  // running on a revoked one. After a few consecutive rejections (not
@@ -1249,7 +1278,7 @@ class BaseAdapter {
1249
1278
  await this._handleMessage(current);
1250
1279
  }
1251
1280
  } catch (e) {
1252
- this._log(`Error ${first ? 'in channel worker' : 'processing queued message'} for ${channel}: ${e.message}`);
1281
+ this._log(`Error ${first ? 'in channel worker' : 'processing queued message'} for ${channel}: ${e.message}`, 'error');
1253
1282
  try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
1254
1283
  }
1255
1284
  delete this._channelRunGeneration[channel];
@@ -1333,7 +1362,7 @@ class BaseAdapter {
1333
1362
  delete cache[channel];
1334
1363
  // Entry is gone — fall through to list+match below.
1335
1364
  } else {
1336
- this._log(`${label} read failed (${e.message}) — reusing last known state`);
1365
+ this._log(`${label} read failed (${e.message}) — reusing last known state`, 'warn');
1337
1366
  return { available: true, state: 'found', entryId: cachedId, title: titles[0], content: null, error: true };
1338
1367
  }
1339
1368
  }
@@ -1369,7 +1398,7 @@ class BaseAdapter {
1369
1398
  }
1370
1399
  return { available: true, state: 'found', entryId: entry.id, title: matchedTitle, content: (full && full.content) || '', error: false };
1371
1400
  } catch (e) {
1372
- this._log(`${label} fetch failed (${e.message}) — reusing last known state`);
1401
+ this._log(`${label} fetch failed (${e.message}) — reusing last known state`, 'warn');
1373
1402
  const knownId = cache[channel] || null;
1374
1403
  return { available: true, state: knownId ? 'found' : 'unknown', entryId: knownId, title: knownId ? titles[0] : null, content: null, error: true };
1375
1404
  }
@@ -1427,7 +1456,7 @@ class BaseAdapter {
1427
1456
  }
1428
1457
  this._pinnedContext[channel] = fresh;
1429
1458
  } catch (e) {
1430
- this._log(`Pinned-context fetch failed (${e && e.message ? e.message : e}) — reusing the last pinned context`);
1459
+ this._log(`Pinned-context fetch failed (${e && e.message ? e.message : e}) — reusing the last pinned context`, 'warn');
1431
1460
  }
1432
1461
  }
1433
1462
 
@@ -1475,7 +1504,7 @@ class BaseAdapter {
1475
1504
  this._log(`Auto-titled channel: ${title}`);
1476
1505
  }
1477
1506
  } catch (e) {
1478
- this._log(`Failed to auto-title channel: ${e.message}`);
1507
+ this._log(`Failed to auto-title channel: ${e.message}`, 'warn');
1479
1508
  }
1480
1509
  }
1481
1510
 
@@ -1625,7 +1654,7 @@ class BaseAdapter {
1625
1654
  }
1626
1655
 
1627
1656
  _onSessionRevoked() {
1628
- this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
1657
+ this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`, 'warn');
1629
1658
  this._running = false;
1630
1659
  }
1631
1660
 
@@ -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.
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Logging policy for background calls that fail repeatedly — the message poll
5
+ * and the workspace heartbeat.
6
+ *
7
+ * Both retry forever, so an unreachable or slow workspace used to produce one
8
+ * entry per attempt: the poll wrote a full stack every ~20s (15s deadline + 5s
9
+ * back-off) at INFO, and the Logs page — inferring severity from "failed" plus
10
+ * the stack — showed a wall of red ERRORs for what was a network outage.
11
+ *
12
+ * A streak logs its 1st, 3rd, 10th, 30th and then every 100th failure, and one
13
+ * line when it ends. Network-level failures are expected in the field and are
14
+ * WARN without a stack; anything else is an ERROR, with its stack once.
15
+ *
16
+ * Pure and dependency-light, so it is unit-testable without an adapter.
17
+ */
18
+
19
+ const { httpStatusOf } = require('./health-status');
20
+
21
+ const TRANSIENT_CODES = new Set([
22
+ 'ETIMEDOUT',
23
+ 'ECONNRESET',
24
+ 'ECONNREFUSED',
25
+ 'ECONNABORTED',
26
+ 'ENOTFOUND',
27
+ 'EAI_AGAIN',
28
+ 'ENETUNREACH',
29
+ 'ENETDOWN',
30
+ 'EHOSTUNREACH',
31
+ 'EPIPE',
32
+ 'UND_ERR_SOCKET',
33
+ 'UND_ERR_CONNECT_TIMEOUT',
34
+ ]);
35
+
36
+ /**
37
+ * True for failures of the network or of an overloaded server — the kind a
38
+ * retry is expected to fix, not a bug or a rejected credential.
39
+ */
40
+ function isTransientNetworkError(err) {
41
+ if (!err) return false;
42
+ if (err.code && TRANSIENT_CODES.has(err.code)) return true;
43
+ if (err.name === 'AbortError' || err.name === 'TimeoutError') return true;
44
+ const status = httpStatusOf(err);
45
+ if (typeof status === 'number') return status === 429 || status >= 500;
46
+ return /\b(timed out|socket hang up|network)\b/i.test(err.message || '');
47
+ }
48
+
49
+ /** Which failure counts in a streak get a log line. */
50
+ function isLogPoint(count) {
51
+ return count === 1 || count === 3 || count === 10 || count === 30 || count % 100 === 0;
52
+ }
53
+
54
+ class FailureStreak {
55
+ constructor() {
56
+ this.count = 0;
57
+ this.since = 0;
58
+ }
59
+
60
+ /** Record one failure. True when this one should be logged. */
61
+ fail(now = Date.now()) {
62
+ this.count++;
63
+ if (this.count === 1) this.since = now;
64
+ return isLogPoint(this.count);
65
+ }
66
+
67
+ /**
68
+ * Record a success. Returns the streak it ended — `{ count, ms }` — or null
69
+ * when there was none.
70
+ */
71
+ succeed(now = Date.now()) {
72
+ if (this.count === 0) return null;
73
+ const ended = { count: this.count, ms: now - this.since };
74
+ this.count = 0;
75
+ this.since = 0;
76
+ return ended;
77
+ }
78
+ }
79
+
80
+ /** "45s" / "12m" / "3h" — for the line that closes a streak. */
81
+ function formatDuration(ms) {
82
+ const s = Math.max(0, Math.round(ms / 1000));
83
+ if (s < 120) return `${s}s`;
84
+ const m = Math.round(s / 60);
85
+ if (m < 120) return `${m}m`;
86
+ return `${Math.round(m / 60)}h`;
87
+ }
88
+
89
+ module.exports = { FailureStreak, isTransientNetworkError, isLogPoint, formatDuration };
@@ -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
 
@@ -255,9 +269,18 @@ class Config {
255
269
  const { keptLines, removed } = filterLogsByTimeRange(allLines, start, end);
256
270
 
257
271
  const nextContent = keptLines.join('\n') + (hasTrailingNewline && keptLines.length > 0 ? '\n' : '');
258
- const tempFile = `${this.logFile}.tmp`;
259
- fs.writeFileSync(tempFile, nextContent, 'utf-8');
260
- fs.renameSync(tempFile, this.logFile);
272
+ // Rewrite in place, not write-temp + rename: the running daemon's stdout
273
+ // and stderr are append-mode handles to daemon.log. A rename leaves them
274
+ // writing into the replaced file (and fails outright on Windows while they
275
+ // are open); truncating in place keeps them appending to daemon.log.
276
+ const nextBytes = Buffer.from(nextContent, 'utf-8');
277
+ const fd = fs.openSync(this.logFile, 'r+');
278
+ try {
279
+ if (nextBytes.length > 0) fs.writeSync(fd, nextBytes, 0, nextBytes.length, 0);
280
+ fs.ftruncateSync(fd, nextBytes.length);
281
+ } finally {
282
+ fs.closeSync(fd);
283
+ }
261
284
 
262
285
  return { removed, remaining: keptLines.length };
263
286
  }
@@ -323,6 +346,62 @@ function parseYaml(text) {
323
346
  return result;
324
347
  }
325
348
 
349
+ /** Bytes read per step when walking back from the end of the log. */
350
+ const TAIL_CHUNK = 64 * 1024;
351
+ /** How far back a first read may walk looking for lines matching the agent filter. */
352
+ const MAX_SCAN_BYTES = 16 * 1024 * 1024;
353
+ /** The most an incremental read takes in one go; older backlog is skipped. */
354
+ const MAX_INCREMENT_BYTES = 4 * 1024 * 1024;
355
+
356
+ function readRange(fd, start, end) {
357
+ const buf = Buffer.alloc(end - start);
358
+ let read = 0;
359
+ while (read < buf.length) {
360
+ const n = fs.readSync(fd, buf, read, buf.length - read, start + read);
361
+ if (n === 0) break;
362
+ read += n;
363
+ }
364
+ return read === buf.length ? buf : buf.subarray(0, read);
365
+ }
366
+
367
+ function splitLines(buf, keep) {
368
+ return buf.toString('utf-8').split('\n').filter((l) => l && keep(l));
369
+ }
370
+
371
+ /**
372
+ * The last `count` matching lines, read backwards in chunks. Stops at the last
373
+ * newline: a line still being written is left for the next incremental read.
374
+ */
375
+ function readLastLines(fd, size, count, keep) {
376
+ const lastChunk = readRange(fd, Math.max(0, size - TAIL_CHUNK), size);
377
+ const lastNewline = lastChunk.lastIndexOf(0x0a);
378
+ const end = lastNewline >= 0 ? size - lastChunk.length + lastNewline + 1 : size;
379
+
380
+ let lines = [];
381
+ let pos = end;
382
+ let carry = Buffer.alloc(0);
383
+ while (pos > 0 && lines.length < count && end - pos < MAX_SCAN_BYTES) {
384
+ const start = Math.max(0, pos - TAIL_CHUNK);
385
+ const block = Buffer.concat([readRange(fd, start, pos), carry]);
386
+ pos = start;
387
+ let body = block;
388
+ if (pos > 0) {
389
+ // The block starts mid-line; that fragment joins the next block.
390
+ const firstNewline = block.indexOf(0x0a);
391
+ if (firstNewline < 0) {
392
+ carry = block;
393
+ continue;
394
+ }
395
+ carry = block.subarray(0, firstNewline);
396
+ body = block.subarray(firstNewline + 1);
397
+ } else {
398
+ carry = Buffer.alloc(0);
399
+ }
400
+ lines = [...splitLines(body, keep), ...lines];
401
+ }
402
+ return { lines: lines.slice(-count), end };
403
+ }
404
+
326
405
  function normalizeTimeValue(value) {
327
406
  if (value instanceof Date) {
328
407
  return Number.isNaN(value.getTime()) ? null : value;