@openagents-org/agent-launcher 0.2.134 → 0.2.135

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.
@@ -0,0 +1 @@
1
+ <svg fill="currentColor" viewBox="0 0 24 24" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><title>Hermes</title><circle cx="12" cy="3.5" r="1.75"/><path d="M11 6h2v16h-2z"/><path d="M13 7.5c2.5 0 4.5-1.2 5.8-2.8l1.4 1c-1.6 2-4.2 3.5-7.2 3.5V7.5zM11 7.5c-2.5 0-4.5-1.2-5.8-2.8l-1.4 1C5.4 7.7 8 9.2 11 9.2V7.5z"/><path d="M13 12.5c2.2 0 4-1 5.3-2.5l1.4 1c-1.6 1.8-3.8 3.2-6.7 3.2v-1.7zM11 12.5c-2.2 0-4-1-5.3-2.5l-1.4 1C5.9 13.3 8.1 14.7 11 14.7v-1.7z"/></svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.134",
3
+ "version": "0.2.135",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/registry.json CHANGED
@@ -537,23 +537,6 @@
537
537
  "not_ready_message": "Not configured \u2014 press e to configure"
538
538
  }
539
539
  },
540
- {
541
- "name": "swebench",
542
- "label": "SWE-bench Agent",
543
- "description": "Language model agent for software engineering tasks",
544
- "homepage": "https://swe-agent.com",
545
- "tags": [
546
- "coding",
547
- "benchmarks",
548
- "research"
549
- ],
550
- "install": {
551
- "binary": "sweagent",
552
- "macos": "pip install sweagent",
553
- "linux": "pip install sweagent",
554
- "windows": "pip install sweagent"
555
- }
556
- },
557
540
  {
558
541
  "name": "kimi",
559
542
  "label": "Kimi",
@@ -38,6 +38,8 @@ class ClaudeAdapter extends BaseAdapter {
38
38
  this._stoppingChannels = new Set();
39
39
  this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
40
40
  this._IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
41
+ this._WATCHDOG_INTERVAL_MS = 15_000; // 15s between checks
42
+ this._WATCHDOG_MAX_TIMEOUTS = 20; // 20 * 15s = 5 min of silence → kill
41
43
  this._sessionsFile = path.join(
42
44
  os.homedir(), '.openagents', 'sessions',
43
45
  `${this.workspaceId}_${this.agentName}.json`
@@ -579,6 +581,7 @@ class ClaudeAdapter extends BaseAdapter {
579
581
  const pp = this._persistentProcs[channel];
580
582
  if (!pp) return;
581
583
  if (pp.idleTimer) clearTimeout(pp.idleTimer);
584
+ this._stopWatchdog(pp);
582
585
  this._stopProcess(pp.proc).catch(() => {});
583
586
  delete this._persistentProcs[channel];
584
587
  }
@@ -597,6 +600,49 @@ class ClaudeAdapter extends BaseAdapter {
597
600
  }, this._IDLE_TIMEOUT_MS);
598
601
  }
599
602
 
603
+ /**
604
+ * Start a watchdog that kills the persistent process if stdout goes
605
+ * silent for too long while a message is in flight. Pauses during
606
+ * tool execution (awaitingToolResult) since long-running commands
607
+ * like builds or sleep produce no stdout legitimately.
608
+ */
609
+ _startWatchdog(pp) {
610
+ this._stopWatchdog(pp);
611
+ pp.lastStdoutTime = Date.now();
612
+ let consecutiveTimeouts = 0;
613
+
614
+ pp.watchdogTimer = setInterval(async () => {
615
+ if (!pp.messageResolve) { consecutiveTimeouts = 0; return; }
616
+ if (pp.awaitingToolResult) { consecutiveTimeouts = 0; return; }
617
+
618
+ const elapsed = Date.now() - pp.lastStdoutTime;
619
+ if (elapsed < this._WATCHDOG_INTERVAL_MS) { consecutiveTimeouts = 0; return; }
620
+
621
+ consecutiveTimeouts++;
622
+ pp.lastStdoutTime = Date.now();
623
+
624
+ if (consecutiveTimeouts === 2) {
625
+ try { await this.sendStatus(pp.msgChannel, 'Still processing...'); } catch {}
626
+ }
627
+
628
+ if (consecutiveTimeouts >= this._WATCHDOG_MAX_TIMEOUTS) {
629
+ this._log(`Watchdog: process unresponsive for ${consecutiveTimeouts * 15}s on ${pp.msgChannel} — killing`);
630
+ this._stopWatchdog(pp);
631
+ try { await this.sendError(pp.msgChannel, 'Agent process became unresponsive and was restarted.'); } catch {}
632
+ if (pp.messageResolve) {
633
+ const resolve = pp.messageResolve;
634
+ pp.messageResolve = null;
635
+ resolve({ exited: true, error: new Error('watchdog timeout') });
636
+ }
637
+ this._killPersistentProc(pp.msgChannel);
638
+ }
639
+ }, this._WATCHDOG_INTERVAL_MS);
640
+ }
641
+
642
+ _stopWatchdog(pp) {
643
+ if (pp.watchdogTimer) { clearInterval(pp.watchdogTimer); pp.watchdogTimer = null; }
644
+ }
645
+
600
646
  /**
601
647
  * Spawn a persistent Claude process for a channel that accepts messages
602
648
  * via stdin (--input-format stream-json). Returns the persistent proc entry.
@@ -651,6 +697,9 @@ class ClaudeAdapter extends BaseAdapter {
651
697
  everPostedAnything: false,
652
698
  stderrBuf: '',
653
699
  alive: true,
700
+ lastStdoutTime: Date.now(),
701
+ watchdogTimer: null,
702
+ awaitingToolResult: false,
654
703
  };
655
704
 
656
705
  if (proc.stderr) {
@@ -665,6 +714,7 @@ class ClaudeAdapter extends BaseAdapter {
665
714
  const eventType = event.type;
666
715
 
667
716
  if (eventType === 'assistant') {
717
+ pp.awaitingToolResult = false;
668
718
  const blocks = (event.message || {}).content || [];
669
719
  for (const block of blocks) {
670
720
  if (block.type === 'text' && block.text && block.text.trim()) {
@@ -680,6 +730,7 @@ class ClaudeAdapter extends BaseAdapter {
680
730
  pp.hasToolUseSinceLastText = true;
681
731
  pp.postedThinking = false;
682
732
  pp.lastResponseText.length = 0;
733
+ pp.awaitingToolResult = true;
683
734
  const toolName = block.name || '';
684
735
  if (toolName === 'TodoWrite' && block.input && block.input.todos) {
685
736
  try {
@@ -707,6 +758,7 @@ class ClaudeAdapter extends BaseAdapter {
707
758
  }
708
759
  }
709
760
  } else if (eventType === 'result') {
761
+ pp.awaitingToolResult = false;
710
762
  const sessionId = event.session_id;
711
763
  if (sessionId) {
712
764
  this._channelSessions[pp.msgChannel] = sessionId;
@@ -732,6 +784,7 @@ class ClaudeAdapter extends BaseAdapter {
732
784
 
733
785
  proc.stdout.on('data', (chunk) => {
734
786
  pp.lineBuffer += chunk.toString('utf-8');
787
+ pp.lastStdoutTime = Date.now();
735
788
  const lines = pp.lineBuffer.split('\n');
736
789
  pp.lineBuffer = lines.pop();
737
790
  for (const line of lines) {
@@ -743,6 +796,7 @@ class ClaudeAdapter extends BaseAdapter {
743
796
  this._log(`Persistent process exited: channel=${channel} code=${code}`);
744
797
  pp.alive = false;
745
798
  if (pp.idleTimer) clearTimeout(pp.idleTimer);
799
+ this._stopWatchdog(pp);
746
800
  if (pp.messageResolve) {
747
801
  pp.messageResolve({ exited: true, code });
748
802
  pp.messageResolve = null;
@@ -754,6 +808,7 @@ class ClaudeAdapter extends BaseAdapter {
754
808
  proc.on('error', (err) => {
755
809
  this._log(`Persistent process error: ${err.message}`);
756
810
  pp.alive = false;
811
+ this._stopWatchdog(pp);
757
812
  if (pp.messageResolve) {
758
813
  pp.messageResolve({ exited: true, error: err });
759
814
  pp.messageResolve = null;
@@ -777,6 +832,7 @@ class ClaudeAdapter extends BaseAdapter {
777
832
  pp.hasToolUseSinceLastText = false;
778
833
  pp.postedThinking = false;
779
834
  pp.everPostedAnything = false;
835
+ pp.awaitingToolResult = false;
780
836
 
781
837
  const stdinMsg = JSON.stringify({
782
838
  type: 'user',
@@ -787,11 +843,18 @@ class ClaudeAdapter extends BaseAdapter {
787
843
  }) + '\n';
788
844
 
789
845
  return new Promise((resolve) => {
790
- pp.messageResolve = resolve;
846
+ pp.messageResolve = (result) => {
847
+ this._stopWatchdog(pp);
848
+ resolve(result);
849
+ };
850
+ pp.lastStdoutTime = Date.now();
851
+ this._startWatchdog(pp);
791
852
  try {
792
853
  pp.proc.stdin.write(stdinMsg);
793
854
  } catch (e) {
794
855
  this._log(`stdin write failed: ${e.message}`);
856
+ this._stopWatchdog(pp);
857
+ pp.messageResolve = null;
795
858
  resolve({ exited: true, error: e });
796
859
  }
797
860
  });
@@ -354,22 +354,38 @@ class OpenClawAdapter extends BaseAdapter {
354
354
  } catch {}
355
355
  }, 500);
356
356
 
357
+ // 'error' and 'exit' can BOTH fire for a single failed spawn. Closing
358
+ // stderrFd twice throws EBADF from inside the event callback, which —
359
+ // with no daemon-level handler — used to crash the entire daemon. Guard
360
+ // so the fd is closed once and the promise settles once.
361
+ let settled = false;
362
+ const closeFd = () => { try { fs.closeSync(stderrFd); } catch {} };
363
+
357
364
  const killTimeout = setTimeout(() => {
358
- proc.kill();
365
+ if (settled) return;
366
+ settled = true;
367
+ clearInterval(pollInterval);
368
+ closeFd();
369
+ try { fs.unlinkSync(stderrFile); } catch {}
370
+ try { proc.kill(); } catch {}
359
371
  reject(new Error('CLI timed out after 600 seconds'));
360
372
  }, 600000);
361
373
 
362
374
  proc.on('error', (err) => {
375
+ if (settled) return;
376
+ settled = true;
363
377
  clearInterval(pollInterval);
364
378
  clearTimeout(killTimeout);
365
- fs.closeSync(stderrFd);
379
+ closeFd();
366
380
  try { fs.unlinkSync(stderrFile); } catch {}
367
381
  reject(err);
368
382
  });
369
383
  proc.on('exit', (code) => {
384
+ if (settled) return;
385
+ settled = true;
370
386
  clearInterval(pollInterval);
371
387
  clearTimeout(killTimeout);
372
- fs.closeSync(stderrFd);
388
+ closeFd();
373
389
  // Read full stderr content (contains JSON output + trace lines)
374
390
  let stderrContent = '';
375
391
  try {
@@ -123,6 +123,22 @@ class OpenCodeAdapter extends BaseAdapter {
123
123
  // ------------------------------------------------------------------
124
124
 
125
125
  _findOpencodeBinary() {
126
+ const home = os.homedir();
127
+ const ext = IS_WINDOWS ? '.cmd' : '';
128
+
129
+ // Tier 0: Isolated runtime prefix — where the launcher installs agents
130
+ // (~/.openagents/runtimes/opencode/node_modules/.bin). Every other adapter
131
+ // checks this first; opencode was the lone exception, so a launcher-managed
132
+ // install was invisible unless its .bin happened to be on PATH — which is
133
+ // exactly why the workspace failed with "opencode CLI not found" even though
134
+ // the marketplace showed it installed.
135
+ const runtimeBin = path.join(home, '.openagents', 'runtimes', 'opencode', 'node_modules', '.bin', `opencode${ext}`);
136
+ if (fs.existsSync(runtimeBin)) return runtimeBin;
137
+
138
+ // Tier 0b: Legacy shared portable prefix.
139
+ const legacyBin = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `opencode${ext}`);
140
+ if (fs.existsSync(legacyBin)) return legacyBin;
141
+
126
142
  // Tier 1: PATH
127
143
  try {
128
144
  if (IS_WINDOWS) {
@@ -136,12 +152,10 @@ class OpenCodeAdapter extends BaseAdapter {
136
152
  } catch {}
137
153
 
138
154
  // Tier 2: Next to Node.js
139
- const ext = IS_WINDOWS ? '.cmd' : '';
140
155
  const nearNode = path.join(path.dirname(process.execPath), `opencode${ext}`);
141
156
  if (fs.existsSync(nearNode)) return nearNode;
142
157
 
143
158
  // Tier 3: Common locations
144
- const home = os.homedir();
145
159
  const candidates = IS_WINDOWS ? [
146
160
  path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
147
161
  ] : [
package/src/cli.js CHANGED
@@ -64,9 +64,18 @@ function table(rows, headers) {
64
64
 
65
65
  async function cmdUp(connector, flags) {
66
66
  if (flags.foreground) {
67
- // Run in foreground (used by daemonize child) — skip PID check
68
- // because the parent already wrote our PID to the file.
67
+ // Run in foreground. daemonize() already guards its own child, but the
68
+ // launcher spawns `up --foreground` DIRECTLY — bypassing that guard — so a
69
+ // stale/empty pid file used to let it pile up dozens of daemons that each
70
+ // join the workspace as the same agents (session wars, duplicate replies).
71
+ // Enforce the singleton here using process-liveness from the pid OR status
72
+ // file, so a clobbered pid file can't defeat it.
69
73
  const daemon = connector.createDaemon();
74
+ const other = Daemon.runningDaemonPid(daemon.config.configDir);
75
+ if (other) {
76
+ print(`Daemon already running (PID ${other}); not starting another.`);
77
+ return;
78
+ }
70
79
  await daemon.start();
71
80
  } else {
72
81
  const pid = connector.getDaemonPid();
@@ -172,7 +181,15 @@ async function cmdRemove(connector, _flags, positional) {
172
181
  async function cmdStart(connector, _flags, positional) {
173
182
  const name = positional[0];
174
183
  if (!name) { print('Usage: agn start <name>'); return; }
175
- connector.sendDaemonCommand(`restart:${name}`);
184
+ // `start` must be idempotent — it ensures the agent is running, it does NOT
185
+ // forcibly restart one that already is. Send `start:` (which the daemon
186
+ // guards: relaunch only when not already running) rather than `restart:`.
187
+ // A blind `restart:` tears down the workspace session the daemon already
188
+ // joined on launch and re-joins as the same agent; the server revokes the
189
+ // first session, so the agent stops the moment it next touches the workspace
190
+ // (first user message → "thinking..." then silence). The launcher already
191
+ // sends `start:`; this aligns the CLI with it.
192
+ connector.sendDaemonCommand(`start:${name}`);
176
193
  print(`Sent start command for '${name}'`);
177
194
  }
178
195
 
package/src/daemon.js CHANGED
@@ -55,6 +55,25 @@ class Daemon {
55
55
  try { process.on('SIGHUP', () => this._reload()); } catch {}
56
56
  }
57
57
 
58
+ // Crash guards. A bug in one adapter — a rejected fire-and-forget promise,
59
+ // an EBADF from a double fs.closeSync in a child-process callback, a throw
60
+ // inside a stream/'exit' handler — must NEVER take down the whole daemon
61
+ // and every other agent with it. Node ≥15 terminates the process on an
62
+ // unhandled rejection by default, so without these the daemon silently
63
+ // dies and the launcher shows "Daemon stopped". Log loudly, keep
64
+ // supervising; per-agent failures are already isolated in _adapterLoop.
65
+ if (!this._crashGuardsInstalled) {
66
+ this._crashGuardsInstalled = true;
67
+ process.on('unhandledRejection', (reason) => {
68
+ const msg = reason && reason.stack ? reason.stack : String(reason);
69
+ this._log(`UNHANDLED REJECTION (daemon kept alive): ${msg}`);
70
+ });
71
+ process.on('uncaughtException', (err) => {
72
+ const msg = err && err.stack ? err.stack : String(err);
73
+ this._log(`UNCAUGHT EXCEPTION (daemon kept alive): ${msg}`);
74
+ });
75
+ }
76
+
58
77
  // Write PID file
59
78
  this._writePid();
60
79
 
@@ -444,7 +463,12 @@ class Daemon {
444
463
  let adapter;
445
464
  try {
446
465
  adapter = createAdapter(agentType, {
447
- workspaceId: network.id,
466
+ // Networks created via the launcher can be persisted with id: null
467
+ // (the workspace service returns only a slug). The server identifies
468
+ // a workspace by its slug — the same value the web UI uses in its URL —
469
+ // so fall back to it. Joining with a null id makes every poll/heartbeat
470
+ // fail "Network not found", which spins the adapter in an error loop.
471
+ workspaceId: network.id || network.slug,
448
472
  channelName: 'general',
449
473
  token: network.token,
450
474
  agentName: name,
@@ -657,8 +681,24 @@ class Daemon {
657
681
  this.stopAgent(agentName);
658
682
  } else if (cmd.startsWith('start:')) {
659
683
  const agentName = cmd.slice(6).trim();
660
- this._log(`Command: start ${agentName}`);
661
- this.restartAgent(agentName);
684
+ // 'start' must be idempotent. The launcher sends start:<name> right
685
+ // after (re)spawning the daemon, but the daemon's own start() already
686
+ // launched every configured agent. A blind restart here tears down
687
+ // the just-joined workspace session and re-joins as the same agent;
688
+ // the server revokes the first session and the agent then stops the
689
+ // moment it next touches the workspace (e.g. the first user message →
690
+ // "thinking..." status). Only (re)launch when it isn't running.
691
+ const running =
692
+ (this._adapters && this._adapters[agentName]) ||
693
+ ['running', 'starting'].includes(
694
+ this._processes[agentName] && this._processes[agentName].state
695
+ );
696
+ if (running) {
697
+ this._log(`Command: start ${agentName} — already running, skipping`);
698
+ } else {
699
+ this._log(`Command: start ${agentName}`);
700
+ this.restartAgent(agentName);
701
+ }
662
702
  } else if (cmd.startsWith('restart:')) {
663
703
  const agentName = cmd.slice(8).trim();
664
704
  this._log(`Command: restart ${agentName}`);
@@ -831,6 +871,38 @@ class Daemon {
831
871
  return false;
832
872
  }
833
873
  }
874
+
875
+ /**
876
+ * Return the PID of another live daemon for this configDir, or null.
877
+ *
878
+ * Used to enforce the singleton even on the `up --foreground` path, which the
879
+ * launcher spawns directly (bypassing daemonize's guard). Trusts BOTH the pid
880
+ * file and the status file (a live daemon rewrites the latter every 5s with
881
+ * its own pid), because the pid file gets emptied/clobbered under races. Uses
882
+ * real process-liveness — not just file age — so a daemon that was just
883
+ * stopped for a legitimate restart doesn't block the replacement.
884
+ */
885
+ static runningDaemonPid(configDir) {
886
+ const self = process.pid;
887
+ const isOtherAlive = (pid) =>
888
+ pid && pid !== self && Daemon._isAlive(pid);
889
+
890
+ const pidFromFile = Daemon._readPid(path.join(configDir, 'daemon.pid'));
891
+ if (isOtherAlive(pidFromFile)) return pidFromFile;
892
+
893
+ try {
894
+ const statusFile = path.join(configDir, 'daemon.status.json');
895
+ const age = Date.now() - fs.statSync(statusFile).mtimeMs;
896
+ // Bound the age so a long-dead daemon whose pid got reused by an
897
+ // unrelated process can't masquerade as a live daemon.
898
+ if (age < 30000) {
899
+ const raw = JSON.parse(fs.readFileSync(statusFile, 'utf-8'));
900
+ const pid = raw && raw.pid;
901
+ if (isOtherAlive(pid)) return pid;
902
+ }
903
+ } catch {}
904
+ return null;
905
+ }
834
906
  }
835
907
 
836
908
  module.exports = { Daemon };
package/src/installer.js CHANGED
@@ -941,7 +941,7 @@ class Installer {
941
941
  const { spawn: spawnProc } = require('child_process');
942
942
  const https = require('https');
943
943
  const os = require('os');
944
- const nodeVersion = 'v22.16.0';
944
+ const nodeVersion = 'v22.22.3';
945
945
  const plat = Installer.platform();
946
946
 
947
947
  if (onData) onData(`Node.js not found. Installing Node.js ${nodeVersion}...\n\n`);
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 24 24" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="11" fill="#6366f1" /><text x="12" y="17" text-anchor="middle" font-family="system-ui" font-weight="700" font-size="14" fill="#fff">S</text></svg>