@openagents-org/agent-launcher 0.2.134 → 0.2.136

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.136",
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",
@@ -633,7 +616,7 @@
633
616
  "requires": ["python3"],
634
617
  "macos": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
635
618
  "linux": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
636
- "windows": "echo 'Hermes requires WSL2 on Windows — see https://github.com/NousResearch/hermes-agent'"
619
+ "windows": "wsl.exe -e bash -lc \"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup\""
637
620
  },
638
621
  "launch": {
639
622
  "args": []
@@ -19,6 +19,7 @@
19
19
 
20
20
  const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
21
21
  const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
22
+ const { defaultAgentWorkdir } = require('../paths');
22
23
 
23
24
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
24
25
 
@@ -82,7 +83,7 @@ class BaseAdapter {
82
83
  network: this.workspaceId,
83
84
  agentType: this.agentType || 'agent',
84
85
  serverHost: require('os').hostname(),
85
- workingDir: this.workingDir || process.cwd(),
86
+ workingDir: this.workingDir || defaultAgentWorkdir(this.agentName),
86
87
  });
87
88
  this._sessionId = (joinResult && joinResult.session_id) || null;
88
89
  this._log(`Joined workspace ${this.workspaceId}${this._sessionId ? ` (session ${this._sessionId.slice(0, 8)})` : ''}`);
@@ -253,7 +254,7 @@ class BaseAdapter {
253
254
  this._log('skill.install: missing skill metadata in payload — ignoring');
254
255
  return;
255
256
  }
256
- this._log(`skill.install: starting install of "${skillId}" (type=${this.agentType}, dir=${this.workingDir || process.cwd()})`);
257
+ this._log(`skill.install: starting install of "${skillId}" (type=${this.agentType}, dir=${this.workingDir || defaultAgentWorkdir(this.agentName)})`);
257
258
 
258
259
  // Best-effort "installing" ping so the UI flips immediately even if the
259
260
  // initial DB write from the request hasn't propagated to this client.
@@ -19,6 +19,7 @@ const { execSync, spawn } = require('child_process');
19
19
  const BaseAdapter = require('./base');
20
20
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
21
21
  const { buildClaudeSystemPrompt, buildClaudeSkillMd } = require('./workspace-prompt');
22
+ const { defaultAgentWorkdir, whichBinary, getEnhancedEnv } = require('../paths');
22
23
 
23
24
  const IS_WINDOWS = process.platform === 'win32';
24
25
 
