@openagents-org/agent-launcher 0.2.135 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.135",
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
@@ -616,7 +616,7 @@
616
616
  "requires": ["python3"],
617
617
  "macos": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
618
618
  "linux": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
619
- "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\""
620
620
  },
621
621
  "launch": {
622
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
 
@@ -332,15 +333,22 @@ class ClaudeAdapter extends BaseAdapter {
332
333
  const portableCandidate = path.join(portableBin, `claude${ext}`);
333
334
  if (fs.existsSync(portableCandidate)) return portableCandidate;
334
335
 
335
- // 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.
336
341
  try {
342
+ const env = getEnhancedEnv();
337
343
  if (IS_WINDOWS) {
338
344
  const r = execSync('where claude.cmd 2>nul || where claude.exe 2>nul || where claude 2>nul', {
339
- encoding: 'utf-8', timeout: 5000,
345
+ encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
340
346
  });
341
- return r.split(/\r?\n/)[0].trim();
347
+ const hit = r.split(/\r?\n/)[0].trim();
348
+ if (hit) return hit;
342
349
  } else {
343
- 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;
344
352
  }
345
353
  } catch {}
346
354
 
@@ -354,6 +362,7 @@ class ClaudeAdapter extends BaseAdapter {
354
362
  path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
355
363
  ] : [
356
364
  path.join(home, '.local', 'bin', 'claude'),
365
+ path.join(home, '.claude', 'local', 'claude'),
357
366
  path.join(home, '.npm-global', 'bin', 'claude'),
358
367
  '/opt/homebrew/bin/claude',
359
368
  '/usr/local/bin/claude',
@@ -362,6 +371,13 @@ class ClaudeAdapter extends BaseAdapter {
362
371
  if (fs.existsSync(c)) return c;
363
372
  }
364
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
+
365
381
  return null;
366
382
  }
367
383
 
@@ -427,8 +443,10 @@ class ClaudeAdapter extends BaseAdapter {
427
443
  cmd.push('--allowedTools', 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep');
428
444
  }
429
445
 
430
- // Write SKILL.md to .claude/skills/ in the working directory
431
- 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);
432
450
  const skillDir = path.join(workDir, '.claude', 'skills');
433
451
  fs.mkdirSync(skillDir, { recursive: true });
434
452
  const skillFile = path.join(skillDir, 'openagents-workspace.md');
@@ -1009,7 +1027,7 @@ class ClaudeAdapter extends BaseAdapter {
1009
1027
 
1010
1028
  if (this._mode === 'plan') {
1011
1029
  try {
1012
- const planDir = path.join(this.workingDir || process.cwd(), '.claude', 'plans');
1030
+ const planDir = path.join(this.workingDir || defaultAgentWorkdir(this.agentName), '.claude', 'plans');
1013
1031
  if (fs.existsSync(planDir)) {
1014
1032
  const planFiles = fs.readdirSync(planDir)
1015
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
 
@@ -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
 
@@ -126,7 +127,18 @@ class OpenCodeAdapter extends BaseAdapter {
126
127
  const home = os.homedir();
127
128
  const ext = IS_WINDOWS ? '.cmd' : '';
128
129
 
129
- // Tier 0: Isolated runtime prefix — where the launcher installs agents
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
130
142
  // (~/.openagents/runtimes/opencode/node_modules/.bin). Every other adapter
131
143
  // checks this first; opencode was the lone exception, so a launcher-managed
132
144
  // install was invisible unless its .bin happened to be on PATH — which is
@@ -139,15 +151,20 @@ class OpenCodeAdapter extends BaseAdapter {
139
151
  const legacyBin = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `opencode${ext}`);
140
152
  if (fs.existsSync(legacyBin)) return legacyBin;
141
153
 
142
- // Tier 1: PATH
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.
143
157
  try {
158
+ const env = getEnhancedEnv();
144
159
  if (IS_WINDOWS) {
145
- const r = execSync('where opencode.cmd 2>nul || where opencode.exe 2>nul || where opencode 2>nul', {
146
- 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,
147
162
  });
148
- return r.split(/\r?\n/)[0].trim();
163
+ const hit = r.split(/\r?\n/)[0].trim();
164
+ if (hit) return hit;
149
165
  } else {
150
- 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;
151
168
  }
152
169
  } catch {}
153
170
 
@@ -167,6 +184,13 @@ class OpenCodeAdapter extends BaseAdapter {
167
184
  for (const c of candidates) {
168
185
  if (fs.existsSync(c)) return c;
169
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
+
170
194
  return null;
171
195
  }
172
196
 
@@ -198,7 +222,12 @@ class OpenCodeAdapter extends BaseAdapter {
198
222
  if (responseText) {
199
223
  await this.sendResponse(msgChannel, responseText);
200
224
  } else {
201
- 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
+ );
202
231
  }
203
232
  } catch (e) {
204
233
  this._log(`Error handling message: ${e.message}`);
@@ -283,6 +312,23 @@ class OpenCodeAdapter extends BaseAdapter {
283
312
  return texts.length ? texts.join('\n').trim() : raw.trim();
284
313
  }
285
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
+
286
332
  /**
287
333
  * Extract and persist session_id from OpenCode JSON events.
288
334
  */
@@ -342,6 +388,8 @@ class OpenCodeAdapter extends BaseAdapter {
342
388
 
343
389
  let spawnBinary = cmd[0];
344
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.
345
393
  if (IS_WINDOWS && spawnBinary.toLowerCase().endsWith('.cmd')) {
346
394
  spawnArgs = ['/C', spawnBinary, ...spawnArgs];
347
395
  spawnBinary = process.env.COMSPEC || 'cmd.exe';
@@ -353,38 +401,62 @@ class OpenCodeAdapter extends BaseAdapter {
353
401
  env: spawnEnv,
354
402
  cwd: this.agentHome,
355
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,
356
408
  });
357
409
 
358
410
  let stdout = '';
359
411
  let stderr = '';
412
+ let settled = false;
413
+ const finish = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
360
414
 
361
415
  if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d; });
362
416
  if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; });
363
417
 
364
- // 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.
365
421
  if (proc.stdin) {
366
- proc.stdin.write(fullPrompt, 'utf-8');
367
- 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 */ }
368
427
  }
369
428
 
370
- proc.on('error', (err) => reject(err));
371
- 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) => {
372
433
  stdout = stdout.trim();
373
434
  stderr = stderr.trim();
374
435
 
375
436
  if (code !== 0) {
376
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
+ ));
377
443
  }
378
444
 
379
445
  if (stdout) {
380
446
  this._persistSessionId(msgChannel, stdout);
381
- resolve(OpenCodeAdapter._extractTextFromJson(stdout));
382
- } else {
383
- if (stderr) {
384
- 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, '');
385
454
  }
386
- resolve('');
455
+ return finish(resolve, text);
387
456
  }
457
+
458
+ if (stderr) this._log(`opencode stderr: ${stderr.slice(0, 300)}`);
459
+ finish(resolve, '');
388
460
  });
389
461
  });
390
462
  }
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.
@@ -477,7 +477,11 @@ class Daemon {
477
477
  openclawAgentId: agentCfg.openclaw_agent_id || 'main',
478
478
  disabledModules: skillsToDisabledModules(agentCfg.skills),
479
479
  agentEnv: this._buildAgentEnv(agentCfg),
480
- 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),
481
485
  toolMode: agentCfg.tool_mode || 'skills',
482
486
  });
483
487
  } catch (e) {
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');