@openagents-org/agent-launcher 0.2.133 → 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.133",
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
  });
@@ -205,7 +205,13 @@ class CursorAdapter extends BaseAdapter {
205
205
  }
206
206
 
207
207
  // Tier 3: Common install locations
208
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
208
209
  const candidates = IS_WINDOWS ? [
210
+ // Windows native installer (install?win32=true) drops the CLI here.
211
+ path.join(localAppData, 'cursor-agent', 'cursor-agent.cmd'),
212
+ path.join(localAppData, 'cursor-agent', 'cursor-agent.exe'),
213
+ path.join(localAppData, 'cursor-agent', 'agent.cmd'),
214
+ path.join(localAppData, 'cursor-agent', 'agent.exe'),
209
215
  path.join(home, '.cursor', 'bin', 'cursor-agent.cmd'),
210
216
  path.join(home, '.cursor', 'bin', 'cursor-agent.exe'),
211
217
  path.join(home, '.cursor', 'bin', 'cursor-agent'),
@@ -195,6 +195,12 @@ class GeminiAdapter extends BaseAdapter {
195
195
 
196
196
  const cmd = [geminiBin, '-p', fullPrompt, '-y', '-o', 'stream-json'];
197
197
 
198
+ // Honor a user-configured model (e.g. when pointing at a relay/proxy whose
199
+ // channels don't match the CLI default). Set via GEMINI_MODEL in env_config.
200
+ const env = this.agentEnv || process.env;
201
+ const model = (env.GEMINI_MODEL || env.GOOGLE_GEMINI_MODEL || '').trim();
202
+ if (model) cmd.push('-m', model);
203
+
198
204
  const sessionId = this._channelSessions[channelName];
199
205
  if (sessionId && !skipResume) {
200
206
  cmd.push('-r', sessionId);
@@ -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
  ] : [
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Pure data helpers for the agent list, shared by the TUI (tui.js) and tests.
3
+ *
4
+ * Kept free of any UI/blessed dependency so the list-rendering logic — in
5
+ * particular the "(local)" labelling and Connect gating — can be unit-tested.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ /**
11
+ * Build one display row per configured agent from a connector.
12
+ *
13
+ * `workspace` holds the resolved workspace URL (or '' when the agent is not
14
+ * connected). `workspaceLabel` is what the WORKSPACE column shows: the URL when
15
+ * connected, otherwise "(local)" — matching `agn list` so local-only agents are
16
+ * always visible rather than rendered as a blank cell.
17
+ */
18
+ function loadAgentRows(connector) {
19
+ const config = connector.config.load();
20
+ const agents = config.agents || [];
21
+ const agentStatuses = connector.getDaemonStatus() || {};
22
+ const pid = connector.getDaemonPid();
23
+ const networks = config.networks || [];
24
+ return agents.map(agent => {
25
+ const info = agentStatuses[agent.name] || {};
26
+ const state = pid ? (info.state || 'stopped') : 'stopped';
27
+ let workspace = '';
28
+ if (agent.network) {
29
+ const net = networks.find(n => n.slug === agent.network || n.id === agent.network);
30
+ if (net) {
31
+ const slug = net.slug || net.id;
32
+ const isLocal = (net.endpoint || '').includes('localhost') || (net.endpoint || '').includes('127.0.0.1');
33
+ if (isLocal) workspace = `${net.endpoint}/${slug}`;
34
+ else workspace = `workspace.openagents.org/${slug}`;
35
+ } else {
36
+ workspace = agent.network;
37
+ }
38
+ }
39
+ let notReadyMsg = '';
40
+ let health = null;
41
+ try {
42
+ health = connector.healthCheck(agent.type || 'openclaw');
43
+ if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
44
+ } catch {}
45
+
46
+ return {
47
+ name: agent.name,
48
+ type: agent.type || 'openclaw',
49
+ state,
50
+ workspace,
51
+ // "(local)" when there's no workspace connection; URL otherwise. `workspace`
52
+ // stays empty for local-only agents so Connect gating still treats them as
53
+ // connectable (see connectAvailable).
54
+ workspaceLabel: workspace || '(local)',
55
+ path: agent.path || '',
56
+ network: agent.network || '',
57
+ lastError: info.last_error || '',
58
+ notReadyMsg,
59
+ health,
60
+ configured: true,
61
+ };
62
+ });
63
+ }
64
+
65
+ /**
66
+ * An agent can be connected to a workspace when it's configured and not already
67
+ * bound to one. Mirrors the footer/menu gating so it can be unit-tested.
68
+ */
69
+ function connectAvailable(row) {
70
+ return !!(row && row.configured && !row.workspace);
71
+ }
72
+
73
+ module.exports = { loadAgentRows, connectAvailable };
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();
@@ -123,7 +132,6 @@ async function cmdCreate(connector, flags, positional) {
123
132
 
124
133
  try {
125
134
  connector.addAgent({ name, type, role, path: flags.path || process.cwd() });
126
- print(`Agent '${name}' created (type: ${type})`);
127
135
 
128
136
  // Signal daemon to pick up the new agent
129
137
  try { connector.sendDaemonCommand('reload'); } catch {}
@@ -132,10 +140,14 @@ async function cmdCreate(connector, flags, positional) {
132
140
  // Without a workspace connection they will not appear in the Workspace Dashboard.
133
141
  const created = connector.config.getAgent(name);
134
142
  if (created && !created.network) {
143
+ print(`Created local agent: ${name} (type: ${type})`);
135
144
  print('');
136
- print(`Note: '${name}' is local-only and will not appear in the Workspace Dashboard.`);
137
- print(`To make it visible, connect it to a workspace:`);
145
+ print('This agent is local-only and will not appear in Workspace Dashboard yet.');
146
+ print('');
147
+ print('To connect it to a Workspace, run:');
138
148
  print(` agn connect ${name} <workspace-token>`);
149
+ } else {
150
+ print(`Agent '${name}' created (type: ${type})`);
139
151
  }
140
152
 
141
153
  if (!connector.isInstalled(type)) {
@@ -169,7 +181,15 @@ async function cmdRemove(connector, _flags, positional) {
169
181
  async function cmdStart(connector, _flags, positional) {
170
182
  const name = positional[0];
171
183
  if (!name) { print('Usage: agn start <name>'); return; }
172
- 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}`);
173
193
  print(`Sent start command for '${name}'`);
174
194
  }
175
195
 
@@ -306,11 +326,10 @@ async function cmdConnect(connector, flags, positional) {
306
326
  // No token supplied and none in the environment. Never prompt — keep
307
327
  // CI / non-interactive environments from hanging. Print a helpful error
308
328
  // explaining why the agent stays invisible and how to fix it.
309
- print(`Error: no workspace token provided for '${name}'.`);
310
- print(`Without a token, '${name}' stays local-only and will not appear in the Workspace Dashboard.`);
311
- print('Provide a token in one of these ways:');
329
+ print('Workspace token is required.');
330
+ print('Local-only agents do not appear in Workspace Dashboard until connected.');
331
+ print('Run:');
312
332
  print(` agn connect ${name} <workspace-token>`);
313
- print(` OPENAGENTS_WORKSPACE_TOKEN=<workspace-token> agn connect ${name}`);
314
333
  process.exitCode = 1;
315
334
  return;
316
335
  }
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
@@ -414,31 +414,72 @@ class Installer {
414
414
  await this.installNodejs(onData);
415
415
  }
416
416
 
417
- // Resolve npm command
418
- let cmd = rawCmd;
417
+ const env = this._buildShellEnv();
418
+ const isWin = process.platform === 'win32';
419
+
420
+ // Build the spawn invocation. Three shapes:
421
+ // 1. npm install via bundled `node npm-cli.js` — argv array, no shell.
422
+ // Path-safe under non-ASCII home dirs on Windows (preferred).
423
+ // 2. npm install via legacy shell string (npm.cmd / npm) — fallback only
424
+ // when no node + npm-cli.js layout is found; OEM-codepage corruption
425
+ // means this breaks on non-ASCII paths, but still works for ASCII.
426
+ // 3. pip / curl / powershell — keep the legacy shell-string behaviour.
427
+ let spawnFile;
428
+ let spawnArgs = [];
429
+ let useShell;
430
+ let installCwd;
431
+ let displayCmd;
432
+
419
433
  if (rawCmd.startsWith('npm install')) {
420
434
  const prefixDir = getRuntimePrefix(agentType);
421
435
  fs.mkdirSync(prefixDir, { recursive: true });
422
- // Use --save so npm tracks the package in package.json (prevents pruning on next install)
423
- const args = rawCmd.replace('npm install', 'install --loglevel=verbose --save').replace(' -g ', ` --prefix "${prefixDir}" `);
424
- cmd = this._resolveNpmCommand(args);
425
- } else if (rawCmd.startsWith('pip install') || rawCmd.startsWith('pipx install')) {
426
- cmd = rawCmd; // pip commands stay as-is
436
+ installCwd = prefixDir;
437
+
438
+ // `npm install [-g] <pkg…>` → install --save --prefix <dir> <pkg…>
439
+ const pkgArgs = rawCmd
440
+ .replace(/^npm install\s*/, '')
441
+ .replace(/(^|\s)-g(\s|$)/, ' ')
442
+ .trim()
443
+ .split(/\s+/)
444
+ .filter(Boolean);
445
+ // Use --save so npm tracks the package in package.json (prevents pruning
446
+ // on next install).
447
+ const npmArgs = ['install', '--loglevel=verbose', '--save', '--prefix', prefixDir, ...pkgArgs];
448
+ displayCmd = `npm ${npmArgs.join(' ')}`;
449
+
450
+ const direct = this._resolveNodeNpmCli();
451
+ if (direct) {
452
+ spawnFile = direct.node;
453
+ spawnArgs = [direct.npmCli, ...npmArgs];
454
+ useShell = false;
455
+ // Ensure npm's own `node` child lookups (postinstall scripts) resolve
456
+ // to this same runtime by putting its dir first on PATH.
457
+ const pathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || 'PATH';
458
+ const nodeDir = path.dirname(direct.node);
459
+ if (!(env[pathKey] || '').includes(nodeDir)) {
460
+ env[pathKey] = nodeDir + (isWin ? ';' : ':') + (env[pathKey] || '');
461
+ }
462
+ } else {
463
+ // Legacy fallback: quoted prefix inside a shell string.
464
+ const args = `install --loglevel=verbose --save --prefix "${prefixDir}" ${pkgArgs.join(' ')}`;
465
+ spawnFile = this._wrapForWindowsShell(this._resolveNpmCommand(args));
466
+ useShell = isWin ? (env.ComSpec || 'C:\\Windows\\System32\\cmd.exe') : true;
467
+ displayCmd = spawnFile;
468
+ }
469
+ } else {
470
+ // pip / curl / powershell — unchanged shell-string behaviour.
471
+ spawnFile = this._wrapForWindowsShell(rawCmd);
472
+ useShell = isWin ? (env.ComSpec || 'C:\\Windows\\System32\\cmd.exe') : true;
473
+ // Set cwd outside System32 on Windows.
474
+ installCwd = os.homedir();
475
+ displayCmd = spawnFile;
427
476
  }
428
477
 
429
- cmd = this._wrapForWindowsShell(cmd);
430
-
431
- if (onData) onData(`$ ${cmd}\n\n`);
432
-
433
- const env = this._buildShellEnv();
434
- const shell = process.platform === 'win32'
435
- ? (process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe')
436
- : true;
437
- // Set cwd to runtime prefix dir (avoids running from System32 on Windows)
438
- const installCwd = rawCmd.startsWith('npm install') ? getRuntimePrefix(agentType) : os.homedir();
478
+ if (onData) onData(`$ ${displayCmd}\n\n`);
439
479
 
440
480
  return new Promise((resolve, reject) => {
441
- const proc = spawn(cmd, [], { shell, env, cwd: installCwd, stdio: ['ignore', 'pipe', 'pipe'] });
481
+ // windowsHide stops a console window from flashing up on every install.
482
+ const proc = spawn(spawnFile, spawnArgs, { shell: useShell, env, cwd: installCwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
442
483
 
443
484
  if (proc.stdout) proc.stdout.setEncoding('utf-8');
444
485
  if (proc.stderr) proc.stderr.setEncoding('utf-8');
@@ -458,7 +499,7 @@ class Installer {
458
499
  if (code === 0) {
459
500
  this._markInstalled(agentType);
460
501
  if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
461
- resolve({ success: true, command: cmd });
502
+ resolve({ success: true, command: displayCmd });
462
503
  } else {
463
504
  // A partial install can leave a placeholder stub at
464
505
  // bin/<binary>.exe and npm-generated cmd-shims under
@@ -473,8 +514,8 @@ class Installer {
473
514
  } catch {}
474
515
  const tail = outputTail.trim();
475
516
  const msg = tail
476
- ? `Install failed with exit code ${code}\nCommand: ${cmd}\n\n${tail}`
477
- : `Install failed with exit code ${code}\nCommand: ${cmd}`;
517
+ ? `Install failed with exit code ${code}\nCommand: ${displayCmd}\n\n${tail}`
518
+ : `Install failed with exit code ${code}\nCommand: ${displayCmd}`;
478
519
  if (onData) onData(`\n${msg}\n`);
479
520
  reject(new Error(msg));
480
521
  }
@@ -599,7 +640,7 @@ class Installer {
599
640
  : true;
600
641
 
601
642
  return new Promise((resolve, reject) => {
602
- const proc = spawn(cmd, [], { shell, env, stdio: ['ignore', 'pipe', 'pipe'] });
643
+ const proc = spawn(cmd, [], { shell, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
603
644
 
604
645
  if (proc.stdout) proc.stdout.setEncoding('utf-8');
605
646
  if (proc.stderr) proc.stderr.setEncoding('utf-8');
@@ -811,9 +852,14 @@ class Installer {
811
852
  } catch {}
812
853
  }
813
854
  }
855
+ // On Windows the spread above yields a "Path" key, not "PATH". Writing
856
+ // `env.PATH` would create a duplicate key holding only extraDirs and drop
857
+ // everything else; libuv then resolves spawned binaries against the wrong,
858
+ // truncated value. Update the existing case-insensitive key in place.
859
+ const pathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || 'PATH';
814
860
  for (const d of extraDirs) {
815
- if (d && !(env.PATH || '').includes(d)) {
816
- env.PATH = d + sep + (env.PATH || '');
861
+ if (d && !(env[pathKey] || '').includes(d)) {
862
+ env[pathKey] = d + sep + (env[pathKey] || '');
817
863
  }
818
864
  }
819
865
  return env;
@@ -895,7 +941,7 @@ class Installer {
895
941
  const { spawn: spawnProc } = require('child_process');
896
942
  const https = require('https');
897
943
  const os = require('os');
898
- const nodeVersion = 'v22.16.0';
944
+ const nodeVersion = 'v22.22.3';
899
945
  const plat = Installer.platform();
900
946
 
901
947
  if (onData) onData(`Node.js not found. Installing Node.js ${nodeVersion}...\n\n`);
@@ -918,7 +964,7 @@ class Installer {
918
964
  const proc = spawnProc('powershell', [
919
965
  '-NoProfile', '-Command',
920
966
  `Expand-Archive -Path '${zipPath}' -DestinationPath '${nodejsDir}' -Force`
921
- ], { stdio: ['ignore', 'pipe', 'pipe'] });
967
+ ], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
922
968
  if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d.toString()); });
923
969
  if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d.toString()); });
924
970
  proc.on('error', reject);
@@ -1039,6 +1085,54 @@ class Installer {
1039
1085
  });
1040
1086
  }
1041
1087
 
1088
+ /**
1089
+ * Locate a `node` binary plus its `npm-cli.js` so npm can be invoked as
1090
+ * `node npm-cli.js …` with argv passed array-style and NO shell. On Windows
1091
+ * that goes through CreateProcessW (UTF-16), so an install prefix under a
1092
+ * non-ASCII home dir (e.g. `C:\Users\用户名\.openagents\runtimes\…`) is
1093
+ * preserved exactly. The legacy `npm.cmd` shell shim instead has cmd.exe
1094
+ * decode the path bytes with the OEM code page (936/GBK on zh-CN), corrupting
1095
+ * it and silently breaking every install. Returns { node, npmCli } or null
1096
+ * when no usable layout is found (caller falls back to the legacy shell
1097
+ * command, which still works for ASCII paths).
1098
+ */
1099
+ _resolveNodeNpmCli() {
1100
+ const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
1101
+ const isWin = process.platform === 'win32';
1102
+ const nodeName = isWin ? 'node.exe' : 'node';
1103
+
1104
+ const nodeCandidates = [];
1105
+ // Bundled portable node (~/.openagents/nodejs, plus nested node-* layouts)
1106
+ const bundledDir = path.join(this.configDir, 'nodejs');
1107
+ nodeCandidates.push(path.join(bundledDir, nodeName));
1108
+ nodeCandidates.push(path.join(bundledDir, 'bin', 'node'));
1109
+ try {
1110
+ for (const e of fs.readdirSync(bundledDir)) {
1111
+ if (e.startsWith('node-')) {
1112
+ nodeCandidates.push(path.join(bundledDir, e, nodeName));
1113
+ nodeCandidates.push(path.join(bundledDir, e, 'bin', 'node'));
1114
+ }
1115
+ }
1116
+ } catch {}
1117
+ // System node — its npm-cli.js lives next to (or one level above) it.
1118
+ try {
1119
+ const sys = whichBinary('node');
1120
+ if (sys) nodeCandidates.push(sys);
1121
+ } catch {}
1122
+
1123
+ for (const node of nodeCandidates) {
1124
+ if (!node || !exists(node)) continue;
1125
+ const dir = path.dirname(node);
1126
+ const npmCli = [
1127
+ path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
1128
+ path.join(dir, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
1129
+ path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
1130
+ ].find(exists);
1131
+ if (npmCli && this._canExecute(node)) return { node, npmCli };
1132
+ }
1133
+ return null;
1134
+ }
1135
+
1042
1136
  /**
1043
1137
  * Resolve the npm CLI command. Uses system npm if available.
1044
1138
  */
package/src/paths.js CHANGED
@@ -107,7 +107,13 @@ function getEnhancedEnv(baseEnv) {
107
107
  const env = { ...(baseEnv || process.env) };
108
108
  const extra = getExtraBinDirs();
109
109
  if (extra.length > 0) {
110
- env.PATH = extra.join(SEP) + SEP + (env.PATH || '');
110
+ // Spreading process.env on Windows yields a "Path" key (not "PATH"), so a
111
+ // bare `env.PATH = …` would create a SECOND key holding only the extra dirs
112
+ // — no System32 — and libuv picks that truncated one when resolving spawned
113
+ // executables (cmd.exe / where.exe become unfindable). Update the existing
114
+ // case-insensitive path key in place instead.
115
+ const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
116
+ env[pathKey] = extra.join(SEP) + SEP + (env[pathKey] || '');
111
117
  }
112
118
  if (IS_WINDOWS) {
113
119
  // Force UTF-8 output from child processes on non-English Windows locales
@@ -179,8 +185,16 @@ function _addWindowsPaths(dirs) {
179
185
  // Portable Node.js installed by OpenAgents Launcher
180
186
  _push(dirs, path.join(HOME, '.openagents', 'nodejs'));
181
187
 
182
- // Cursor CLI native installer
188
+ // Cursor CLI native installer.
189
+ // - ~/.cursor/bin : the curl|bash layout (also used by some setups)
190
+ // - %LOCALAPPDATA%\cursor-agent : where the Windows installer
191
+ // (irm 'https://cursor.com/install?win32=true' | iex) actually drops
192
+ // cursor-agent.cmd / agent.cmd. The installer edits the *registry* PATH,
193
+ // so an already-running launcher/daemon process never sees it via `where`
194
+ // unless we add the dir here explicitly.
183
195
  _push(dirs, path.join(HOME, '.cursor', 'bin'));
196
+ if (localAppData) _push(dirs, path.join(localAppData, 'cursor-agent'));
197
+ _push(dirs, path.join(HOME, '.local', 'bin'));
184
198
 
185
199
  // Node.js install
186
200
  _push(dirs, path.join(programFiles, 'nodejs'));
package/src/tui.js CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  const blessed = require('blessed');
10
10
  const { AgentConnector } = require('./index');
11
+ const { loadAgentRows, connectAvailable } = require('./agent-rows');
11
12
  const path = require('path');
12
13
  const fs = require('fs');
13
14
  const { spawn } = require('child_process');
@@ -64,49 +65,6 @@ function getConnector() {
64
65
  return new AgentConnector({ configDir });
65
66
  }
66
67
 
67
- function loadAgentRows(connector) {
68
- const config = connector.config.load();
69
- const agents = config.agents || [];
70
- const agentStatuses = connector.getDaemonStatus() || {};
71
- const pid = connector.getDaemonPid();
72
- const networks = config.networks || [];
73
- return agents.map(agent => {
74
- const info = agentStatuses[agent.name] || {};
75
- const state = pid ? (info.state || 'stopped') : 'stopped';
76
- let workspace = '';
77
- if (agent.network) {
78
- const net = networks.find(n => n.slug === agent.network || n.id === agent.network);
79
- if (net) {
80
- const slug = net.slug || net.id;
81
- const isLocal = (net.endpoint || '').includes('localhost') || (net.endpoint || '').includes('127.0.0.1');
82
- if (isLocal) workspace = `${net.endpoint}/${slug}`;
83
- else workspace = `workspace.openagents.org/${slug}`;
84
- } else {
85
- workspace = agent.network;
86
- }
87
- }
88
- let notReadyMsg = '';
89
- let health = null;
90
- try {
91
- health = connector.healthCheck(agent.type || 'openclaw');
92
- if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
93
- } catch {}
94
-
95
- return {
96
- name: agent.name,
97
- type: agent.type || 'openclaw',
98
- state,
99
- workspace,
100
- path: agent.path || '',
101
- network: agent.network || '',
102
- lastError: info.last_error || '',
103
- notReadyMsg,
104
- health,
105
- configured: true,
106
- };
107
- });
108
- }
109
-
110
68
  function describeHealth(health) {
111
69
  if (!health) return '';
112
70
  if (!health.ready) return health.message || 'Not configured';
@@ -271,7 +229,7 @@ function createTUI() {
271
229
  const envFields = connector.registry.getEnvFields(agent.type);
272
230
  if (envFields && envFields.length > 0) items.push({ key: 'e', label: 'Configure' });
273
231
 
274
- if (!agent.workspace) items.push({ key: 'c', label: 'Connect' });
232
+ if (connectAvailable(agent)) items.push({ key: 'c', label: 'Connect' });
275
233
  if (agent.workspace) items.push({ key: 'd', label: 'Disconnect' });
276
234
  if (agent.workspace) items.push({ key: 'w', label: 'Workspace' });
277
235
 
@@ -322,7 +280,8 @@ function createTUI() {
322
280
  const items = [];
323
281
  for (const r of agentRows) {
324
282
  const state = stateMarkup(r.state, !!r.workspace);
325
- const ws = r.workspace || '';
283
+ // Local-only agents (no workspace) show a dimmed "(local)" marker.
284
+ const ws = r.workspace || `{gray-fg}${r.workspaceLabel}{/gray-fg}`;
326
285
  items.push(` ${r.name.padEnd(22)} ${r.type.padEnd(14)} ${state.padEnd(30)} ${ws}`);
327
286
  // Detail row: working dir + config warning
328
287
  const details = [];
@@ -418,7 +377,7 @@ function createTUI() {
418
377
  if (isStopped) actions.push({ label: 'Start', key: 'start' });
419
378
  if (isRunning) actions.push({ label: 'Stop', key: 'stop' });
420
379
  if (agent.workspace) actions.push({ label: 'Open Workspace', key: 'open_workspace' });
421
- if (!agent.workspace) actions.push({ label: 'Connect to Workspace', key: 'connect' });
380
+ if (connectAvailable(agent)) actions.push({ label: 'Connect to Workspace', key: 'connect' });
422
381
  if (agent.workspace) actions.push({ label: 'Disconnect from Workspace', key: 'disconnect' });
423
382
  actions.push({ label: 'Remove', key: 'remove' });
424
383
 
@@ -1403,7 +1362,7 @@ function createTUI() {
1403
1362
  Connect() {
1404
1363
  if (currentView !== 'main' || !selectedAgent()) return;
1405
1364
  const a = selectedAgent();
1406
- if (a.configured && !a.workspace) showConnectWorkspaceScreen(a.name);
1365
+ if (connectAvailable(a)) showConnectWorkspaceScreen(a.name);
1407
1366
  },
1408
1367
  Disconnect() {
1409
1368
  if (currentView !== 'main' || !selectedAgent()) return;
@@ -1492,4 +1451,4 @@ function createTUI() {
1492
1451
  }
1493
1452
 
1494
1453
  function run() { createTUI(); }
1495
- module.exports = { run };
1454
+ module.exports = { run, loadAgentRows, connectAvailable };
@@ -104,32 +104,75 @@ function detectInstallPrefix() {
104
104
  return null;
105
105
  }
106
106
 
107
- function findNpmBin() {
108
- const nodeDir = path.dirname(process.execPath);
109
- for (const name of ['npm', 'npm.cmd']) {
107
+ /**
108
+ * Locate the npm executable next to the running node binary, falling back to
109
+ * a PATH lookup. On Windows the runnable file is `npm.cmd`; the extensionless
110
+ * `npm` shipped alongside it is a Unix shell script that cmd.exe / PowerShell
111
+ * cannot execute, so it must be checked AFTER `npm.cmd`. The opts are injectable
112
+ * to keep this testable across platforms.
113
+ */
114
+ function findNpmBin(opts = {}) {
115
+ const platform = opts.platform || process.platform;
116
+ const nodeDir = opts.nodeDir || path.dirname(process.execPath);
117
+ const exists = opts.exists || fs.existsSync;
118
+ const names = platform === 'win32' ? ['npm.cmd', 'npm'] : ['npm'];
119
+ for (const name of names) {
110
120
  const candidate = path.join(nodeDir, name);
111
- if (fs.existsSync(candidate)) return candidate;
121
+ if (exists(candidate)) return candidate;
112
122
  }
113
- try {
114
- return execSync(process.platform === 'win32' ? 'where npm' : 'which npm', {
123
+ // PATH fallback (injectable so tests can exercise the hard fallback
124
+ // deterministically regardless of the host OS).
125
+ const lookup = opts.lookup || (() =>
126
+ execSync(platform === 'win32' ? 'where npm' : 'which npm', {
115
127
  encoding: 'utf-8', timeout: 3000,
116
- }).trim().split(/\r?\n/)[0];
117
- } catch { return 'npm'; }
128
+ }).trim().split(/\r?\n/)[0]);
129
+ try {
130
+ return lookup() || (platform === 'win32' ? 'npm.cmd' : 'npm');
131
+ } catch { return platform === 'win32' ? 'npm.cmd' : 'npm'; }
118
132
  }
119
133
 
120
134
  /**
121
135
  * Run npm install to update to the latest version. Blocking.
122
- * Returns true on success.
136
+ * Returns true on success. `opts` are injectable for testing.
123
137
  */
124
- function runUpdate() {
125
- const prefix = detectInstallPrefix();
126
- const npmBin = findNpmBin();
138
+ function runUpdate(opts = {}) {
139
+ const platform = opts.platform || process.platform;
140
+ const spawn = opts.spawn || spawnSync;
141
+ const npmBin = opts.npmBin || findNpmBin({ platform });
142
+ const prefix = opts.prefix !== undefined ? opts.prefix : detectInstallPrefix();
127
143
  const args = ['install', '--no-save', `${PKG_NAME}@latest`];
128
144
  if (prefix) args.push('--prefix', prefix);
129
145
  else args.push('-g');
146
+
147
+ // On Windows, npm.cmd is a batch file and must be invoked through cmd.exe.
148
+ // Passing the args via cmd.exe with shell:false lets Node quote paths that
149
+ // contain spaces (e.g. C:\Program Files\nodejs\npm.cmd) correctly.
150
+ let cmd, cmdArgs;
151
+ if (platform === 'win32') {
152
+ cmd = process.env.ComSpec || 'cmd.exe';
153
+ cmdArgs = ['/d', '/s', '/c', npmBin, ...args];
154
+ } else {
155
+ cmd = npmBin;
156
+ cmdArgs = args;
157
+ }
158
+
130
159
  process.stderr.write(`[launcher] Running: ${npmBin} ${args.join(' ')}\n`);
131
- const r = spawnSync(npmBin, args, { stdio: 'inherit' });
132
- return r.status === 0;
160
+ const r = spawn(cmd, cmdArgs, { stdio: 'inherit' });
161
+
162
+ // spawn failed to even start the process (ENOENT / EINVAL / EACCES ...).
163
+ if (r && r.error) {
164
+ process.stderr.write(`[launcher] Failed to run npm (${cmd}): ${r.error.message}\n`);
165
+ return false;
166
+ }
167
+ // npm started but exited non-zero. Its own stderr was already streamed via
168
+ // stdio:'inherit'; surface the exit code so the failure isn't silent.
169
+ if (!r || r.status !== 0) {
170
+ const code = r ? r.status : 'unknown';
171
+ const sig = r && r.signal ? ` (signal ${r.signal})` : '';
172
+ process.stderr.write(`[launcher] npm exited with code ${code}${sig}\n`);
173
+ return false;
174
+ }
175
+ return true;
133
176
  }
134
177
 
135
178
  /**
@@ -179,5 +222,6 @@ module.exports = {
179
222
  checkForUpdate,
180
223
  notifyAndMaybeUpdate,
181
224
  runUpdate,
225
+ findNpmBin,
182
226
  currentVersion,
183
227
  };
@@ -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>