@@ -38,6 +39,8 @@ class ClaudeAdapter extends BaseAdapter {
38
39
  this._stoppingChannels = new Set();
39
40
  this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
40
41
  this._IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
42
+ this._WATCHDOG_INTERVAL_MS = 15_000; // 15s between checks
43
+ this._WATCHDOG_MAX_TIMEOUTS = 20; // 20 * 15s = 5 min of silence → kill
41
44
  this._sessionsFile = path.join(
42
45
  os.homedir(), '.openagents', 'sessions',
43
46
  `${this.workspaceId}_${this.agentName}.json`
@@ -330,15 +333,22 @@ class ClaudeAdapter extends BaseAdapter {
330
333
  const portableCandidate = path.join(portableBin, `claude${ext}`);
331
334
  if (fs.existsSync(portableCandidate)) return portableCandidate;
332
335
 
333
- // Tier 1: PATH search
336
+ // Tier 1: PATH search. Use the ENRICHED env so the lookup sees the same
337
+ // node-version-manager / homebrew / npm-global dirs the launcher adds —
338
+ // a packaged Electron daemon's own PATH is minimal, which is why `which
339
+ // claude` came up empty and the agent reported "claude CLI not found".
340
+ // windowsHide stops a console window from flashing.
334
341
  try {
342
+ const env = getEnhancedEnv();
335
343
  if (IS_WINDOWS) {
336
344
  const r = execSync('where claude.cmd 2>nul || where claude.exe 2>nul || where claude 2>nul', {
337
- encoding: 'utf-8', timeout: 5000,
345
+ encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
338
346
  });
339
- return r.split(/\r?\n/)[0].trim();
347
+ const hit = r.split(/\r?\n/)[0].trim();
348
+ if (hit) return hit;
340
349
  } else {
341
- return execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim();
350
+ const hit = execSync('which claude', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
351
+ if (hit) return hit;
342
352
  }
343
353
  } catch {}
344
354
 
@@ -352,6 +362,7 @@ class ClaudeAdapter extends BaseAdapter {
352
362
  path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
353
363
  ] : [
354
364
  path.join(home, '.local', 'bin', 'claude'),
365
+ path.join(home, '.claude', 'local', 'claude'),
355
366
  path.join(home, '.npm-global', 'bin', 'claude'),
356
367
  '/opt/homebrew/bin/claude',
357
368
  '/usr/local/bin/claude',
@@ -360,6 +371,13 @@ class ClaudeAdapter extends BaseAdapter {
360
371
  if (fs.existsSync(c)) return c;
361
372
  }
362
373
 
374
+ // Tier 4: Deep scan of every known bin dir (nvm/fnm/volta node-global,
375
+ // homebrew, cargo, pip, …). This is what catches a `claude` installed as a
376
+ // global npm package under a version-managed Node — the most common setup,
377
+ // and the one the fixed-PATH tiers above miss.
378
+ const viaWhich = whichBinary('claude');
379
+ if (viaWhich) return viaWhich;
380
+
363
381
  return null;
364
382
  }
365
383
 
@@ -425,8 +443,10 @@ class ClaudeAdapter extends BaseAdapter {
425
443
  cmd.push('--allowedTools', 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep');
426
444
  }
427
445
 
428
- // Write SKILL.md to .claude/skills/ in the working directory
429
- const workDir = this.workingDir || process.cwd();
446
+ // Write SKILL.md to .claude/skills/ in the working directory. Never use
447
+ // process.cwd() as the fallback — on a packaged Windows daemon that is
448
+ // C:\WINDOWS\system32 and mkdir there throws EPERM.
449
+ const workDir = this.workingDir || defaultAgentWorkdir(this.agentName);
430
450
  const skillDir = path.join(workDir, '.claude', 'skills');
431
451
  fs.mkdirSync(skillDir, { recursive: true });
432
452
  const skillFile = path.join(skillDir, 'openagents-workspace.md');
@@ -579,6 +599,7 @@ class ClaudeAdapter extends BaseAdapter {
579
599
  const pp = this._persistentProcs[channel];
580
600
  if (!pp) return;
581
601
  if (pp.idleTimer) clearTimeout(pp.idleTimer);
602
+ this._stopWatchdog(pp);
582
603
  this._stopProcess(pp.proc).catch(() => {});
583
604
  delete this._persistentProcs[channel];
584
605
  }
@@ -597,6 +618,49 @@ class ClaudeAdapter extends BaseAdapter {
597
618
  }, this._IDLE_TIMEOUT_MS);
598
619
  }
599
620
 
621
+ /**
622
+ * Start a watchdog that kills the persistent process if stdout goes
623
+ * silent for too long while a message is in flight. Pauses during
624
+ * tool execution (awaitingToolResult) since long-running commands
625
+ * like builds or sleep produce no stdout legitimately.
626
+ */
627
+ _startWatchdog(pp) {
628
+ this._stopWatchdog(pp);
629
+ pp.lastStdoutTime = Date.now();
630
+ let consecutiveTimeouts = 0;
631
+
632
+ pp.watchdogTimer = setInterval(async () => {
633
+ if (!pp.messageResolve) { consecutiveTimeouts = 0; return; }
634
+ if (pp.awaitingToolResult) { consecutiveTimeouts = 0; return; }
635
+
636
+ const elapsed = Date.now() - pp.lastStdoutTime;
637
+ if (elapsed < this._WATCHDOG_INTERVAL_MS) { consecutiveTimeouts = 0; return; }
638
+
639
+ consecutiveTimeouts++;
640
+ pp.lastStdoutTime = Date.now();
641
+
642
+ if (consecutiveTimeouts === 2) {
643
+ try { await this.sendStatus(pp.msgChannel, 'Still processing...'); } catch {}
644
+ }
645
+
646
+ if (consecutiveTimeouts >= this._WATCHDOG_MAX_TIMEOUTS) {
647
+ this._log(`Watchdog: process unresponsive for ${consecutiveTimeouts * 15}s on ${pp.msgChannel} — killing`);
648
+ this._stopWatchdog(pp);
649
+ try { await this.sendError(pp.msgChannel, 'Agent process became unresponsive and was restarted.'); } catch {}
650
+ if (pp.messageResolve) {
651
+ const resolve = pp.messageResolve;
652
+ pp.messageResolve = null;
653
+ resolve({ exited: true, error: new Error('watchdog timeout') });
654
+ }
655
+ this._killPersistentProc(pp.msgChannel);
656
+ }
657
+ }, this._WATCHDOG_INTERVAL_MS);
658
+ }
659
+
660
+ _stopWatchdog(pp) {
661
+ if (pp.watchdogTimer) { clearInterval(pp.watchdogTimer); pp.watchdogTimer = null; }
662
+ }
663
+
600
664
  /**
601
665
  * Spawn a persistent Claude process for a channel that accepts messages
602
666
  * via stdin (--input-format stream-json). Returns the persistent proc entry.
@@ -651,6 +715,9 @@ class ClaudeAdapter extends BaseAdapter {
651
715
  everPostedAnything: false,
652
716
  stderrBuf: '',
653
717
  alive: true,
718
+ lastStdoutTime: Date.now(),
719
+ watchdogTimer: null,
720
+ awaitingToolResult: false,
654
721
  };
655
722
 
656
723
  if (proc.stderr) {
@@ -665,6 +732,7 @@ class ClaudeAdapter extends BaseAdapter {
665
732
  const eventType = event.type;
666
733
 
667
734
  if (eventType === 'assistant') {
735
+ pp.awaitingToolResult = false;
668
736
  const blocks = (event.message || {}).content || [];
669
737
  for (const block of blocks) {
670
738
  if (block.type === 'text' && block.text && block.text.trim()) {
@@ -680,6 +748,7 @@ class ClaudeAdapter extends BaseAdapter {
680
748
  pp.hasToolUseSinceLastText = true;
681
749
  pp.postedThinking = false;
682
750
  pp.lastResponseText.length = 0;
751
+ pp.awaitingToolResult = true;
683
752
  const toolName = block.name || '';
684
753
  if (toolName === 'TodoWrite' && block.input && block.input.todos) {
685
754
  try {
@@ -707,6 +776,7 @@ class ClaudeAdapter extends BaseAdapter {
707
776
  }
708
777
  }
709
778
  } else if (eventType === 'result') {
779
+ pp.awaitingToolResult = false;
710
780
  const sessionId = event.session_id;
711
781
  if (sessionId) {
712
782
  this._channelSessions[pp.msgChannel] = sessionId;
@@ -732,6 +802,7 @@ class ClaudeAdapter extends BaseAdapter {
732
802
 
733
803
  proc.stdout.on('data', (chunk) => {
734
804
  pp.lineBuffer += chunk.toString('utf-8');
805
+ pp.lastStdoutTime = Date.now();
735
806
  const lines = pp.lineBuffer.split('\n');
736
807
  pp.lineBuffer = lines.pop();
737
808
  for (const line of lines) {
@@ -743,6 +814,7 @@ class ClaudeAdapter extends BaseAdapter {
743
814
  this._log(`Persistent process exited: channel=${channel} code=${code}`);
744
815
  pp.alive = false;
745
816
  if (pp.idleTimer) clearTimeout(pp.idleTimer);
817
+ this._stopWatchdog(pp);
746
818
  if (pp.messageResolve) {
747
819
  pp.messageResolve({ exited: true, code });
748
820
  pp.messageResolve = null;
@@ -754,6 +826,7 @@ class ClaudeAdapter extends BaseAdapter {
754
826
  proc.on('error', (err) => {
755
827
  this._log(`Persistent process error: ${err.message}`);
756
828
  pp.alive = false;
829
+ this._stopWatchdog(pp);
757
830
  if (pp.messageResolve) {
758
831
  pp.messageResolve({ exited: true, error: err });
759
832
  pp.messageResolve = null;
@@ -777,6 +850,7 @@ class ClaudeAdapter extends BaseAdapter {
777
850
  pp.hasToolUseSinceLastText = false;
778
851
  pp.postedThinking = false;
779
852
  pp.everPostedAnything = false;
853
+ pp.awaitingToolResult = false;
780
854
 
781
855
  const stdinMsg = JSON.stringify({
782
856
  type: 'user',
@@ -787,11 +861,18 @@ class ClaudeAdapter extends BaseAdapter {
787
861
  }) + '\n';
788
862
 
789
863
  return new Promise((resolve) => {
790
- pp.messageResolve = resolve;
864
+ pp.messageResolve = (result) => {
865
+ this._stopWatchdog(pp);
866
+ resolve(result);
867
+ };
868
+ pp.lastStdoutTime = Date.now();
869
+ this._startWatchdog(pp);
791
870
  try {
792
871
  pp.proc.stdin.write(stdinMsg);
793
872
  } catch (e) {
794
873
  this._log(`stdin write failed: ${e.message}`);
874
+ this._stopWatchdog(pp);
875
+ pp.messageResolve = null;
795
876
  resolve({ exited: true, error: e });
796
877
  }
797
878
  });
@@ -946,7 +1027,7 @@ class ClaudeAdapter extends BaseAdapter {
946
1027
 
947
1028
  if (this._mode === 'plan') {
948
1029
  try {
949
- const planDir = path.join(this.workingDir || process.cwd(), '.claude', 'plans');
1030
+ const planDir = path.join(this.workingDir || defaultAgentWorkdir(this.agentName), '.claude', 'plans');
950
1031
  if (fs.existsSync(planDir)) {
951
1032
  const planFiles = fs.readdirSync(planDir)
952
1033
  .filter((f) => f.endsWith('.md'))
@@ -17,6 +17,7 @@ const { execSync, spawn } = require('child_process');
17
17
  const BaseAdapter = require('./base');
18
18
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
19
19
  const { buildCursorSkillMd } = require('./workspace-prompt');
20
+ const { defaultAgentWorkdir, whichBinary, getEnhancedEnv } = require('../paths');
20
21
 
21
22
  const IS_WINDOWS = process.platform === 'win32';
22
23
 
@@ -185,15 +186,22 @@ class CursorAdapter extends BaseAdapter {
185
186
  if (fs.existsSync(portableCandidate)) return portableCandidate;
186
187
  }
187
188
 
188
- // Tier 1: PATH search
189
+ // Tier 1: PATH search. Use the ENRICHED env so the lookup sees the dirs the
190
+ // launcher adds — a freshly-run cursor installer updates the *user* PATH,
191
+ // which the daemon's already-running process won't see, so `where` against
192
+ // the daemon's own PATH comes up empty even though cursor-agent is installed.
193
+ // windowsHide stops a console window from flashing.
189
194
  try {
195
+ const env = getEnhancedEnv();
190
196
  if (IS_WINDOWS) {
191
197
  const r = execSync('where cursor-agent.cmd 2>nul || where cursor-agent.exe 2>nul || where cursor-agent 2>nul || where agent.cmd 2>nul || where agent.exe 2>nul || where agent 2>nul', {
192
- encoding: 'utf-8', timeout: 5000,
198
+ encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
193
199
  });
194
- return r.split(/\r?\n/)[0].trim();
200
+ const hit = r.split(/\r?\n/)[0].trim();
201
+ if (hit) return hit;
195
202
  } else {
196
- return execSync('which cursor-agent || which agent', { encoding: 'utf-8', timeout: 5000 }).trim();
203
+ const hit = execSync('which cursor-agent || which agent', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
204
+ if (hit) return hit;
197
205
  }
198
206
  } catch {}
199
207
 
@@ -207,6 +215,17 @@ class CursorAdapter extends BaseAdapter {
207
215
  // Tier 3: Common install locations
208
216
  const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
209
217
  const candidates = IS_WINDOWS ? [
218
+ // The Windows installer (install?win32=true) drops cursor-agent under the
219
+ // user home's .local/bin — the same place the Unix installer uses. This
220
+ // was missing, so a successful install was invisible to the launcher.
221
+ path.join(home, '.local', 'bin', 'cursor-agent.exe'),
222
+ path.join(home, '.local', 'bin', 'cursor-agent.cmd'),
223
+ path.join(home, '.local', 'bin', 'cursor-agent'),
224
+ path.join(home, '.local', 'bin', 'agent.exe'),
225
+ path.join(home, '.local', 'bin', 'agent.cmd'),
226
+ // Older/alternate layout: %LOCALAPPDATA%\Programs\cursor-agent\.
227
+ path.join(localAppData, 'Programs', 'cursor-agent', 'cursor-agent.exe'),
228
+ path.join(localAppData, 'Programs', 'cursor-agent', 'agent.exe'),
210
229
  // Windows native installer (install?win32=true) drops the CLI here.
211
230
  path.join(localAppData, 'cursor-agent', 'cursor-agent.cmd'),
212
231
  path.join(localAppData, 'cursor-agent', 'cursor-agent.exe'),
@@ -236,6 +255,12 @@ class CursorAdapter extends BaseAdapter {
236
255
  if (fs.existsSync(c)) return c;
237
256
  }
238
257
 
258
+ // Tier 4: Deep scan of every known bin dir (nvm/fnm/volta, homebrew, …).
259
+ for (const name of names) {
260
+ const viaWhich = whichBinary(name);
261
+ if (viaWhich) return viaWhich;
262
+ }
263
+
239
264
  return null;
240
265
  }
241
266
 
@@ -277,7 +302,9 @@ class CursorAdapter extends BaseAdapter {
277
302
  // ── Skill file writing ──
278
303
 
279
304
  _writeSkillFile(channelName) {
280
- const workDir = this.workingDir || process.cwd();
305
+ // Never fall back to process.cwd() (C:\WINDOWS\system32 on a packaged
306
+ // Windows daemon → EPERM); use a writable per-agent dir under ~/.openagents.
307
+ const workDir = this.workingDir || defaultAgentWorkdir(this.agentName);
281
308
  const skillDir = path.join(workDir, '.cursor', 'skills');
282
309
  fs.mkdirSync(skillDir, { recursive: true });
283
310
  const skillFile = path.join(skillDir, 'openagents-workspace.md');
@@ -21,6 +21,7 @@ const { execSync, spawn } = require('child_process');
21
21
 
22
22
  const BaseAdapter = require('./base');
23
23
  const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
24
+ const { whichBinary, getEnhancedEnv } = require('../paths');
24
25
 
25
26
  const IS_WINDOWS = process.platform === 'win32';
26
27
  const SESSION_ID_RE = /session_id:\s*(\S+)/;
@@ -65,22 +66,39 @@ class HermesAdapter extends BaseAdapter {
65
66
 
66
67
  _findHermesBinary() {
67
68
  const home = os.homedir();
69
+ // Reset each call. When set, this._hermesBin is a path INSIDE WSL and must
70
+ // be invoked as `wsl -e <path> …` rather than spawned natively.
71
+ this._hermesViaWsl = false;
68
72
 
69
- // Tier 1: PATH
73
+ // Tier 1: PATH (enriched env so we see the dirs the launcher adds; a fresh
74
+ // install updates the user PATH, which the running daemon won't pick up).
75
+ // windowsHide stops a console window from flashing.
70
76
  try {
77
+ const env = getEnhancedEnv();
71
78
  if (IS_WINDOWS) {
72
79
  // Native Windows unsupported upstream — we try anyway for WSL cases
73
- const r = execSync('where hermes 2>nul', { encoding: 'utf-8', timeout: 5000 });
80
+ const r = execSync('where hermes.exe 2>nul || where hermes.cmd 2>nul || where hermes 2>nul', {
81
+ encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
82
+ });
74
83
  const found = r.split(/\r?\n/)[0].trim();
75
84
  if (found) return found;
76
85
  } else {
77
- const found = execSync('which hermes', { encoding: 'utf-8', timeout: 5000 }).trim();
86
+ const found = execSync('which hermes', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
78
87
  if (found) return found;
79
88
  }
80
89
  } catch {}
81
90
 
82
- // Tier 2: Common install locations (hermes installer uses ~/.local/bin)
83
- const candidates = IS_WINDOWS ? [] : [
91
+ // Tier 2: Common install locations. The installer drops `hermes` in
92
+ // ~/.local/bin and (on Windows) ~/.hermes/bin.
93
+ const candidates = IS_WINDOWS ? [
94
+ path.join(home, '.hermes', 'bin', 'hermes.exe'),
95
+ path.join(home, '.hermes', 'bin', 'hermes.cmd'),
96
+ path.join(home, '.hermes', 'bin', 'hermes'),
97
+ path.join(home, '.local', 'bin', 'hermes.exe'),
98
+ path.join(home, '.local', 'bin', 'hermes.cmd'),
99
+ path.join(home, '.local', 'bin', 'hermes'),
100
+ ] : [
101
+ path.join(home, '.hermes', 'bin', 'hermes'),
84
102
  path.join(home, '.local', 'bin', 'hermes'),
85
103
  '/opt/homebrew/bin/hermes',
86
104
  '/usr/local/bin/hermes',
@@ -89,6 +107,40 @@ class HermesAdapter extends BaseAdapter {
89
107
  if (fs.existsSync(c)) return c;
90
108
  }
91
109
 
110
+ // Tier 3: Deep scan of every known bin dir.
111
+ const viaWhich = whichBinary('hermes');
112
+ if (viaWhich) return viaWhich;
113
+
114
+ // Tier 4 (Windows only): Hermes is not supported natively on Windows — it
115
+ // installs and runs inside WSL2. If a WSL distro has hermes on its login
116
+ // PATH, resolve its absolute in-WSL path; _runHermes then invokes it as
117
+ // `wsl -e <path> …`.
118
+ if (IS_WINDOWS) {
119
+ const wslPath = this._resolveWslHermes();
120
+ if (wslPath) {
121
+ this._hermesViaWsl = true;
122
+ return wslPath;
123
+ }
124
+ }
125
+
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Resolve hermes's absolute path inside the default WSL distro, or null.
131
+ * Uses a login shell (`bash -lc`) so the installer's PATH additions
132
+ * (~/.local/bin) are visible. Returns an absolute Linux path like
133
+ * /home/<user>/.local/bin/hermes.
134
+ */
135
+ _resolveWslHermes() {
136
+ if (!IS_WINDOWS) return null;
137
+ try {
138
+ const out = execSync('wsl.exe -e bash -lc "command -v hermes"', {
139
+ encoding: 'utf-8', timeout: 8000, windowsHide: true,
140
+ }).trim();
141
+ const p = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
142
+ if (p && p.startsWith('/')) return p;
143
+ } catch {}
92
144
  return null;
93
145
  }
94
146
 
@@ -255,10 +307,25 @@ class HermesAdapter extends BaseAdapter {
255
307
  this._log(`Running hermes (profile=${this.hermesProfile}, channel=${channelName}, resume=${!!resumeId})`);
256
308
 
257
309
  const env = { ...(this.agentEnv || process.env) };
258
- const proc = spawn(this._hermesBin, args, {
310
+
311
+ // On Windows hermes lives inside WSL: invoke `wsl -e <wsl-hermes-path> …`.
312
+ // `-e` runs the binary directly (no shell), so every arg — including the
313
+ // multi-line prompt in `-q` — passes through verbatim with no quoting hazard.
314
+ // hermes then uses its own config (~/.hermes inside WSL) for model/keys.
315
+ let spawnBin = this._hermesBin;
316
+ let spawnArgs = args;
317
+ if (this._hermesViaWsl) {
318
+ spawnBin = 'wsl.exe';
319
+ spawnArgs = ['-e', this._hermesBin, ...args];
320
+ }
321
+
322
+ const proc = spawn(spawnBin, spawnArgs, {
259
323
  env,
260
324
  stdio: ['ignore', 'pipe', 'pipe'],
261
- detached: !IS_WINDOWS,
325
+ // No process group on Windows / WSL (can't signal a group); windowsHide
326
+ // keeps the wsl.exe console from flashing up.
327
+ detached: !IS_WINDOWS && !this._hermesViaWsl,
328
+ windowsHide: true,
262
329
  });
263
330
  this._channelProcesses[channelName] = proc;
264
331
 
@@ -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 {
@@ -18,6 +18,7 @@ const { execSync, spawn } = require('child_process');
18
18
  const BaseAdapter = require('./base');
19
19
  const { formatAttachmentsForPrompt } = require('./utils');
20
20
  const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt } = require('./workspace-prompt');
21
+ const { whichBinary, getEnhancedEnv } = require('../paths');
21
22
 
22
23
  const IS_WINDOWS = process.platform === 'win32';
23
24
 
@@ -123,25 +124,55 @@ class OpenCodeAdapter extends BaseAdapter {
123
124
  // ------------------------------------------------------------------
124
125
 
125
126
  _findOpencodeBinary() {
126
- // Tier 1: PATH
127
+ const home = os.homedir();
128
+ const ext = IS_WINDOWS ? '.cmd' : '';
129
+
130
+ // Tier 0: the actual native binary the opencode-ai package ships, NOT the
131
+ // npm `.cmd`/`.bin` shim. opencode-ai's `bin` is a single self-contained
132
+ // executable named `opencode.exe` on every platform (on macOS/Linux it's
133
+ // the native Mach-O/ELF binary — the .exe suffix is just how the package
134
+ // names it). Returning it directly lets us spawn it without going through
135
+ // `cmd.exe /C opencode.cmd`, which on Windows is what flashed a console
136
+ // window — and an attached console makes opencode drop into its interactive
137
+ // TUI instead of non-interactive `run`, so it hung waiting for keypresses.
138
+ const nativeExe = path.join(home, '.openagents', 'runtimes', 'opencode', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
139
+ if (fs.existsSync(nativeExe)) return nativeExe;
140
+
141
+ // Tier 0b: Isolated runtime prefix shim — where the launcher installs agents
142
+ // (~/.openagents/runtimes/opencode/node_modules/.bin). Every other adapter
143
+ // checks this first; opencode was the lone exception, so a launcher-managed
144
+ // install was invisible unless its .bin happened to be on PATH — which is
145
+ // exactly why the workspace failed with "opencode CLI not found" even though
146
+ // the marketplace showed it installed.
147
+ const runtimeBin = path.join(home, '.openagents', 'runtimes', 'opencode', 'node_modules', '.bin', `opencode${ext}`);
148
+ if (fs.existsSync(runtimeBin)) return runtimeBin;
149
+
150
+ // Tier 0b: Legacy shared portable prefix.
151
+ const legacyBin = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `opencode${ext}`);
152
+ if (fs.existsSync(legacyBin)) return legacyBin;
153
+
154
+ // Tier 1: PATH. Use the ENRICHED env (node-version-manager/homebrew/npm
155
+ // dirs the launcher adds) so the lookup matches a packaged daemon's real
156
+ // reach; windowsHide stops a console window from flashing on Windows.
127
157
  try {
158
+ const env = getEnhancedEnv();
128
159
  if (IS_WINDOWS) {
129
- const r = execSync('where opencode.cmd 2>nul || where opencode.exe 2>nul || where opencode 2>nul', {
130
- encoding: 'utf-8', timeout: 5000,
160
+ const r = execSync('where opencode.exe 2>nul || where opencode.cmd 2>nul || where opencode 2>nul', {
161
+ encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
131
162
  });
132
- return r.split(/\r?\n/)[0].trim();
163
+ const hit = r.split(/\r?\n/)[0].trim();
164
+ if (hit) return hit;
133
165
  } else {
134
- return execSync('which opencode', { encoding: 'utf-8', timeout: 5000 }).trim();
166
+ const hit = execSync('which opencode', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
167
+ if (hit) return hit;
135
168
  }
136
169
  } catch {}
137
170
 
138
171
  // Tier 2: Next to Node.js
139
- const ext = IS_WINDOWS ? '.cmd' : '';
140
172
  const nearNode = path.join(path.dirname(process.execPath), `opencode${ext}`);
141
173
  if (fs.existsSync(nearNode)) return nearNode;
142
174
 
143
175
  // Tier 3: Common locations
144
- const home = os.homedir();
145
176
  const candidates = IS_WINDOWS ? [
146
177
  path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
147
178
  ] : [
@@ -153,6 +184,13 @@ class OpenCodeAdapter extends BaseAdapter {
153
184
  for (const c of candidates) {
154
185
  if (fs.existsSync(c)) return c;
155
186
  }
187
+
188
+ // Tier 4: Deep scan of every known bin dir (nvm/fnm/volta node-global,
189
+ // homebrew, …) — catches an `opencode` installed as a global npm package
190
+ // under a version-managed Node, which the fixed-PATH tiers above miss.
191
+ const viaWhich = whichBinary('opencode');
192
+ if (viaWhich) return viaWhich;
193
+
156
194
  return null;
157
195
  }
158
196
 
@@ -184,7 +222,12 @@ class OpenCodeAdapter extends BaseAdapter {
184
222
  if (responseText) {
185
223
  await this.sendResponse(msgChannel, responseText);
186
224
  } else {
187
- await this.sendResponse(msgChannel, 'No response generated. Please try again.');
225
+ // Exit 0 with no assistant text almost always means OpenCode has no
226
+ // model/provider configured (it emits only step_start then stops).
227
+ await this.sendResponse(
228
+ msgChannel,
229
+ 'OpenCode ran but produced no response. This usually means no model/provider is configured — set one up (e.g. `opencode auth login` or configure a provider/API key) and try again.'
230
+ );
188
231
  }
189
232
  } catch (e) {
190
233
  this._log(`Error handling message: ${e.message}`);
@@ -269,6 +312,23 @@ class OpenCodeAdapter extends BaseAdapter {
269
312
  return texts.length ? texts.join('\n').trim() : raw.trim();
270
313
  }
271
314
 
315
+ /**
316
+ * True when `raw` is opencode JSON that carries ONLY control events
317
+ * (step_start / step_finish / tool_use) and no assistant text — which is what
318
+ * opencode emits when no model/provider is configured. In that case
319
+ * _extractTextFromJson falls back to returning the raw event JSON, which we
320
+ * must NOT post to the channel as if it were a reply. Non-JSON output (real
321
+ * plain text) returns false so it is kept.
322
+ */
323
+ static _isOnlyControlJson(raw) {
324
+ const events = OpenCodeAdapter._splitJsonObjects(raw);
325
+ if (!events.length) return false;
326
+ for (const ev of events) {
327
+ if (OpenCodeAdapter._extractTextFromEvent(ev)) return false;
328
+ }
329
+ return true;
330
+ }
331
+
272
332
  /**
273
333
  * Extract and persist session_id from OpenCode JSON events.
274
334
  */
@@ -328,6 +388,8 @@ class OpenCodeAdapter extends BaseAdapter {
328
388
 
329
389
  let spawnBinary = cmd[0];
330
390
  let spawnArgs = cmd.slice(1);
391
+ // Only the npm `.cmd` shim needs a cmd.exe host. The native opencode.exe
392
+ // (preferred by _findOpencodeBinary) is spawned directly — no console host.
331
393
  if (IS_WINDOWS && spawnBinary.toLowerCase().endsWith('.cmd')) {
332
394
  spawnArgs = ['/C', spawnBinary, ...spawnArgs];
333
395
  spawnBinary = process.env.COMSPEC || 'cmd.exe';
@@ -339,38 +401,62 @@ class OpenCodeAdapter extends BaseAdapter {
339
401
  env: spawnEnv,
340
402
  cwd: this.agentHome,
341
403
  timeout: 300000, // 5 minutes
404
+ // Never let a console window appear. Besides being ugly, an attached
405
+ // console makes opencode start its interactive TUI and hang instead of
406
+ // running headless — the root cause of "stuck on thinking…" on Windows.
407
+ windowsHide: true,
342
408
  });
343
409
 
344
410
  let stdout = '';
345
411
  let stderr = '';
412
+ let settled = false;
413
+ const finish = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
346
414
 
347
415
  if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d; });
348
416
  if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; });
349
417
 
350
- // Send the prompt via stdin
418
+ // Send the prompt via stdin, then close it so opencode knows the message
419
+ // is complete and never blocks waiting for more input. Guard the write:
420
+ // if the child already died, writing to its stdin throws EPIPE.
351
421
  if (proc.stdin) {
352
- proc.stdin.write(fullPrompt, 'utf-8');
353
- proc.stdin.end();
422
+ proc.stdin.on('error', () => {});
423
+ try {
424
+ proc.stdin.write(fullPrompt, 'utf-8');
425
+ proc.stdin.end();
426
+ } catch { /* child gone — the exit/error handler reports it */ }
354
427
  }
355
428
 
356
- proc.on('error', (err) => reject(err));
357
- proc.on('exit', (code) => {
429
+ proc.on('error', (err) => finish(reject, err));
430
+ // 'close' (not 'exit') fires after stdout/stderr have fully drained, so we
431
+ // never parse a truncated response.
432
+ proc.on('close', (code) => {
358
433
  stdout = stdout.trim();
359
434
  stderr = stderr.trim();
360
435
 
361
436
  if (code !== 0) {
362
437
  this._log(`opencode exited with code ${code}: ${stderr.slice(0, 300)}`);
438
+ // Surface the real failure instead of a generic "No response" so the
439
+ // user can see (e.g.) a missing model/provider or auth error.
440
+ return finish(reject, new Error(
441
+ stderr ? stderr.slice(0, 500) : `opencode exited with code ${code}`
442
+ ));
363
443
  }
364
444
 
365
445
  if (stdout) {
366
446
  this._persistSessionId(msgChannel, stdout);
367
- resolve(OpenCodeAdapter._extractTextFromJson(stdout));
368
- } else {
369
- if (stderr) {
370
- this._log(`opencode stderr: ${stderr.slice(0, 300)}`);
447
+ const text = OpenCodeAdapter._extractTextFromJson(stdout);
448
+ // Exit 0 but no assistant text usually means no model/provider is
449
+ // configured (opencode emits only step_start then stops). Tell the
450
+ // user how to fix it rather than echoing raw event JSON.
451
+ if (!text || OpenCodeAdapter._isOnlyControlJson(stdout)) {
452
+ this._log(`opencode produced no assistant text. stdout head: ${stdout.slice(0, 300)}`);
453
+ return finish(resolve, '');
371
454
  }
372
- resolve('');
455
+ return finish(resolve, text);
373
456
  }
457
+
458
+ if (stderr) this._log(`opencode stderr: ${stderr.slice(0, 300)}`);
459
+ finish(resolve, '');
374
460
  });
375
461
  });
376
462
  }
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
@@ -5,7 +5,7 @@ const path = require('path');
5
5
  const { spawn, execSync, execFileSync } = require('child_process');
6
6
  const os = require('os');
7
7
  const { WorkspaceClient } = require('./workspace-client');
8
- const { getEnhancedEnv, whichBinary, IS_WINDOWS } = require('./paths');
8
+ const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
9
9
 
10
10
  /**
11
11
  * Agent process lifecycle manager.
@@ -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,
@@ -453,7 +477,11 @@ class Daemon {
453
477
  openclawAgentId: agentCfg.openclaw_agent_id || 'main',
454
478
  disabledModules: skillsToDisabledModules(agentCfg.skills),
455
479
  agentEnv: this._buildAgentEnv(agentCfg),
456
- workingDir: agentCfg.path || undefined,
480
+ // Always give the agent a real, writable working directory. Without an
481
+ // explicit `path`, adapters used to fall back to process.cwd(), which on
482
+ // a packaged Windows launcher is C:\WINDOWS\system32 — so writing
483
+ // .claude/skills there failed with EPERM. Root it under ~/.openagents.
484
+ workingDir: agentCfg.path || defaultAgentWorkdir(name),
457
485
  toolMode: agentCfg.tool_mode || 'skills',
458
486
  });
459
487
  } catch (e) {
@@ -657,8 +685,24 @@ class Daemon {
657
685
  this.stopAgent(agentName);
658
686
  } else if (cmd.startsWith('start:')) {
659
687
  const agentName = cmd.slice(6).trim();
660
- this._log(`Command: start ${agentName}`);
661
- this.restartAgent(agentName);
688
+ // 'start' must be idempotent. The launcher sends start:<name> right
689
+ // after (re)spawning the daemon, but the daemon's own start() already
690
+ // launched every configured agent. A blind restart here tears down
691
+ // the just-joined workspace session and re-joins as the same agent;
692
+ // the server revokes the first session and the agent then stops the
693
+ // moment it next touches the workspace (e.g. the first user message →
694
+ // "thinking..." status). Only (re)launch when it isn't running.
695
+ const running =
696
+ (this._adapters && this._adapters[agentName]) ||
697
+ ['running', 'starting'].includes(
698
+ this._processes[agentName] && this._processes[agentName].state
699
+ );
700
+ if (running) {
701
+ this._log(`Command: start ${agentName} — already running, skipping`);
702
+ } else {
703
+ this._log(`Command: start ${agentName}`);
704
+ this.restartAgent(agentName);
705
+ }
662
706
  } else if (cmd.startsWith('restart:')) {
663
707
  const agentName = cmd.slice(8).trim();
664
708
  this._log(`Command: restart ${agentName}`);
@@ -831,6 +875,38 @@ class Daemon {
831
875
  return false;
832
876
  }
833
877
  }
878
+
879
+ /**
880
+ * Return the PID of another live daemon for this configDir, or null.
881
+ *
882
+ * Used to enforce the singleton even on the `up --foreground` path, which the
883
+ * launcher spawns directly (bypassing daemonize's guard). Trusts BOTH the pid
884
+ * file and the status file (a live daemon rewrites the latter every 5s with
885
+ * its own pid), because the pid file gets emptied/clobbered under races. Uses
886
+ * real process-liveness — not just file age — so a daemon that was just
887
+ * stopped for a legitimate restart doesn't block the replacement.
888
+ */
889
+ static runningDaemonPid(configDir) {
890
+ const self = process.pid;
891
+ const isOtherAlive = (pid) =>
892
+ pid && pid !== self && Daemon._isAlive(pid);
893
+
894
+ const pidFromFile = Daemon._readPid(path.join(configDir, 'daemon.pid'));
895
+ if (isOtherAlive(pidFromFile)) return pidFromFile;
896
+
897
+ try {
898
+ const statusFile = path.join(configDir, 'daemon.status.json');
899
+ const age = Date.now() - fs.statSync(statusFile).mtimeMs;
900
+ // Bound the age so a long-dead daemon whose pid got reused by an
901
+ // unrelated process can't masquerade as a live daemon.
902
+ if (age < 30000) {
903
+ const raw = JSON.parse(fs.readFileSync(statusFile, 'utf-8'));
904
+ const pid = raw && raw.pid;
905
+ if (isOtherAlive(pid)) return pid;
906
+ }
907
+ } catch {}
908
+ return null;
909
+ }
834
910
  }
835
911
 
836
912
  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`);
package/src/paths.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
+ const os = require('os');
13
14
  const { execSync } = require('child_process');
14
15
 
15
16
  const IS_WINDOWS = process.platform === 'win32';
@@ -353,6 +354,28 @@ function getCorePrefix() {
353
354
  return path.join(HOME, '.openagents', 'core');
354
355
  }
355
356
 
357
+ /**
358
+ * A guaranteed-writable working directory for an agent that has no explicit
359
+ * `path` configured.
360
+ *
361
+ * NEVER fall back to process.cwd() for this: on a packaged Windows launcher the
362
+ * daemon (and the agent CLIs it spawns) inherit a cwd of C:\WINDOWS\system32
363
+ * (or the app's Program Files dir), so writing .claude/skills, .claude/plans,
364
+ * etc. relative to cwd fails with `EPERM: operation not permitted, mkdir`.
365
+ *
366
+ * Rooted under ~/.openagents — the same writable tree the daemon already uses
367
+ * for its pid/status/log files — with one isolated subdir per agent. Uses
368
+ * os.homedir() (OS-account based, reliable even when HOME/USERPROFILE are
369
+ * stripped from a child process's environment) rather than the env-derived
370
+ * HOME above.
371
+ */
372
+ function defaultAgentWorkdir(agentName) {
373
+ const safe = String(agentName || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
374
+ const dir = path.join(os.homedir(), '.openagents', 'workspaces', safe);
375
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
376
+ return dir;
377
+ }
378
+
356
379
  /**
357
380
  * Reset the two 30s caches that back binary detection. Call this after
358
381
  * install / uninstall so a freshly-created bin dir (e.g. ~/.cursor/bin)
@@ -372,6 +395,7 @@ module.exports = {
372
395
  clearBinaryLookupCache,
373
396
  getRuntimePrefix,
374
397
  getCorePrefix,
398
+ defaultAgentWorkdir,
375
399
  IS_WINDOWS,
376
400
  IS_MACOS,
377
401
  SEP,
@@ -25,6 +25,7 @@ const fs = require('fs');
25
25
  const os = require('os');
26
26
  const path = require('path');
27
27
  const { execFileSync } = require('child_process');
28
+ const { defaultAgentWorkdir } = require('./paths');
28
29
 
29
30
  /**
30
31
  * Resolve the skills directory for a given agent type, rooted at the agent's
@@ -40,7 +41,10 @@ const { execFileSync } = require('child_process');
40
41
  * @returns {string} absolute path to the skills directory
41
42
  */
42
43
  function skillsDirForAgentType(agentType, workingDir) {
43
- const base = workingDir || process.cwd();
44
+ // Never root skills at process.cwd(): a packaged Windows daemon's cwd is
45
+ // C:\WINDOWS\system32, so installing there throws EPERM. Fall back to a
46
+ // writable per-agent dir under ~/.openagents instead.
47
+ const base = workingDir || defaultAgentWorkdir(agentType);
44
48
  switch ((agentType || '').toLowerCase()) {
45
49
  case 'claude':
46
50
  return path.join(base, '.claude', 'skills');
@@ -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>