@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.
- package/icons/hermes.svg +1 -0
- package/package.json +1 -1
- package/registry.json +1 -18
- package/src/adapters/base.js +3 -2
- package/src/adapters/claude.js +89 -8
- package/src/adapters/cursor.js +32 -5
- package/src/adapters/hermes.js +74 -7
- package/src/adapters/openclaw.js +19 -3
- package/src/adapters/opencode.js +104 -18
- package/src/cli.js +20 -3
- package/src/daemon.js +81 -5
- package/src/installer.js +1 -1
- package/src/paths.js +24 -0
- package/src/skill-installer.js +5 -1
- package/icons/swebench.svg +0 -1
package/icons/hermes.svg
ADDED
|
@@ -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
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": "
|
|
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": []
|
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
|
|
|
@@ -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
|
-
|
|
347
|
+
const hit = r.split(/\r?\n/)[0].trim();
|
|
348
|
+
if (hit) return hit;
|
|
340
349
|
} else {
|
|
341
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ||
|
|
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'))
|
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/openclaw.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
+
closeFd();
|
|
373
389
|
// Read full stderr content (contains JSON output + trace lines)
|
|
374
390
|
let stderrContent = '';
|
|
375
391
|
try {
|
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
|
|
|
@@ -123,25 +124,55 @@ class OpenCodeAdapter extends BaseAdapter {
|
|
|
123
124
|
// ------------------------------------------------------------------
|
|
124
125
|
|
|
125
126
|
_findOpencodeBinary() {
|
|
126
|
-
|
|
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.
|
|
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
|
-
|
|
163
|
+
const hit = r.split(/\r?\n/)[0].trim();
|
|
164
|
+
if (hit) return hit;
|
|
133
165
|
} else {
|
|
134
|
-
|
|
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
|
-
|
|
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.
|
|
353
|
-
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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 (
|
|
68
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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.
|
|
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,
|
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');
|
package/icons/swebench.svg
DELETED
|
@@ -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>
|