@openagents-org/agent-launcher 0.2.133 → 0.2.135
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/icons/hermes.svg +1 -0
- package/package.json +1 -1
- package/registry.json +0 -17
- package/src/adapters/claude.js +64 -1
- package/src/adapters/cursor.js +6 -0
- package/src/adapters/gemini.js +6 -0
- package/src/adapters/openclaw.js +19 -3
- package/src/adapters/opencode.js +16 -2
- package/src/agent-rows.js +73 -0
- package/src/cli.js +29 -10
- package/src/daemon.js +75 -3
- package/src/installer.js +120 -26
- package/src/paths.js +16 -2
- package/src/tui.js +7 -48
- package/src/update-check.js +58 -14
- 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",
|
package/src/adapters/claude.js
CHANGED
|
@@ -38,6 +38,8 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
38
38
|
this._stoppingChannels = new Set();
|
|
39
39
|
this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
|
|
40
40
|
this._IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
41
|
+
this._WATCHDOG_INTERVAL_MS = 15_000; // 15s between checks
|
|
42
|
+
this._WATCHDOG_MAX_TIMEOUTS = 20; // 20 * 15s = 5 min of silence → kill
|
|
41
43
|
this._sessionsFile = path.join(
|
|
42
44
|
os.homedir(), '.openagents', 'sessions',
|
|
43
45
|
`${this.workspaceId}_${this.agentName}.json`
|
|
@@ -579,6 +581,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
579
581
|
const pp = this._persistentProcs[channel];
|
|
580
582
|
if (!pp) return;
|
|
581
583
|
if (pp.idleTimer) clearTimeout(pp.idleTimer);
|
|
584
|
+
this._stopWatchdog(pp);
|
|
582
585
|
this._stopProcess(pp.proc).catch(() => {});
|
|
583
586
|
delete this._persistentProcs[channel];
|
|
584
587
|
}
|
|
@@ -597,6 +600,49 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
597
600
|
}, this._IDLE_TIMEOUT_MS);
|
|
598
601
|
}
|
|
599
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Start a watchdog that kills the persistent process if stdout goes
|
|
605
|
+
* silent for too long while a message is in flight. Pauses during
|
|
606
|
+
* tool execution (awaitingToolResult) since long-running commands
|
|
607
|
+
* like builds or sleep produce no stdout legitimately.
|
|
608
|
+
*/
|
|
609
|
+
_startWatchdog(pp) {
|
|
610
|
+
this._stopWatchdog(pp);
|
|
611
|
+
pp.lastStdoutTime = Date.now();
|
|
612
|
+
let consecutiveTimeouts = 0;
|
|
613
|
+
|
|
614
|
+
pp.watchdogTimer = setInterval(async () => {
|
|
615
|
+
if (!pp.messageResolve) { consecutiveTimeouts = 0; return; }
|
|
616
|
+
if (pp.awaitingToolResult) { consecutiveTimeouts = 0; return; }
|
|
617
|
+
|
|
618
|
+
const elapsed = Date.now() - pp.lastStdoutTime;
|
|
619
|
+
if (elapsed < this._WATCHDOG_INTERVAL_MS) { consecutiveTimeouts = 0; return; }
|
|
620
|
+
|
|
621
|
+
consecutiveTimeouts++;
|
|
622
|
+
pp.lastStdoutTime = Date.now();
|
|
623
|
+
|
|
624
|
+
if (consecutiveTimeouts === 2) {
|
|
625
|
+
try { await this.sendStatus(pp.msgChannel, 'Still processing...'); } catch {}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (consecutiveTimeouts >= this._WATCHDOG_MAX_TIMEOUTS) {
|
|
629
|
+
this._log(`Watchdog: process unresponsive for ${consecutiveTimeouts * 15}s on ${pp.msgChannel} — killing`);
|
|
630
|
+
this._stopWatchdog(pp);
|
|
631
|
+
try { await this.sendError(pp.msgChannel, 'Agent process became unresponsive and was restarted.'); } catch {}
|
|
632
|
+
if (pp.messageResolve) {
|
|
633
|
+
const resolve = pp.messageResolve;
|
|
634
|
+
pp.messageResolve = null;
|
|
635
|
+
resolve({ exited: true, error: new Error('watchdog timeout') });
|
|
636
|
+
}
|
|
637
|
+
this._killPersistentProc(pp.msgChannel);
|
|
638
|
+
}
|
|
639
|
+
}, this._WATCHDOG_INTERVAL_MS);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
_stopWatchdog(pp) {
|
|
643
|
+
if (pp.watchdogTimer) { clearInterval(pp.watchdogTimer); pp.watchdogTimer = null; }
|
|
644
|
+
}
|
|
645
|
+
|
|
600
646
|
/**
|
|
601
647
|
* Spawn a persistent Claude process for a channel that accepts messages
|
|
602
648
|
* via stdin (--input-format stream-json). Returns the persistent proc entry.
|
|
@@ -651,6 +697,9 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
651
697
|
everPostedAnything: false,
|
|
652
698
|
stderrBuf: '',
|
|
653
699
|
alive: true,
|
|
700
|
+
lastStdoutTime: Date.now(),
|
|
701
|
+
watchdogTimer: null,
|
|
702
|
+
awaitingToolResult: false,
|
|
654
703
|
};
|
|
655
704
|
|
|
656
705
|
if (proc.stderr) {
|
|
@@ -665,6 +714,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
665
714
|
const eventType = event.type;
|
|
666
715
|
|
|
667
716
|
if (eventType === 'assistant') {
|
|
717
|
+
pp.awaitingToolResult = false;
|
|
668
718
|
const blocks = (event.message || {}).content || [];
|
|
669
719
|
for (const block of blocks) {
|
|
670
720
|
if (block.type === 'text' && block.text && block.text.trim()) {
|
|
@@ -680,6 +730,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
680
730
|
pp.hasToolUseSinceLastText = true;
|
|
681
731
|
pp.postedThinking = false;
|
|
682
732
|
pp.lastResponseText.length = 0;
|
|
733
|
+
pp.awaitingToolResult = true;
|
|
683
734
|
const toolName = block.name || '';
|
|
684
735
|
if (toolName === 'TodoWrite' && block.input && block.input.todos) {
|
|
685
736
|
try {
|
|
@@ -707,6 +758,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
707
758
|
}
|
|
708
759
|
}
|
|
709
760
|
} else if (eventType === 'result') {
|
|
761
|
+
pp.awaitingToolResult = false;
|
|
710
762
|
const sessionId = event.session_id;
|
|
711
763
|
if (sessionId) {
|
|
712
764
|
this._channelSessions[pp.msgChannel] = sessionId;
|
|
@@ -732,6 +784,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
732
784
|
|
|
733
785
|
proc.stdout.on('data', (chunk) => {
|
|
734
786
|
pp.lineBuffer += chunk.toString('utf-8');
|
|
787
|
+
pp.lastStdoutTime = Date.now();
|
|
735
788
|
const lines = pp.lineBuffer.split('\n');
|
|
736
789
|
pp.lineBuffer = lines.pop();
|
|
737
790
|
for (const line of lines) {
|
|
@@ -743,6 +796,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
743
796
|
this._log(`Persistent process exited: channel=${channel} code=${code}`);
|
|
744
797
|
pp.alive = false;
|
|
745
798
|
if (pp.idleTimer) clearTimeout(pp.idleTimer);
|
|
799
|
+
this._stopWatchdog(pp);
|
|
746
800
|
if (pp.messageResolve) {
|
|
747
801
|
pp.messageResolve({ exited: true, code });
|
|
748
802
|
pp.messageResolve = null;
|
|
@@ -754,6 +808,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
754
808
|
proc.on('error', (err) => {
|
|
755
809
|
this._log(`Persistent process error: ${err.message}`);
|
|
756
810
|
pp.alive = false;
|
|
811
|
+
this._stopWatchdog(pp);
|
|
757
812
|
if (pp.messageResolve) {
|
|
758
813
|
pp.messageResolve({ exited: true, error: err });
|
|
759
814
|
pp.messageResolve = null;
|
|
@@ -777,6 +832,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
777
832
|
pp.hasToolUseSinceLastText = false;
|
|
778
833
|
pp.postedThinking = false;
|
|
779
834
|
pp.everPostedAnything = false;
|
|
835
|
+
pp.awaitingToolResult = false;
|
|
780
836
|
|
|
781
837
|
const stdinMsg = JSON.stringify({
|
|
782
838
|
type: 'user',
|
|
@@ -787,11 +843,18 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
787
843
|
}) + '\n';
|
|
788
844
|
|
|
789
845
|
return new Promise((resolve) => {
|
|
790
|
-
pp.messageResolve =
|
|
846
|
+
pp.messageResolve = (result) => {
|
|
847
|
+
this._stopWatchdog(pp);
|
|
848
|
+
resolve(result);
|
|
849
|
+
};
|
|
850
|
+
pp.lastStdoutTime = Date.now();
|
|
851
|
+
this._startWatchdog(pp);
|
|
791
852
|
try {
|
|
792
853
|
pp.proc.stdin.write(stdinMsg);
|
|
793
854
|
} catch (e) {
|
|
794
855
|
this._log(`stdin write failed: ${e.message}`);
|
|
856
|
+
this._stopWatchdog(pp);
|
|
857
|
+
pp.messageResolve = null;
|
|
795
858
|
resolve({ exited: true, error: e });
|
|
796
859
|
}
|
|
797
860
|
});
|
package/src/adapters/cursor.js
CHANGED
|
@@ -205,7 +205,13 @@ class CursorAdapter extends BaseAdapter {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
// Tier 3: Common install locations
|
|
208
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
208
209
|
const candidates = IS_WINDOWS ? [
|
|
210
|
+
// Windows native installer (install?win32=true) drops the CLI here.
|
|
211
|
+
path.join(localAppData, 'cursor-agent', 'cursor-agent.cmd'),
|
|
212
|
+
path.join(localAppData, 'cursor-agent', 'cursor-agent.exe'),
|
|
213
|
+
path.join(localAppData, 'cursor-agent', 'agent.cmd'),
|
|
214
|
+
path.join(localAppData, 'cursor-agent', 'agent.exe'),
|
|
209
215
|
path.join(home, '.cursor', 'bin', 'cursor-agent.cmd'),
|
|
210
216
|
path.join(home, '.cursor', 'bin', 'cursor-agent.exe'),
|
|
211
217
|
path.join(home, '.cursor', 'bin', 'cursor-agent'),
|
package/src/adapters/gemini.js
CHANGED
|
@@ -195,6 +195,12 @@ class GeminiAdapter extends BaseAdapter {
|
|
|
195
195
|
|
|
196
196
|
const cmd = [geminiBin, '-p', fullPrompt, '-y', '-o', 'stream-json'];
|
|
197
197
|
|
|
198
|
+
// Honor a user-configured model (e.g. when pointing at a relay/proxy whose
|
|
199
|
+
// channels don't match the CLI default). Set via GEMINI_MODEL in env_config.
|
|
200
|
+
const env = this.agentEnv || process.env;
|
|
201
|
+
const model = (env.GEMINI_MODEL || env.GOOGLE_GEMINI_MODEL || '').trim();
|
|
202
|
+
if (model) cmd.push('-m', model);
|
|
203
|
+
|
|
198
204
|
const sessionId = this._channelSessions[channelName];
|
|
199
205
|
if (sessionId && !skipResume) {
|
|
200
206
|
cmd.push('-r', sessionId);
|
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
|
@@ -123,6 +123,22 @@ class OpenCodeAdapter extends BaseAdapter {
|
|
|
123
123
|
// ------------------------------------------------------------------
|
|
124
124
|
|
|
125
125
|
_findOpencodeBinary() {
|
|
126
|
+
const home = os.homedir();
|
|
127
|
+
const ext = IS_WINDOWS ? '.cmd' : '';
|
|
128
|
+
|
|
129
|
+
// Tier 0: Isolated runtime prefix — where the launcher installs agents
|
|
130
|
+
// (~/.openagents/runtimes/opencode/node_modules/.bin). Every other adapter
|
|
131
|
+
// checks this first; opencode was the lone exception, so a launcher-managed
|
|
132
|
+
// install was invisible unless its .bin happened to be on PATH — which is
|
|
133
|
+
// exactly why the workspace failed with "opencode CLI not found" even though
|
|
134
|
+
// the marketplace showed it installed.
|
|
135
|
+
const runtimeBin = path.join(home, '.openagents', 'runtimes', 'opencode', 'node_modules', '.bin', `opencode${ext}`);
|
|
136
|
+
if (fs.existsSync(runtimeBin)) return runtimeBin;
|
|
137
|
+
|
|
138
|
+
// Tier 0b: Legacy shared portable prefix.
|
|
139
|
+
const legacyBin = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `opencode${ext}`);
|
|
140
|
+
if (fs.existsSync(legacyBin)) return legacyBin;
|
|
141
|
+
|
|
126
142
|
// Tier 1: PATH
|
|
127
143
|
try {
|
|
128
144
|
if (IS_WINDOWS) {
|
|
@@ -136,12 +152,10 @@ class OpenCodeAdapter extends BaseAdapter {
|
|
|
136
152
|
} catch {}
|
|
137
153
|
|
|
138
154
|
// Tier 2: Next to Node.js
|
|
139
|
-
const ext = IS_WINDOWS ? '.cmd' : '';
|
|
140
155
|
const nearNode = path.join(path.dirname(process.execPath), `opencode${ext}`);
|
|
141
156
|
if (fs.existsSync(nearNode)) return nearNode;
|
|
142
157
|
|
|
143
158
|
// Tier 3: Common locations
|
|
144
|
-
const home = os.homedir();
|
|
145
159
|
const candidates = IS_WINDOWS ? [
|
|
146
160
|
path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
|
|
147
161
|
] : [
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure data helpers for the agent list, shared by the TUI (tui.js) and tests.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of any UI/blessed dependency so the list-rendering logic — in
|
|
5
|
+
* particular the "(local)" labelling and Connect gating — can be unit-tested.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build one display row per configured agent from a connector.
|
|
12
|
+
*
|
|
13
|
+
* `workspace` holds the resolved workspace URL (or '' when the agent is not
|
|
14
|
+
* connected). `workspaceLabel` is what the WORKSPACE column shows: the URL when
|
|
15
|
+
* connected, otherwise "(local)" — matching `agn list` so local-only agents are
|
|
16
|
+
* always visible rather than rendered as a blank cell.
|
|
17
|
+
*/
|
|
18
|
+
function loadAgentRows(connector) {
|
|
19
|
+
const config = connector.config.load();
|
|
20
|
+
const agents = config.agents || [];
|
|
21
|
+
const agentStatuses = connector.getDaemonStatus() || {};
|
|
22
|
+
const pid = connector.getDaemonPid();
|
|
23
|
+
const networks = config.networks || [];
|
|
24
|
+
return agents.map(agent => {
|
|
25
|
+
const info = agentStatuses[agent.name] || {};
|
|
26
|
+
const state = pid ? (info.state || 'stopped') : 'stopped';
|
|
27
|
+
let workspace = '';
|
|
28
|
+
if (agent.network) {
|
|
29
|
+
const net = networks.find(n => n.slug === agent.network || n.id === agent.network);
|
|
30
|
+
if (net) {
|
|
31
|
+
const slug = net.slug || net.id;
|
|
32
|
+
const isLocal = (net.endpoint || '').includes('localhost') || (net.endpoint || '').includes('127.0.0.1');
|
|
33
|
+
if (isLocal) workspace = `${net.endpoint}/${slug}`;
|
|
34
|
+
else workspace = `workspace.openagents.org/${slug}`;
|
|
35
|
+
} else {
|
|
36
|
+
workspace = agent.network;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let notReadyMsg = '';
|
|
40
|
+
let health = null;
|
|
41
|
+
try {
|
|
42
|
+
health = connector.healthCheck(agent.type || 'openclaw');
|
|
43
|
+
if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
|
|
44
|
+
} catch {}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
name: agent.name,
|
|
48
|
+
type: agent.type || 'openclaw',
|
|
49
|
+
state,
|
|
50
|
+
workspace,
|
|
51
|
+
// "(local)" when there's no workspace connection; URL otherwise. `workspace`
|
|
52
|
+
// stays empty for local-only agents so Connect gating still treats them as
|
|
53
|
+
// connectable (see connectAvailable).
|
|
54
|
+
workspaceLabel: workspace || '(local)',
|
|
55
|
+
path: agent.path || '',
|
|
56
|
+
network: agent.network || '',
|
|
57
|
+
lastError: info.last_error || '',
|
|
58
|
+
notReadyMsg,
|
|
59
|
+
health,
|
|
60
|
+
configured: true,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* An agent can be connected to a workspace when it's configured and not already
|
|
67
|
+
* bound to one. Mirrors the footer/menu gating so it can be unit-tested.
|
|
68
|
+
*/
|
|
69
|
+
function connectAvailable(row) {
|
|
70
|
+
return !!(row && row.configured && !row.workspace);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { loadAgentRows, connectAvailable };
|
package/src/cli.js
CHANGED
|
@@ -64,9 +64,18 @@ function table(rows, headers) {
|
|
|
64
64
|
|
|
65
65
|
async function cmdUp(connector, flags) {
|
|
66
66
|
if (flags.foreground) {
|
|
67
|
-
// Run in foreground (
|
|
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();
|
|
@@ -123,7 +132,6 @@ async function cmdCreate(connector, flags, positional) {
|
|
|
123
132
|
|
|
124
133
|
try {
|
|
125
134
|
connector.addAgent({ name, type, role, path: flags.path || process.cwd() });
|
|
126
|
-
print(`Agent '${name}' created (type: ${type})`);
|
|
127
135
|
|
|
128
136
|
// Signal daemon to pick up the new agent
|
|
129
137
|
try { connector.sendDaemonCommand('reload'); } catch {}
|
|
@@ -132,10 +140,14 @@ async function cmdCreate(connector, flags, positional) {
|
|
|
132
140
|
// Without a workspace connection they will not appear in the Workspace Dashboard.
|
|
133
141
|
const created = connector.config.getAgent(name);
|
|
134
142
|
if (created && !created.network) {
|
|
143
|
+
print(`Created local agent: ${name} (type: ${type})`);
|
|
135
144
|
print('');
|
|
136
|
-
print(
|
|
137
|
-
print(
|
|
145
|
+
print('This agent is local-only and will not appear in Workspace Dashboard yet.');
|
|
146
|
+
print('');
|
|
147
|
+
print('To connect it to a Workspace, run:');
|
|
138
148
|
print(` agn connect ${name} <workspace-token>`);
|
|
149
|
+
} else {
|
|
150
|
+
print(`Agent '${name}' created (type: ${type})`);
|
|
139
151
|
}
|
|
140
152
|
|
|
141
153
|
if (!connector.isInstalled(type)) {
|
|
@@ -169,7 +181,15 @@ async function cmdRemove(connector, _flags, positional) {
|
|
|
169
181
|
async function cmdStart(connector, _flags, positional) {
|
|
170
182
|
const name = positional[0];
|
|
171
183
|
if (!name) { print('Usage: agn start <name>'); return; }
|
|
172
|
-
|
|
184
|
+
// `start` must be idempotent — it ensures the agent is running, it does NOT
|
|
185
|
+
// forcibly restart one that already is. Send `start:` (which the daemon
|
|
186
|
+
// guards: relaunch only when not already running) rather than `restart:`.
|
|
187
|
+
// A blind `restart:` tears down the workspace session the daemon already
|
|
188
|
+
// joined on launch and re-joins as the same agent; the server revokes the
|
|
189
|
+
// first session, so the agent stops the moment it next touches the workspace
|
|
190
|
+
// (first user message → "thinking..." then silence). The launcher already
|
|
191
|
+
// sends `start:`; this aligns the CLI with it.
|
|
192
|
+
connector.sendDaemonCommand(`start:${name}`);
|
|
173
193
|
print(`Sent start command for '${name}'`);
|
|
174
194
|
}
|
|
175
195
|
|
|
@@ -306,11 +326,10 @@ async function cmdConnect(connector, flags, positional) {
|
|
|
306
326
|
// No token supplied and none in the environment. Never prompt — keep
|
|
307
327
|
// CI / non-interactive environments from hanging. Print a helpful error
|
|
308
328
|
// explaining why the agent stays invisible and how to fix it.
|
|
309
|
-
print(
|
|
310
|
-
print(
|
|
311
|
-
print('
|
|
329
|
+
print('Workspace token is required.');
|
|
330
|
+
print('Local-only agents do not appear in Workspace Dashboard until connected.');
|
|
331
|
+
print('Run:');
|
|
312
332
|
print(` agn connect ${name} <workspace-token>`);
|
|
313
|
-
print(` OPENAGENTS_WORKSPACE_TOKEN=<workspace-token> agn connect ${name}`);
|
|
314
333
|
process.exitCode = 1;
|
|
315
334
|
return;
|
|
316
335
|
}
|
package/src/daemon.js
CHANGED
|
@@ -55,6 +55,25 @@ class Daemon {
|
|
|
55
55
|
try { process.on('SIGHUP', () => this._reload()); } catch {}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Crash guards. A bug in one adapter — a rejected fire-and-forget promise,
|
|
59
|
+
// an EBADF from a double fs.closeSync in a child-process callback, a throw
|
|
60
|
+
// inside a stream/'exit' handler — must NEVER take down the whole daemon
|
|
61
|
+
// and every other agent with it. Node ≥15 terminates the process on an
|
|
62
|
+
// unhandled rejection by default, so without these the daemon silently
|
|
63
|
+
// dies and the launcher shows "Daemon stopped". Log loudly, keep
|
|
64
|
+
// supervising; per-agent failures are already isolated in _adapterLoop.
|
|
65
|
+
if (!this._crashGuardsInstalled) {
|
|
66
|
+
this._crashGuardsInstalled = true;
|
|
67
|
+
process.on('unhandledRejection', (reason) => {
|
|
68
|
+
const msg = reason && reason.stack ? reason.stack : String(reason);
|
|
69
|
+
this._log(`UNHANDLED REJECTION (daemon kept alive): ${msg}`);
|
|
70
|
+
});
|
|
71
|
+
process.on('uncaughtException', (err) => {
|
|
72
|
+
const msg = err && err.stack ? err.stack : String(err);
|
|
73
|
+
this._log(`UNCAUGHT EXCEPTION (daemon kept alive): ${msg}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
// Write PID file
|
|
59
78
|
this._writePid();
|
|
60
79
|
|
|
@@ -444,7 +463,12 @@ class Daemon {
|
|
|
444
463
|
let adapter;
|
|
445
464
|
try {
|
|
446
465
|
adapter = createAdapter(agentType, {
|
|
447
|
-
|
|
466
|
+
// Networks created via the launcher can be persisted with id: null
|
|
467
|
+
// (the workspace service returns only a slug). The server identifies
|
|
468
|
+
// a workspace by its slug — the same value the web UI uses in its URL —
|
|
469
|
+
// so fall back to it. Joining with a null id makes every poll/heartbeat
|
|
470
|
+
// fail "Network not found", which spins the adapter in an error loop.
|
|
471
|
+
workspaceId: network.id || network.slug,
|
|
448
472
|
channelName: 'general',
|
|
449
473
|
token: network.token,
|
|
450
474
|
agentName: name,
|
|
@@ -657,8 +681,24 @@ class Daemon {
|
|
|
657
681
|
this.stopAgent(agentName);
|
|
658
682
|
} else if (cmd.startsWith('start:')) {
|
|
659
683
|
const agentName = cmd.slice(6).trim();
|
|
660
|
-
|
|
661
|
-
|
|
684
|
+
// 'start' must be idempotent. The launcher sends start:<name> right
|
|
685
|
+
// after (re)spawning the daemon, but the daemon's own start() already
|
|
686
|
+
// launched every configured agent. A blind restart here tears down
|
|
687
|
+
// the just-joined workspace session and re-joins as the same agent;
|
|
688
|
+
// the server revokes the first session and the agent then stops the
|
|
689
|
+
// moment it next touches the workspace (e.g. the first user message →
|
|
690
|
+
// "thinking..." status). Only (re)launch when it isn't running.
|
|
691
|
+
const running =
|
|
692
|
+
(this._adapters && this._adapters[agentName]) ||
|
|
693
|
+
['running', 'starting'].includes(
|
|
694
|
+
this._processes[agentName] && this._processes[agentName].state
|
|
695
|
+
);
|
|
696
|
+
if (running) {
|
|
697
|
+
this._log(`Command: start ${agentName} — already running, skipping`);
|
|
698
|
+
} else {
|
|
699
|
+
this._log(`Command: start ${agentName}`);
|
|
700
|
+
this.restartAgent(agentName);
|
|
701
|
+
}
|
|
662
702
|
} else if (cmd.startsWith('restart:')) {
|
|
663
703
|
const agentName = cmd.slice(8).trim();
|
|
664
704
|
this._log(`Command: restart ${agentName}`);
|
|
@@ -831,6 +871,38 @@ class Daemon {
|
|
|
831
871
|
return false;
|
|
832
872
|
}
|
|
833
873
|
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Return the PID of another live daemon for this configDir, or null.
|
|
877
|
+
*
|
|
878
|
+
* Used to enforce the singleton even on the `up --foreground` path, which the
|
|
879
|
+
* launcher spawns directly (bypassing daemonize's guard). Trusts BOTH the pid
|
|
880
|
+
* file and the status file (a live daemon rewrites the latter every 5s with
|
|
881
|
+
* its own pid), because the pid file gets emptied/clobbered under races. Uses
|
|
882
|
+
* real process-liveness — not just file age — so a daemon that was just
|
|
883
|
+
* stopped for a legitimate restart doesn't block the replacement.
|
|
884
|
+
*/
|
|
885
|
+
static runningDaemonPid(configDir) {
|
|
886
|
+
const self = process.pid;
|
|
887
|
+
const isOtherAlive = (pid) =>
|
|
888
|
+
pid && pid !== self && Daemon._isAlive(pid);
|
|
889
|
+
|
|
890
|
+
const pidFromFile = Daemon._readPid(path.join(configDir, 'daemon.pid'));
|
|
891
|
+
if (isOtherAlive(pidFromFile)) return pidFromFile;
|
|
892
|
+
|
|
893
|
+
try {
|
|
894
|
+
const statusFile = path.join(configDir, 'daemon.status.json');
|
|
895
|
+
const age = Date.now() - fs.statSync(statusFile).mtimeMs;
|
|
896
|
+
// Bound the age so a long-dead daemon whose pid got reused by an
|
|
897
|
+
// unrelated process can't masquerade as a live daemon.
|
|
898
|
+
if (age < 30000) {
|
|
899
|
+
const raw = JSON.parse(fs.readFileSync(statusFile, 'utf-8'));
|
|
900
|
+
const pid = raw && raw.pid;
|
|
901
|
+
if (isOtherAlive(pid)) return pid;
|
|
902
|
+
}
|
|
903
|
+
} catch {}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
834
906
|
}
|
|
835
907
|
|
|
836
908
|
module.exports = { Daemon };
|
package/src/installer.js
CHANGED
|
@@ -414,31 +414,72 @@ class Installer {
|
|
|
414
414
|
await this.installNodejs(onData);
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
-
|
|
418
|
-
|
|
417
|
+
const env = this._buildShellEnv();
|
|
418
|
+
const isWin = process.platform === 'win32';
|
|
419
|
+
|
|
420
|
+
// Build the spawn invocation. Three shapes:
|
|
421
|
+
// 1. npm install via bundled `node npm-cli.js` — argv array, no shell.
|
|
422
|
+
// Path-safe under non-ASCII home dirs on Windows (preferred).
|
|
423
|
+
// 2. npm install via legacy shell string (npm.cmd / npm) — fallback only
|
|
424
|
+
// when no node + npm-cli.js layout is found; OEM-codepage corruption
|
|
425
|
+
// means this breaks on non-ASCII paths, but still works for ASCII.
|
|
426
|
+
// 3. pip / curl / powershell — keep the legacy shell-string behaviour.
|
|
427
|
+
let spawnFile;
|
|
428
|
+
let spawnArgs = [];
|
|
429
|
+
let useShell;
|
|
430
|
+
let installCwd;
|
|
431
|
+
let displayCmd;
|
|
432
|
+
|
|
419
433
|
if (rawCmd.startsWith('npm install')) {
|
|
420
434
|
const prefixDir = getRuntimePrefix(agentType);
|
|
421
435
|
fs.mkdirSync(prefixDir, { recursive: true });
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
436
|
+
installCwd = prefixDir;
|
|
437
|
+
|
|
438
|
+
// `npm install [-g] <pkg…>` → install --save --prefix <dir> <pkg…>
|
|
439
|
+
const pkgArgs = rawCmd
|
|
440
|
+
.replace(/^npm install\s*/, '')
|
|
441
|
+
.replace(/(^|\s)-g(\s|$)/, ' ')
|
|
442
|
+
.trim()
|
|
443
|
+
.split(/\s+/)
|
|
444
|
+
.filter(Boolean);
|
|
445
|
+
// Use --save so npm tracks the package in package.json (prevents pruning
|
|
446
|
+
// on next install).
|
|
447
|
+
const npmArgs = ['install', '--loglevel=verbose', '--save', '--prefix', prefixDir, ...pkgArgs];
|
|
448
|
+
displayCmd = `npm ${npmArgs.join(' ')}`;
|
|
449
|
+
|
|
450
|
+
const direct = this._resolveNodeNpmCli();
|
|
451
|
+
if (direct) {
|
|
452
|
+
spawnFile = direct.node;
|
|
453
|
+
spawnArgs = [direct.npmCli, ...npmArgs];
|
|
454
|
+
useShell = false;
|
|
455
|
+
// Ensure npm's own `node` child lookups (postinstall scripts) resolve
|
|
456
|
+
// to this same runtime by putting its dir first on PATH.
|
|
457
|
+
const pathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || 'PATH';
|
|
458
|
+
const nodeDir = path.dirname(direct.node);
|
|
459
|
+
if (!(env[pathKey] || '').includes(nodeDir)) {
|
|
460
|
+
env[pathKey] = nodeDir + (isWin ? ';' : ':') + (env[pathKey] || '');
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
// Legacy fallback: quoted prefix inside a shell string.
|
|
464
|
+
const args = `install --loglevel=verbose --save --prefix "${prefixDir}" ${pkgArgs.join(' ')}`;
|
|
465
|
+
spawnFile = this._wrapForWindowsShell(this._resolveNpmCommand(args));
|
|
466
|
+
useShell = isWin ? (env.ComSpec || 'C:\\Windows\\System32\\cmd.exe') : true;
|
|
467
|
+
displayCmd = spawnFile;
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
// pip / curl / powershell — unchanged shell-string behaviour.
|
|
471
|
+
spawnFile = this._wrapForWindowsShell(rawCmd);
|
|
472
|
+
useShell = isWin ? (env.ComSpec || 'C:\\Windows\\System32\\cmd.exe') : true;
|
|
473
|
+
// Set cwd outside System32 on Windows.
|
|
474
|
+
installCwd = os.homedir();
|
|
475
|
+
displayCmd = spawnFile;
|
|
427
476
|
}
|
|
428
477
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (onData) onData(`$ ${cmd}\n\n`);
|
|
432
|
-
|
|
433
|
-
const env = this._buildShellEnv();
|
|
434
|
-
const shell = process.platform === 'win32'
|
|
435
|
-
? (process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe')
|
|
436
|
-
: true;
|
|
437
|
-
// Set cwd to runtime prefix dir (avoids running from System32 on Windows)
|
|
438
|
-
const installCwd = rawCmd.startsWith('npm install') ? getRuntimePrefix(agentType) : os.homedir();
|
|
478
|
+
if (onData) onData(`$ ${displayCmd}\n\n`);
|
|
439
479
|
|
|
440
480
|
return new Promise((resolve, reject) => {
|
|
441
|
-
|
|
481
|
+
// windowsHide stops a console window from flashing up on every install.
|
|
482
|
+
const proc = spawn(spawnFile, spawnArgs, { shell: useShell, env, cwd: installCwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
442
483
|
|
|
443
484
|
if (proc.stdout) proc.stdout.setEncoding('utf-8');
|
|
444
485
|
if (proc.stderr) proc.stderr.setEncoding('utf-8');
|
|
@@ -458,7 +499,7 @@ class Installer {
|
|
|
458
499
|
if (code === 0) {
|
|
459
500
|
this._markInstalled(agentType);
|
|
460
501
|
if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
|
|
461
|
-
resolve({ success: true, command:
|
|
502
|
+
resolve({ success: true, command: displayCmd });
|
|
462
503
|
} else {
|
|
463
504
|
// A partial install can leave a placeholder stub at
|
|
464
505
|
// bin/<binary>.exe and npm-generated cmd-shims under
|
|
@@ -473,8 +514,8 @@ class Installer {
|
|
|
473
514
|
} catch {}
|
|
474
515
|
const tail = outputTail.trim();
|
|
475
516
|
const msg = tail
|
|
476
|
-
? `Install failed with exit code ${code}\nCommand: ${
|
|
477
|
-
: `Install failed with exit code ${code}\nCommand: ${
|
|
517
|
+
? `Install failed with exit code ${code}\nCommand: ${displayCmd}\n\n${tail}`
|
|
518
|
+
: `Install failed with exit code ${code}\nCommand: ${displayCmd}`;
|
|
478
519
|
if (onData) onData(`\n${msg}\n`);
|
|
479
520
|
reject(new Error(msg));
|
|
480
521
|
}
|
|
@@ -599,7 +640,7 @@ class Installer {
|
|
|
599
640
|
: true;
|
|
600
641
|
|
|
601
642
|
return new Promise((resolve, reject) => {
|
|
602
|
-
const proc = spawn(cmd, [], { shell, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
643
|
+
const proc = spawn(cmd, [], { shell, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
603
644
|
|
|
604
645
|
if (proc.stdout) proc.stdout.setEncoding('utf-8');
|
|
605
646
|
if (proc.stderr) proc.stderr.setEncoding('utf-8');
|
|
@@ -811,9 +852,14 @@ class Installer {
|
|
|
811
852
|
} catch {}
|
|
812
853
|
}
|
|
813
854
|
}
|
|
855
|
+
// On Windows the spread above yields a "Path" key, not "PATH". Writing
|
|
856
|
+
// `env.PATH` would create a duplicate key holding only extraDirs and drop
|
|
857
|
+
// everything else; libuv then resolves spawned binaries against the wrong,
|
|
858
|
+
// truncated value. Update the existing case-insensitive key in place.
|
|
859
|
+
const pathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || 'PATH';
|
|
814
860
|
for (const d of extraDirs) {
|
|
815
|
-
if (d && !(env
|
|
816
|
-
env
|
|
861
|
+
if (d && !(env[pathKey] || '').includes(d)) {
|
|
862
|
+
env[pathKey] = d + sep + (env[pathKey] || '');
|
|
817
863
|
}
|
|
818
864
|
}
|
|
819
865
|
return env;
|
|
@@ -895,7 +941,7 @@ class Installer {
|
|
|
895
941
|
const { spawn: spawnProc } = require('child_process');
|
|
896
942
|
const https = require('https');
|
|
897
943
|
const os = require('os');
|
|
898
|
-
const nodeVersion = 'v22.
|
|
944
|
+
const nodeVersion = 'v22.22.3';
|
|
899
945
|
const plat = Installer.platform();
|
|
900
946
|
|
|
901
947
|
if (onData) onData(`Node.js not found. Installing Node.js ${nodeVersion}...\n\n`);
|
|
@@ -918,7 +964,7 @@ class Installer {
|
|
|
918
964
|
const proc = spawnProc('powershell', [
|
|
919
965
|
'-NoProfile', '-Command',
|
|
920
966
|
`Expand-Archive -Path '${zipPath}' -DestinationPath '${nodejsDir}' -Force`
|
|
921
|
-
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
967
|
+
], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
922
968
|
if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d.toString()); });
|
|
923
969
|
if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d.toString()); });
|
|
924
970
|
proc.on('error', reject);
|
|
@@ -1039,6 +1085,54 @@ class Installer {
|
|
|
1039
1085
|
});
|
|
1040
1086
|
}
|
|
1041
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* Locate a `node` binary plus its `npm-cli.js` so npm can be invoked as
|
|
1090
|
+
* `node npm-cli.js …` with argv passed array-style and NO shell. On Windows
|
|
1091
|
+
* that goes through CreateProcessW (UTF-16), so an install prefix under a
|
|
1092
|
+
* non-ASCII home dir (e.g. `C:\Users\用户名\.openagents\runtimes\…`) is
|
|
1093
|
+
* preserved exactly. The legacy `npm.cmd` shell shim instead has cmd.exe
|
|
1094
|
+
* decode the path bytes with the OEM code page (936/GBK on zh-CN), corrupting
|
|
1095
|
+
* it and silently breaking every install. Returns { node, npmCli } or null
|
|
1096
|
+
* when no usable layout is found (caller falls back to the legacy shell
|
|
1097
|
+
* command, which still works for ASCII paths).
|
|
1098
|
+
*/
|
|
1099
|
+
_resolveNodeNpmCli() {
|
|
1100
|
+
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
1101
|
+
const isWin = process.platform === 'win32';
|
|
1102
|
+
const nodeName = isWin ? 'node.exe' : 'node';
|
|
1103
|
+
|
|
1104
|
+
const nodeCandidates = [];
|
|
1105
|
+
// Bundled portable node (~/.openagents/nodejs, plus nested node-* layouts)
|
|
1106
|
+
const bundledDir = path.join(this.configDir, 'nodejs');
|
|
1107
|
+
nodeCandidates.push(path.join(bundledDir, nodeName));
|
|
1108
|
+
nodeCandidates.push(path.join(bundledDir, 'bin', 'node'));
|
|
1109
|
+
try {
|
|
1110
|
+
for (const e of fs.readdirSync(bundledDir)) {
|
|
1111
|
+
if (e.startsWith('node-')) {
|
|
1112
|
+
nodeCandidates.push(path.join(bundledDir, e, nodeName));
|
|
1113
|
+
nodeCandidates.push(path.join(bundledDir, e, 'bin', 'node'));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
} catch {}
|
|
1117
|
+
// System node — its npm-cli.js lives next to (or one level above) it.
|
|
1118
|
+
try {
|
|
1119
|
+
const sys = whichBinary('node');
|
|
1120
|
+
if (sys) nodeCandidates.push(sys);
|
|
1121
|
+
} catch {}
|
|
1122
|
+
|
|
1123
|
+
for (const node of nodeCandidates) {
|
|
1124
|
+
if (!node || !exists(node)) continue;
|
|
1125
|
+
const dir = path.dirname(node);
|
|
1126
|
+
const npmCli = [
|
|
1127
|
+
path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
1128
|
+
path.join(dir, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
1129
|
+
path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
1130
|
+
].find(exists);
|
|
1131
|
+
if (npmCli && this._canExecute(node)) return { node, npmCli };
|
|
1132
|
+
}
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1042
1136
|
/**
|
|
1043
1137
|
* Resolve the npm CLI command. Uses system npm if available.
|
|
1044
1138
|
*/
|
package/src/paths.js
CHANGED
|
@@ -107,7 +107,13 @@ function getEnhancedEnv(baseEnv) {
|
|
|
107
107
|
const env = { ...(baseEnv || process.env) };
|
|
108
108
|
const extra = getExtraBinDirs();
|
|
109
109
|
if (extra.length > 0) {
|
|
110
|
-
|
|
110
|
+
// Spreading process.env on Windows yields a "Path" key (not "PATH"), so a
|
|
111
|
+
// bare `env.PATH = …` would create a SECOND key holding only the extra dirs
|
|
112
|
+
// — no System32 — and libuv picks that truncated one when resolving spawned
|
|
113
|
+
// executables (cmd.exe / where.exe become unfindable). Update the existing
|
|
114
|
+
// case-insensitive path key in place instead.
|
|
115
|
+
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
|
|
116
|
+
env[pathKey] = extra.join(SEP) + SEP + (env[pathKey] || '');
|
|
111
117
|
}
|
|
112
118
|
if (IS_WINDOWS) {
|
|
113
119
|
// Force UTF-8 output from child processes on non-English Windows locales
|
|
@@ -179,8 +185,16 @@ function _addWindowsPaths(dirs) {
|
|
|
179
185
|
// Portable Node.js installed by OpenAgents Launcher
|
|
180
186
|
_push(dirs, path.join(HOME, '.openagents', 'nodejs'));
|
|
181
187
|
|
|
182
|
-
// Cursor CLI native installer
|
|
188
|
+
// Cursor CLI native installer.
|
|
189
|
+
// - ~/.cursor/bin : the curl|bash layout (also used by some setups)
|
|
190
|
+
// - %LOCALAPPDATA%\cursor-agent : where the Windows installer
|
|
191
|
+
// (irm 'https://cursor.com/install?win32=true' | iex) actually drops
|
|
192
|
+
// cursor-agent.cmd / agent.cmd. The installer edits the *registry* PATH,
|
|
193
|
+
// so an already-running launcher/daemon process never sees it via `where`
|
|
194
|
+
// unless we add the dir here explicitly.
|
|
183
195
|
_push(dirs, path.join(HOME, '.cursor', 'bin'));
|
|
196
|
+
if (localAppData) _push(dirs, path.join(localAppData, 'cursor-agent'));
|
|
197
|
+
_push(dirs, path.join(HOME, '.local', 'bin'));
|
|
184
198
|
|
|
185
199
|
// Node.js install
|
|
186
200
|
_push(dirs, path.join(programFiles, 'nodejs'));
|
package/src/tui.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const blessed = require('blessed');
|
|
10
10
|
const { AgentConnector } = require('./index');
|
|
11
|
+
const { loadAgentRows, connectAvailable } = require('./agent-rows');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
const fs = require('fs');
|
|
13
14
|
const { spawn } = require('child_process');
|
|
@@ -64,49 +65,6 @@ function getConnector() {
|
|
|
64
65
|
return new AgentConnector({ configDir });
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
function loadAgentRows(connector) {
|
|
68
|
-
const config = connector.config.load();
|
|
69
|
-
const agents = config.agents || [];
|
|
70
|
-
const agentStatuses = connector.getDaemonStatus() || {};
|
|
71
|
-
const pid = connector.getDaemonPid();
|
|
72
|
-
const networks = config.networks || [];
|
|
73
|
-
return agents.map(agent => {
|
|
74
|
-
const info = agentStatuses[agent.name] || {};
|
|
75
|
-
const state = pid ? (info.state || 'stopped') : 'stopped';
|
|
76
|
-
let workspace = '';
|
|
77
|
-
if (agent.network) {
|
|
78
|
-
const net = networks.find(n => n.slug === agent.network || n.id === agent.network);
|
|
79
|
-
if (net) {
|
|
80
|
-
const slug = net.slug || net.id;
|
|
81
|
-
const isLocal = (net.endpoint || '').includes('localhost') || (net.endpoint || '').includes('127.0.0.1');
|
|
82
|
-
if (isLocal) workspace = `${net.endpoint}/${slug}`;
|
|
83
|
-
else workspace = `workspace.openagents.org/${slug}`;
|
|
84
|
-
} else {
|
|
85
|
-
workspace = agent.network;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
let notReadyMsg = '';
|
|
89
|
-
let health = null;
|
|
90
|
-
try {
|
|
91
|
-
health = connector.healthCheck(agent.type || 'openclaw');
|
|
92
|
-
if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
|
|
93
|
-
} catch {}
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
name: agent.name,
|
|
97
|
-
type: agent.type || 'openclaw',
|
|
98
|
-
state,
|
|
99
|
-
workspace,
|
|
100
|
-
path: agent.path || '',
|
|
101
|
-
network: agent.network || '',
|
|
102
|
-
lastError: info.last_error || '',
|
|
103
|
-
notReadyMsg,
|
|
104
|
-
health,
|
|
105
|
-
configured: true,
|
|
106
|
-
};
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
68
|
function describeHealth(health) {
|
|
111
69
|
if (!health) return '';
|
|
112
70
|
if (!health.ready) return health.message || 'Not configured';
|
|
@@ -271,7 +229,7 @@ function createTUI() {
|
|
|
271
229
|
const envFields = connector.registry.getEnvFields(agent.type);
|
|
272
230
|
if (envFields && envFields.length > 0) items.push({ key: 'e', label: 'Configure' });
|
|
273
231
|
|
|
274
|
-
if (
|
|
232
|
+
if (connectAvailable(agent)) items.push({ key: 'c', label: 'Connect' });
|
|
275
233
|
if (agent.workspace) items.push({ key: 'd', label: 'Disconnect' });
|
|
276
234
|
if (agent.workspace) items.push({ key: 'w', label: 'Workspace' });
|
|
277
235
|
|
|
@@ -322,7 +280,8 @@ function createTUI() {
|
|
|
322
280
|
const items = [];
|
|
323
281
|
for (const r of agentRows) {
|
|
324
282
|
const state = stateMarkup(r.state, !!r.workspace);
|
|
325
|
-
|
|
283
|
+
// Local-only agents (no workspace) show a dimmed "(local)" marker.
|
|
284
|
+
const ws = r.workspace || `{gray-fg}${r.workspaceLabel}{/gray-fg}`;
|
|
326
285
|
items.push(` ${r.name.padEnd(22)} ${r.type.padEnd(14)} ${state.padEnd(30)} ${ws}`);
|
|
327
286
|
// Detail row: working dir + config warning
|
|
328
287
|
const details = [];
|
|
@@ -418,7 +377,7 @@ function createTUI() {
|
|
|
418
377
|
if (isStopped) actions.push({ label: 'Start', key: 'start' });
|
|
419
378
|
if (isRunning) actions.push({ label: 'Stop', key: 'stop' });
|
|
420
379
|
if (agent.workspace) actions.push({ label: 'Open Workspace', key: 'open_workspace' });
|
|
421
|
-
if (
|
|
380
|
+
if (connectAvailable(agent)) actions.push({ label: 'Connect to Workspace', key: 'connect' });
|
|
422
381
|
if (agent.workspace) actions.push({ label: 'Disconnect from Workspace', key: 'disconnect' });
|
|
423
382
|
actions.push({ label: 'Remove', key: 'remove' });
|
|
424
383
|
|
|
@@ -1403,7 +1362,7 @@ function createTUI() {
|
|
|
1403
1362
|
Connect() {
|
|
1404
1363
|
if (currentView !== 'main' || !selectedAgent()) return;
|
|
1405
1364
|
const a = selectedAgent();
|
|
1406
|
-
if (a
|
|
1365
|
+
if (connectAvailable(a)) showConnectWorkspaceScreen(a.name);
|
|
1407
1366
|
},
|
|
1408
1367
|
Disconnect() {
|
|
1409
1368
|
if (currentView !== 'main' || !selectedAgent()) return;
|
|
@@ -1492,4 +1451,4 @@ function createTUI() {
|
|
|
1492
1451
|
}
|
|
1493
1452
|
|
|
1494
1453
|
function run() { createTUI(); }
|
|
1495
|
-
module.exports = { run };
|
|
1454
|
+
module.exports = { run, loadAgentRows, connectAvailable };
|
package/src/update-check.js
CHANGED
|
@@ -104,32 +104,75 @@ function detectInstallPrefix() {
|
|
|
104
104
|
return null;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Locate the npm executable next to the running node binary, falling back to
|
|
109
|
+
* a PATH lookup. On Windows the runnable file is `npm.cmd`; the extensionless
|
|
110
|
+
* `npm` shipped alongside it is a Unix shell script that cmd.exe / PowerShell
|
|
111
|
+
* cannot execute, so it must be checked AFTER `npm.cmd`. The opts are injectable
|
|
112
|
+
* to keep this testable across platforms.
|
|
113
|
+
*/
|
|
114
|
+
function findNpmBin(opts = {}) {
|
|
115
|
+
const platform = opts.platform || process.platform;
|
|
116
|
+
const nodeDir = opts.nodeDir || path.dirname(process.execPath);
|
|
117
|
+
const exists = opts.exists || fs.existsSync;
|
|
118
|
+
const names = platform === 'win32' ? ['npm.cmd', 'npm'] : ['npm'];
|
|
119
|
+
for (const name of names) {
|
|
110
120
|
const candidate = path.join(nodeDir, name);
|
|
111
|
-
if (
|
|
121
|
+
if (exists(candidate)) return candidate;
|
|
112
122
|
}
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
// PATH fallback (injectable so tests can exercise the hard fallback
|
|
124
|
+
// deterministically regardless of the host OS).
|
|
125
|
+
const lookup = opts.lookup || (() =>
|
|
126
|
+
execSync(platform === 'win32' ? 'where npm' : 'which npm', {
|
|
115
127
|
encoding: 'utf-8', timeout: 3000,
|
|
116
|
-
}).trim().split(/\r?\n/)[0];
|
|
117
|
-
|
|
128
|
+
}).trim().split(/\r?\n/)[0]);
|
|
129
|
+
try {
|
|
130
|
+
return lookup() || (platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
131
|
+
} catch { return platform === 'win32' ? 'npm.cmd' : 'npm'; }
|
|
118
132
|
}
|
|
119
133
|
|
|
120
134
|
/**
|
|
121
135
|
* Run npm install to update to the latest version. Blocking.
|
|
122
|
-
* Returns true on success.
|
|
136
|
+
* Returns true on success. `opts` are injectable for testing.
|
|
123
137
|
*/
|
|
124
|
-
function runUpdate() {
|
|
125
|
-
const
|
|
126
|
-
const
|
|
138
|
+
function runUpdate(opts = {}) {
|
|
139
|
+
const platform = opts.platform || process.platform;
|
|
140
|
+
const spawn = opts.spawn || spawnSync;
|
|
141
|
+
const npmBin = opts.npmBin || findNpmBin({ platform });
|
|
142
|
+
const prefix = opts.prefix !== undefined ? opts.prefix : detectInstallPrefix();
|
|
127
143
|
const args = ['install', '--no-save', `${PKG_NAME}@latest`];
|
|
128
144
|
if (prefix) args.push('--prefix', prefix);
|
|
129
145
|
else args.push('-g');
|
|
146
|
+
|
|
147
|
+
// On Windows, npm.cmd is a batch file and must be invoked through cmd.exe.
|
|
148
|
+
// Passing the args via cmd.exe with shell:false lets Node quote paths that
|
|
149
|
+
// contain spaces (e.g. C:\Program Files\nodejs\npm.cmd) correctly.
|
|
150
|
+
let cmd, cmdArgs;
|
|
151
|
+
if (platform === 'win32') {
|
|
152
|
+
cmd = process.env.ComSpec || 'cmd.exe';
|
|
153
|
+
cmdArgs = ['/d', '/s', '/c', npmBin, ...args];
|
|
154
|
+
} else {
|
|
155
|
+
cmd = npmBin;
|
|
156
|
+
cmdArgs = args;
|
|
157
|
+
}
|
|
158
|
+
|
|
130
159
|
process.stderr.write(`[launcher] Running: ${npmBin} ${args.join(' ')}\n`);
|
|
131
|
-
const r =
|
|
132
|
-
|
|
160
|
+
const r = spawn(cmd, cmdArgs, { stdio: 'inherit' });
|
|
161
|
+
|
|
162
|
+
// spawn failed to even start the process (ENOENT / EINVAL / EACCES ...).
|
|
163
|
+
if (r && r.error) {
|
|
164
|
+
process.stderr.write(`[launcher] Failed to run npm (${cmd}): ${r.error.message}\n`);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
// npm started but exited non-zero. Its own stderr was already streamed via
|
|
168
|
+
// stdio:'inherit'; surface the exit code so the failure isn't silent.
|
|
169
|
+
if (!r || r.status !== 0) {
|
|
170
|
+
const code = r ? r.status : 'unknown';
|
|
171
|
+
const sig = r && r.signal ? ` (signal ${r.signal})` : '';
|
|
172
|
+
process.stderr.write(`[launcher] npm exited with code ${code}${sig}\n`);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
133
176
|
}
|
|
134
177
|
|
|
135
178
|
/**
|
|
@@ -179,5 +222,6 @@ module.exports = {
|
|
|
179
222
|
checkForUpdate,
|
|
180
223
|
notifyAndMaybeUpdate,
|
|
181
224
|
runUpdate,
|
|
225
|
+
findNpmBin,
|
|
182
226
|
currentVersion,
|
|
183
227
|
};
|
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>
|