@openagents-org/agent-launcher 0.2.135 → 0.2.138
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 +1 -1
- package/registry.json +1 -1
- package/src/adapters/base.js +3 -2
- package/src/adapters/claude.js +25 -7
- package/src/adapters/cursor.js +32 -5
- package/src/adapters/hermes.js +74 -7
- package/src/adapters/opencode.js +89 -17
- package/src/daemon.js +6 -2
- package/src/paths.js +24 -0
- package/src/skill-installer.js +5 -1
package/package.json
CHANGED
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": "
|
|
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": []
|
package/src/adapters/base.js
CHANGED
|
@@ -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 ||
|
|
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 ||
|
|
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.
|
package/src/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
347
|
+
const hit = r.split(/\r?\n/)[0].trim();
|
|
348
|
+
if (hit) return hit;
|
|
342
349
|
} else {
|
|
343
|
-
|
|
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
|
-
|
|
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 ||
|
|
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'))
|
package/src/adapters/cursor.js
CHANGED
|
@@ -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
|
-
|
|
200
|
+
const hit = r.split(/\r?\n/)[0].trim();
|
|
201
|
+
if (hit) return hit;
|
|
195
202
|
} else {
|
|
196
|
-
|
|
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
|
-
|
|
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');
|
package/src/adapters/hermes.js
CHANGED
|
@@ -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
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/adapters/opencode.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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
|
-
|
|
163
|
+
const hit = r.split(/\r?\n/)[0].trim();
|
|
164
|
+
if (hit) return hit;
|
|
149
165
|
} else {
|
|
150
|
-
|
|
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
|
-
|
|
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.
|
|
367
|
-
|
|
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
|
|
371
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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,
|
package/src/skill-installer.js
CHANGED
|
@@ -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
|
-
|
|
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');
|