@openagents-org/agent-launcher 0.2.103 → 0.2.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.103",
3
+ "version": "0.2.110",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/registry.json CHANGED
@@ -136,16 +136,27 @@
136
136
  {
137
137
  "name": "OPENAI_API_KEY",
138
138
  "description": "OpenAI API key",
139
- "required": true,
139
+ "required": false,
140
140
  "password": true
141
+ },
142
+ {
143
+ "name": "OPENAI_BASE_URL",
144
+ "description": "OpenAI-compatible base URL",
145
+ "required": false
141
146
  }
142
147
  ],
143
148
  "check_ready": {
144
- "env_vars": [
145
- "OPENAI_API_KEY"
149
+ "env_all": [
150
+ "OPENAI_API_KEY",
151
+ "OPENAI_BASE_URL"
146
152
  ],
147
- "saved_env_key": "OPENAI_API_KEY",
148
- "not_ready_message": "No API key \u2014 press e to configure"
153
+ "saved_env_all": [
154
+ "OPENAI_API_KEY",
155
+ "OPENAI_BASE_URL"
156
+ ],
157
+ "status_command": "codex login status",
158
+ "login_command": "codex login",
159
+ "not_ready_message": "Not configured. Set OPENAI_API_KEY + OPENAI_BASE_URL, or run: codex login"
149
160
  },
150
161
  "resolve_env": {
151
162
  "rules": [
@@ -525,4 +536,4 @@
525
536
  "windows": "pip install sweagent"
526
537
  }
527
538
  }
528
- ]
539
+ ]
@@ -12,12 +12,12 @@
12
12
  *
13
13
  * Subclasses must implement _handleMessage(msg).
14
14
  *
15
- * Direct port of Python: src/openagents/adapters/base.py
15
+ * Direct port of Python: sdk/src/openagents/adapters/base.py
16
16
  */
17
17
 
18
18
  'use strict';
19
19
 
20
- const { WorkspaceClient } = require('../workspace-client');
20
+ const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
21
21
  const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
22
22
 
23
23
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
@@ -43,6 +43,7 @@ class BaseAdapter {
43
43
  this.client = new WorkspaceClient(this.endpoint);
44
44
  this._lastEventId = null;
45
45
  this._running = false;
46
+ this._sessionId = null; // issued by server on /v1/join; used to prove liveness
46
47
  this._processedIds = new Set();
47
48
  this._titledSessions = new Set();
48
49
  this._mode = 'execute';
@@ -65,13 +66,14 @@ class BaseAdapter {
65
66
 
66
67
  // Announce agent to workspace
67
68
  try {
68
- await this.client.joinNetwork(this.agentName, this.token, {
69
+ const joinResult = await this.client.joinNetwork(this.agentName, this.token, {
69
70
  network: this.workspaceId,
70
71
  agentType: this.agentType || 'agent',
71
72
  serverHost: require('os').hostname(),
72
73
  workingDir: this.workingDir || process.cwd(),
73
74
  });
74
- this._log(`Joined workspace ${this.workspaceId}`);
75
+ this._sessionId = (joinResult && joinResult.session_id) || null;
76
+ this._log(`Joined workspace ${this.workspaceId}${this._sessionId ? ` (session ${this._sessionId.slice(0, 8)})` : ''}`);
75
77
  } catch (e) {
76
78
  this._log(`Warning: join failed: ${e.message}`);
77
79
  }
@@ -130,8 +132,13 @@ class BaseAdapter {
130
132
 
131
133
  async _heartbeat() {
132
134
  try {
133
- await this.client.heartbeat(this.workspaceId, this.agentName, this.token);
135
+ await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
134
136
  } catch (e) {
137
+ if (e instanceof SessionRevokedError) {
138
+ this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
139
+ this._running = false;
140
+ return;
141
+ }
135
142
  this._log(`Heartbeat failed: ${e.message}`);
136
143
  }
137
144
  }
@@ -312,8 +319,11 @@ class BaseAdapter {
312
319
  senderName: this.agentName,
313
320
  messageType: 'status',
314
321
  metadata: { agent_mode: this._mode },
322
+ sessionId: this._sessionId,
315
323
  });
316
- } catch {}
324
+ } catch (e) {
325
+ if (e instanceof SessionRevokedError) this._onSessionRevoked();
326
+ }
317
327
  }
318
328
 
319
329
  async sendThinking(channel, content) {
@@ -323,15 +333,27 @@ class BaseAdapter {
323
333
  senderName: this.agentName,
324
334
  messageType: 'thinking',
325
335
  metadata: { agent_mode: this._mode },
336
+ sessionId: this._sessionId,
326
337
  });
327
- } catch {}
338
+ } catch (e) {
339
+ if (e instanceof SessionRevokedError) this._onSessionRevoked();
340
+ }
328
341
  }
329
342
 
330
343
  async sendResponse(channel, content) {
331
- await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
332
- senderType: 'agent',
333
- senderName: this.agentName,
334
- });
344
+ try {
345
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
346
+ senderType: 'agent',
347
+ senderName: this.agentName,
348
+ sessionId: this._sessionId,
349
+ });
350
+ } catch (e) {
351
+ if (e instanceof SessionRevokedError) {
352
+ this._onSessionRevoked();
353
+ return;
354
+ }
355
+ throw e;
356
+ }
335
357
  }
336
358
 
337
359
  async sendError(channel, error) {
@@ -339,8 +361,16 @@ class BaseAdapter {
339
361
  await this.client.sendMessage(this.workspaceId, channel, this.token, error, {
340
362
  senderType: 'agent',
341
363
  senderName: this.agentName,
364
+ sessionId: this._sessionId,
342
365
  });
343
- } catch {}
366
+ } catch (e) {
367
+ if (e instanceof SessionRevokedError) this._onSessionRevoked();
368
+ }
369
+ }
370
+
371
+ _onSessionRevoked() {
372
+ this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
373
+ this._running = false;
344
374
  }
345
375
 
346
376
  // ------------------------------------------------------------------
@@ -6,7 +6,7 @@
6
6
  * - Claude CLI subprocess (stream-json) for task execution
7
7
  * - MCP server for workspace tool access
8
8
  *
9
- * Direct port of Python: src/openagents/adapters/claude.py
9
+ * Direct port of Python: sdk/src/openagents/adapters/claude.py
10
10
  */
11
11
 
12
12
  'use strict';
@@ -272,72 +272,56 @@ class ClaudeAdapter extends BaseAdapter {
272
272
  if (this.disabledModules.has('files')) mcpArgs.push('--disable-files');
273
273
  if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser');
274
274
 
275
- // Find openagents binary (multi-tier)
276
- let oaBin = null;
277
- const home3 = os.homedir();
278
- // Tier 0: Check all isolated runtime prefixes for openagents binary
279
- const oaExt = IS_WINDOWS ? '.cmd' : '';
280
- const runtimesRoot = path.join(home3, '.openagents', 'runtimes');
281
- try {
282
- for (const d of fs.readdirSync(runtimesRoot, { withFileTypes: true })) {
283
- if (d.isDirectory()) {
284
- const candidate = path.join(runtimesRoot, d.name, 'node_modules', '.bin', `openagents${oaExt}`);
285
- if (fs.existsSync(candidate)) { oaBin = candidate; break; }
275
+ // Resolve the MCP server entry point. Prefer the sibling bin inside this
276
+ // very package — it's guaranteed to exist whenever claude.js is executing,
277
+ // so it never falls through to a broken PATH lookup. If Claude Code can't
278
+ // spawn the MCP server, it silently hides every workspace tool and the
279
+ // agent reports "workspace_read_file isn't in my tool set".
280
+ let mcpCommand = this._findNodeBin();
281
+ let mcpFinalArgs = mcpArgs;
282
+ const siblingBin = path.resolve(__dirname, '..', '..', 'bin', 'agent-connector.js');
283
+ if (fs.existsSync(siblingBin)) {
284
+ mcpFinalArgs = [siblingBin, ...mcpArgs];
285
+ } else {
286
+ // Fallback: search installed locations (older layouts, global installs)
287
+ let oaBin = null;
288
+ const home3 = os.homedir();
289
+ const oaExt = IS_WINDOWS ? '.cmd' : '';
290
+ const runtimesRoot = path.join(home3, '.openagents', 'runtimes');
291
+ try {
292
+ for (const d of fs.readdirSync(runtimesRoot, { withFileTypes: true })) {
293
+ if (d.isDirectory()) {
294
+ const candidate = path.join(runtimesRoot, d.name, 'node_modules', '.bin', `openagents${oaExt}`);
295
+ if (fs.existsSync(candidate)) { oaBin = candidate; break; }
296
+ }
286
297
  }
298
+ } catch {}
299
+ if (!oaBin) {
300
+ const oaPortable = path.join(home3, '.openagents', 'nodejs', 'node_modules', '.bin', `openagents${oaExt}`);
301
+ if (fs.existsSync(oaPortable)) oaBin = oaPortable;
287
302
  }
288
- } catch {}
289
- // Tier 0b: Legacy portable install
290
- if (!oaBin) {
291
- const oaPortable = path.join(home3, '.openagents', 'nodejs', 'node_modules', '.bin', `openagents${oaExt}`);
292
- if (fs.existsSync(oaPortable)) oaBin = oaPortable;
293
- }
294
- // Tier 1: PATH
295
- if (!oaBin) try {
296
- if (IS_WINDOWS) {
297
- oaBin = execSync('where openagents.cmd 2>nul || where openagents.exe 2>nul || where openagents 2>nul', {
298
- encoding: 'utf-8', timeout: 5000,
299
- }).split(/\r?\n/)[0].trim();
303
+ if (!oaBin) try {
304
+ if (IS_WINDOWS) {
305
+ oaBin = execSync('where openagents.cmd 2>nul || where openagents.exe 2>nul || where openagents 2>nul', {
306
+ encoding: 'utf-8', timeout: 5000,
307
+ }).split(/\r?\n/)[0].trim();
308
+ } else {
309
+ oaBin = execSync('which openagents', { encoding: 'utf-8', timeout: 5000 }).trim();
310
+ }
311
+ } catch {}
312
+ if (!oaBin) {
313
+ this._log('Could not find openagents binary — MCP tools may not be available');
314
+ mcpCommand = 'openagents';
300
315
  } else {
301
- oaBin = execSync('which openagents', { encoding: 'utf-8', timeout: 5000 }).trim();
302
- }
303
- } catch {}
304
- // Tier 2: Next to Node.js
305
- if (!oaBin) {
306
- const nodeBinDir2 = path.dirname(process.execPath);
307
- const oaExt = IS_WINDOWS ? '.cmd' : '';
308
- const nearNode2 = path.join(nodeBinDir2, `openagents${oaExt}`);
309
- if (fs.existsSync(nearNode2)) oaBin = nearNode2;
310
- }
311
- // Tier 3: Common locations
312
- if (!oaBin) {
313
- const home2 = os.homedir();
314
- const oaCandidates = IS_WINDOWS ? [
315
- path.join(process.env.APPDATA || '', 'npm', 'openagents.cmd'),
316
- ] : [
317
- path.join(home2, '.openagents', 'npm-global', 'bin', 'openagents'),
318
- path.join(home2, '.local', 'bin', 'openagents'),
319
- path.join(home2, '.npm-global', 'bin', 'openagents'),
320
- '/opt/homebrew/bin/openagents',
321
- '/usr/local/bin/openagents',
322
- ];
323
- for (const c of oaCandidates) {
324
- if (fs.existsSync(c)) { oaBin = c; break; }
316
+ const resolved = this._resolveToNodeCmd(oaBin);
317
+ if (resolved) {
318
+ mcpCommand = resolved[0];
319
+ mcpFinalArgs = [resolved[1], ...mcpArgs];
320
+ } else {
321
+ mcpCommand = oaBin;
322
+ }
325
323
  }
326
324
  }
327
- if (!oaBin) {
328
- oaBin = 'openagents';
329
- this._log('Could not find openagents binary — MCP tools may not be available');
330
- }
331
-
332
- // Resolve shim/symlink to node + JS entry point for MCP server
333
- // (.cmd shims and #!/usr/bin/env node shebangs both fail as MCP commands)
334
- let mcpCommand = oaBin;
335
- let mcpFinalArgs = mcpArgs;
336
- const mcpResolved = this._resolveToNodeCmd(oaBin);
337
- if (mcpResolved) {
338
- mcpCommand = mcpResolved[0];
339
- mcpFinalArgs = [mcpResolved[1], ...mcpArgs];
340
- }
341
325
 
342
326
  const mcpConfig = {
343
327
  mcpServers: {
@@ -140,7 +140,18 @@ class CodexAdapter extends BaseAdapter {
140
140
  const nearNode = path.join(nodeBinDir, `codex${ext}`);
141
141
  if (fs.existsSync(nearNode)) return nearNode;
142
142
 
143
- // Tier 3: Common install locations
143
+ // Tier 3: npm global prefix (handles custom npm prefix like D:\node\node_global)
144
+ try {
145
+ const npmPrefix = execSync('npm config get prefix', {
146
+ encoding: 'utf-8', timeout: 5000, windowsHide: true,
147
+ }).trim();
148
+ if (npmPrefix) {
149
+ const prefixCandidate = path.join(npmPrefix, `codex${ext}`);
150
+ if (fs.existsSync(prefixCandidate)) return prefixCandidate;
151
+ }
152
+ } catch {}
153
+
154
+ // Tier 4: Common install locations
144
155
  const candidates = IS_WINDOWS ? [
145
156
  path.join(process.env.APPDATA || '', 'npm', 'codex.cmd'),
146
157
  ] : [
@@ -265,12 +276,10 @@ class CodexAdapter extends BaseAdapter {
265
276
  cmd.push('-C', this.workingDir);
266
277
  }
267
278
 
268
- cmd.push(fullPrompt);
269
-
270
279
  this._log(`Spawning: codex exec ${threadId && attempt === 0 ? `resume ${threadId} ` : ''}--json --full-auto -m ${this._directModel || 'default'}`);
271
280
 
272
281
  try {
273
- const result = await this._spawnCodex(cmd, env, msgChannel);
282
+ const result = await this._spawnCodex(cmd, env, msgChannel, fullPrompt);
274
283
 
275
284
  if (result.responseText) {
276
285
  await this.sendResponse(msgChannel, result.responseText);
@@ -293,14 +302,15 @@ class CodexAdapter extends BaseAdapter {
293
302
  }
294
303
  }
295
304
 
296
- async _spawnCodex(cmd, env, msgChannel) {
305
+ async _spawnCodex(cmd, env, msgChannel, prompt) {
297
306
  return new Promise((resolve, reject) => {
298
307
  const proc = spawn(cmd[0], cmd.slice(1), {
299
- stdio: ['ignore', 'pipe', 'pipe'],
308
+ stdio: ['pipe', 'pipe', 'pipe'],
300
309
  env,
301
310
  cwd: this.workingDir,
302
311
  detached: !IS_WINDOWS,
303
312
  windowsHide: true,
313
+ shell: IS_WINDOWS,
304
314
  });
305
315
  this._channelProcesses[msgChannel] = proc;
306
316
 
@@ -314,6 +324,11 @@ class CodexAdapter extends BaseAdapter {
314
324
  proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
315
325
  }
316
326
 
327
+ if (proc.stdin) {
328
+ proc.stdin.write(prompt || '', 'utf-8');
329
+ proc.stdin.end();
330
+ }
331
+
317
332
  const processLine = async (line) => {
318
333
  line = line.trim();
319
334
  if (!line) return;
@@ -2,7 +2,7 @@
2
2
  * Cursor adapter — AI-powered code editor agent mode.
3
3
  *
4
4
  * Uses direct LLM API mode (OpenAI-compatible chat completions).
5
- * Port of Python: src/openagents/adapters/cursor.py
5
+ * Port of Python: sdk/src/openagents/adapters/cursor.py
6
6
  */
7
7
 
8
8
  'use strict';
@@ -4,7 +4,7 @@
4
4
  * Calls OpenAI-compatible chat completions API directly with SSE streaming.
5
5
  * No CLI binary needed — just OPENAI_API_KEY + OPENAI_BASE_URL.
6
6
  *
7
- * Port of Python: src/openagents/adapters/nanoclaw.py & cursor.py
7
+ * Port of Python: sdk/src/openagents/adapters/nanoclaw.py & cursor.py
8
8
  */
9
9
 
10
10
  'use strict';
@@ -2,7 +2,7 @@
2
2
  * NanoClaw adapter — lightweight containerized coding agent.
3
3
  *
4
4
  * Uses direct LLM API mode (OpenAI-compatible chat completions).
5
- * Port of Python: src/openagents/adapters/nanoclaw.py
5
+ * Port of Python: sdk/src/openagents/adapters/nanoclaw.py
6
6
  */
7
7
 
8
8
  'use strict';
@@ -5,7 +5,7 @@
5
5
  * - CLI mode: `openclaw agent --local --json` (preferred)
6
6
  * - Workspace context injected via SKILL.md auto-discovery
7
7
  *
8
- * Direct port of Python: src/openagents/adapters/openclaw.py
8
+ * Direct port of Python: sdk/src/openagents/adapters/openclaw.py
9
9
  * (CLI mode only — gateway WS and direct HTTP modes are not yet ported)
10
10
  */
11
11
 
@@ -5,7 +5,7 @@
5
5
  * `opencode run --format json` as a subprocess. OpenCode handles its own
6
6
  * model configuration, provider selection, and tool chain.
7
7
  *
8
- * Port of Python PR #316: src/openagents/adapters/opencode.py
8
+ * Port of Python PR #316: sdk/src/openagents/adapters/opencode.py
9
9
  */
10
10
 
11
11
  'use strict';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared utilities for adapter implementations.
3
3
  *
4
- * Direct port of Python: src/openagents/adapters/utils.py
4
+ * Direct port of Python: sdk/src/openagents/adapters/utils.py
5
5
  */
6
6
 
7
7
  'use strict';
@@ -6,7 +6,7 @@
6
6
  * - Multi-agent collaboration (@mention delegation)
7
7
  * - Workspace REST API skills (files, browser, tunnels)
8
8
  *
9
- * Direct port of Python: src/openagents/adapters/workspace_prompt.py
9
+ * Direct port of Python: sdk/src/openagents/adapters/workspace_prompt.py
10
10
  */
11
11
 
12
12
  'use strict';
@@ -21,8 +21,12 @@ function buildWorkspaceIdentity(agentName, workspaceId, channelName, mode = 'exe
21
21
  '— just write your answer naturally.\n\n' +
22
22
  '## Workspace Context\n' +
23
23
  `- Workspace ID: ${workspaceId}\n` +
24
- `- Channel: ${channelName}\n` +
25
- `- Mode: ${mode}\n`
24
+ `- Channel: ${channelName} (this is the channel you are currently speaking in)\n` +
25
+ `- Mode: ${mode}\n\n` +
26
+ 'When you need prior context, call `workspace_get_history` with ' +
27
+ `\`channel="${channelName}"\` (the current channel). Without the ` +
28
+ 'channel argument the tool falls back to a default channel that may ' +
29
+ 'be different from where you are.\n'
26
30
  );
27
31
  }
28
32
 
package/src/daemon.js CHANGED
@@ -30,6 +30,7 @@ class Daemon {
30
30
  this._shuttingDown = false;
31
31
  this._statusInterval = null;
32
32
  this._cmdInterval = null;
33
+ this._reloadInFlight = null; // serialize concurrent _reload() calls
33
34
  }
34
35
 
35
36
  // ---------------------------------------------------------------------------
@@ -182,6 +183,22 @@ class Daemon {
182
183
  const bin = execPath || process.execPath;
183
184
 
184
185
  fs.mkdirSync(configDir, { recursive: true });
186
+
187
+ // Refuse to start if an existing daemon is already running.
188
+ // Without this check, repeated `agn up` invocations would spawn
189
+ // multiple daemons that each process the same message → duplicate
190
+ // bot replies.
191
+ const existingPid = Daemon._readPid(pidFile);
192
+ if (existingPid && Daemon._isAlive(existingPid)) {
193
+ console.error(`Daemon already running (PID ${existingPid}).`);
194
+ console.error(`Run 'agn down' first, or 'agn status' to check.`);
195
+ process.exit(1);
196
+ }
197
+ // Stale pid file — clean up before spawning fresh
198
+ if (existingPid) {
199
+ try { fs.unlinkSync(pidFile); } catch {}
200
+ }
201
+
185
202
  const logFd = fs.openSync(logFile, 'a');
186
203
 
187
204
  // Build env with enhanced PATH (ensures node/npm are findable)
@@ -636,6 +653,27 @@ class Daemon {
636
653
  }
637
654
 
638
655
  async _reload() {
656
+ // Serialize reloads. fs.watch, the 'reload' command, and SIGHUP can
657
+ // all fire concurrently. Without a mutex, two _reload() calls in flight
658
+ // may both observe the same stale `this._adapters[name]` entry between
659
+ // stopAgent() and _launchAgent(), leaving a ghost adapter running
660
+ // alongside the new one → duplicate bot replies per message.
661
+ if (this._reloadInFlight) {
662
+ // Wait for the in-flight reload to finish, then run once more
663
+ // (the config may have changed again since it started).
664
+ this._reloadInFlight = this._reloadInFlight.then(
665
+ () => this._reloadUnsafe(),
666
+ () => this._reloadUnsafe(),
667
+ );
668
+ return this._reloadInFlight;
669
+ }
670
+ this._reloadInFlight = this._reloadUnsafe().finally(() => {
671
+ this._reloadInFlight = null;
672
+ });
673
+ return this._reloadInFlight;
674
+ }
675
+
676
+ async _reloadUnsafe() {
639
677
  this._log('Reloading config...');
640
678
  const oldNames = this._cachedAgentNames || new Set();
641
679
  const oldConfigs = this._cachedAgentConfigs || {};
@@ -657,12 +695,14 @@ class Daemon {
657
695
  // Start new agents or restart agents whose network changed
658
696
  for (const agent of newAgents) {
659
697
  if (!oldNames.has(agent.name)) {
698
+ await this._ensureAdapterCleared(agent.name);
660
699
  this._launchAgent(agent);
661
700
  this._log(`Reload: started new agent '${agent.name}'`);
662
701
  } else if ((oldConfigs[agent.name] || '') !== (agent.network || '')) {
663
702
  // Network config changed — restart agent
664
703
  await this.stopAgent(agent.name);
665
704
  this._stoppedAgents.delete(agent.name);
705
+ await this._ensureAdapterCleared(agent.name);
666
706
  this._launchAgent(agent);
667
707
  this._log(`Reload: restarted '${agent.name}' (network changed)`);
668
708
  }
@@ -673,6 +713,28 @@ class Daemon {
673
713
  this._writeStatus();
674
714
  }
675
715
 
716
+ /**
717
+ * Wait until the old adapter (if any) has fully released its slot in
718
+ * this._adapters before relaunching. stopAgent already waits up to 5s,
719
+ * but on slow shutdowns that can be too short — and _launchAgent's
720
+ * duplicate-check would then silently skip the relaunch, leaving the
721
+ * OLD adapter running instead of starting the new one.
722
+ */
723
+ async _ensureAdapterCleared(name) {
724
+ for (let i = 0; i < 20; i++) {
725
+ if (!this._adapters || !this._adapters[name]) return;
726
+ await this._sleep(500);
727
+ }
728
+ // Last resort: force-clear the slot so the new adapter can start.
729
+ // The old adapter will exit on its next poll iteration since its
730
+ // entry in _stoppedAgents triggers adapter.stop() via checkStop.
731
+ if (this._adapters && this._adapters[name]) {
732
+ this._log(`WARNING: adapter '${name}' did not clear after 10s — force-releasing slot to avoid duplicate`);
733
+ try { this._adapters[name].stop(); } catch {}
734
+ delete this._adapters[name];
735
+ }
736
+ }
737
+
676
738
  _writePid() {
677
739
  try {
678
740
  fs.writeFileSync(this.config.pidFile, String(process.pid), 'utf-8');
package/src/identity.js CHANGED
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Local identity management (~/.openagents/identity.json).
5
5
  *
6
- * Port of Python: src/openagents/client/workspace_client.py (identity section)
6
+ * Port of Python: sdk/src/openagents/client/workspace_client.py (identity section)
7
7
  */
8
8
 
9
9
  const fs = require('fs');
package/src/installer.js CHANGED
@@ -5,6 +5,10 @@ const os = require('os');
5
5
  const path = require('path');
6
6
  const { execSync, exec } = require('child_process');
7
7
  const { whichBinary, getEnhancedEnv, getRuntimePrefix } = require('./paths');
8
+ const { EnvManager } = require('./env');
9
+
10
+ const STATUS_CACHE_TTL_MS = 10000;
11
+ const statusCache = new Map();
8
12
 
9
13
  /**
10
14
  * Manages installation and uninstallation of agent runtimes.
@@ -19,6 +23,7 @@ class Installer {
19
23
  this.configDir = configDir;
20
24
  this.markersFile = path.join(configDir, 'installed_agents.json');
21
25
  this.markersDir = path.join(configDir, 'installed');
26
+ this.env = new EnvManager(configDir);
22
27
  }
23
28
 
24
29
  /**
@@ -131,7 +136,17 @@ class Installer {
131
136
  */
132
137
  healthCheck(agentType) {
133
138
  const binary = this._whichBinary(agentType);
134
- if (!binary) return { installed: false, binary: null, version: null };
139
+ if (!binary) {
140
+ return {
141
+ installed: false,
142
+ binary: null,
143
+ version: null,
144
+ ready: false,
145
+ auth_mode: null,
146
+ execution_mode: 'unavailable',
147
+ message: 'Not installed',
148
+ };
149
+ }
135
150
 
136
151
  const entry = this.registry.getEntry(agentType);
137
152
  const checkCmd = entry && entry.install ? entry.install.check_command : null;
@@ -150,51 +165,152 @@ class Installer {
150
165
  version = match ? match[1] : raw.split('\n')[0];
151
166
  } catch {}
152
167
 
153
- // Check login/ready status if check_ready is defined
154
- let ready = true;
168
+ const readiness = this._evaluateReadiness(agentType, entry, binary);
169
+ return { installed: true, binary, version, ...readiness };
170
+ }
171
+
172
+ _evaluateReadiness(agentType, entry, binary) {
155
173
  const checkReady = entry?.check_ready;
156
- if (checkReady) {
157
- ready = false;
158
- // Check env vars
159
- if (checkReady.env_vars) {
160
- for (const v of checkReady.env_vars) {
161
- if (process.env[v]) { ready = true; break; }
174
+ if (!checkReady) {
175
+ return {
176
+ ready: true,
177
+ auth_mode: null,
178
+ execution_mode: 'unavailable',
179
+ message: 'Ready',
180
+ };
181
+ }
182
+
183
+ const savedEnv = this.env.getEffective(agentType, this.registry);
184
+ const directEnv = this._hasAllValues(process.env, checkReady.env_all);
185
+ const directSaved = this._hasAllValues(savedEnv, checkReady.saved_env_all || checkReady.env_all);
186
+ const directReady = directEnv || directSaved;
187
+ const envAnyReady = this._hasAnyValue(process.env, checkReady.env_vars);
188
+ const savedAnyReady = !!(checkReady.saved_env_key && savedEnv[checkReady.saved_env_key]);
189
+ const credsReady = this._checkCredsReady(checkReady);
190
+
191
+ let cliReady = false;
192
+ if (checkReady.status_command && binary) {
193
+ cliReady = this._checkStatusCommand(checkReady.status_command);
194
+ }
195
+
196
+ if (directReady) {
197
+ return {
198
+ ready: true,
199
+ auth_mode: 'api_key',
200
+ execution_mode: 'direct',
201
+ message: 'Ready',
202
+ };
203
+ }
204
+
205
+ if (cliReady) {
206
+ return {
207
+ ready: true,
208
+ auth_mode: 'cli_login',
209
+ execution_mode: 'subprocess',
210
+ message: 'Ready',
211
+ };
212
+ }
213
+
214
+ if (envAnyReady || savedAnyReady) {
215
+ // Legacy single-key path (e.g. agents that only advertise env_vars / saved_env_key).
216
+ // An API key being present means the agent can be launched with it in the environment,
217
+ // so treat this as a direct-launch configuration.
218
+ return {
219
+ ready: true,
220
+ auth_mode: 'api_key',
221
+ execution_mode: 'direct',
222
+ message: 'Ready',
223
+ };
224
+ }
225
+
226
+ if (credsReady) {
227
+ return {
228
+ ready: true,
229
+ auth_mode: 'cli_login',
230
+ execution_mode: 'subprocess',
231
+ message: 'Ready',
232
+ };
233
+ }
234
+
235
+ return {
236
+ ready: false,
237
+ auth_mode: null,
238
+ execution_mode: 'unavailable',
239
+ message: checkReady.not_ready_message || 'Not configured',
240
+ };
241
+ }
242
+
243
+ _hasAllValues(source, keys) {
244
+ if (!keys || keys.length === 0) return false;
245
+ return keys.every((key) => !!(source && source[key]));
246
+ }
247
+
248
+ _hasAnyValue(source, keys) {
249
+ if (!keys || keys.length === 0) return false;
250
+ return keys.some((key) => !!(source && source[key]));
251
+ }
252
+
253
+ _checkCredsReady(checkReady) {
254
+ if (checkReady.creds_file) {
255
+ try {
256
+ const credsPath = checkReady.creds_file.replace('~', os.homedir());
257
+ if (fs.existsSync(credsPath)) {
258
+ const stat = fs.statSync(credsPath);
259
+ if (stat.isDirectory()) return fs.readdirSync(credsPath).length > 0;
260
+ const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
261
+ if (checkReady.creds_key) return !!creds[checkReady.creds_key];
262
+ return true;
162
263
  }
264
+ } catch {}
265
+ }
266
+
267
+ if (checkReady.keychain_service && process.platform === 'darwin') {
268
+ if (this._checkMacKeychain(checkReady.keychain_service, checkReady.creds_key)) {
269
+ return true;
163
270
  }
164
- // Check credentials file or directory
165
- if (!ready && checkReady.creds_file) {
166
- try {
167
- const credsPath = checkReady.creds_file.replace('~', os.homedir());
168
- if (fs.existsSync(credsPath)) {
169
- const stat = fs.statSync(credsPath);
170
- if (stat.isDirectory()) {
171
- // Directory exists — check if it has files (e.g. session files)
172
- ready = fs.readdirSync(credsPath).length > 0;
173
- } else {
174
- // File — parse JSON and check key
175
- const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
176
- if (checkReady.creds_key) {
177
- ready = !!creds[checkReady.creds_key];
178
- } else {
179
- ready = true;
180
- }
181
- }
182
- }
183
- } catch {}
184
- }
185
- // Also check OAuth credentials (Claude Code stores tokens in .credentials.json)
186
- if (!ready) {
187
- try {
188
- const oauthFile = path.join(os.homedir(), '.claude', '.credentials.json');
189
- if (fs.existsSync(oauthFile)) {
190
- const creds = JSON.parse(fs.readFileSync(oauthFile, 'utf-8'));
191
- if (creds.claudeAiOauth?.accessToken) ready = true;
192
- }
193
- } catch {}
194
- }
195
271
  }
196
272
 
197
- return { installed: true, binary, version, ready };
273
+ return false;
274
+ }
275
+
276
+ /**
277
+ * Check a macOS Keychain generic-password entry. If creds_key is provided the
278
+ * stored value is parsed as JSON and required to contain that key; otherwise any
279
+ * non-empty value counts as ready. Returns false on non-macOS or on any error.
280
+ */
281
+ _checkMacKeychain(service, credsKey) {
282
+ try {
283
+ const stdout = execSync(
284
+ `security find-generic-password -s ${JSON.stringify(service)} -w`,
285
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, encoding: 'utf-8' },
286
+ ).trim();
287
+ if (!stdout) return false;
288
+ if (!credsKey) return true;
289
+ const creds = JSON.parse(stdout);
290
+ return !!creds[credsKey];
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ _checkStatusCommand(command) {
297
+ const cached = statusCache.get(command);
298
+ if (cached && (Date.now() - cached.ts) < STATUS_CACHE_TTL_MS) {
299
+ return cached.ok;
300
+ }
301
+
302
+ let ok = false;
303
+ try {
304
+ execSync(command, {
305
+ stdio: 'ignore',
306
+ timeout: 5000,
307
+ env: getEnhancedEnv(),
308
+ });
309
+ ok = true;
310
+ } catch {}
311
+
312
+ statusCache.set(command, { ok, ts: Date.now() });
313
+ return ok;
198
314
  }
199
315
 
200
316
  /**
package/src/mcp-server.js CHANGED
@@ -27,11 +27,12 @@ function buildToolDefs(disabledModules) {
27
27
  // -- Workspace core (always enabled) --
28
28
  {
29
29
  name: 'workspace_get_history',
30
- description: 'Read recent messages in the current workspace channel.',
30
+ description: 'Read recent messages in a workspace channel. Defaults to the current channel; pass channel to query another.',
31
31
  inputSchema: {
32
32
  type: 'object',
33
33
  properties: {
34
34
  limit: { type: 'integer', description: 'Number of messages to return (default 20)', default: 20 },
35
+ channel: { type: 'string', description: 'Channel name (e.g. "channel-9bcd8e66"); omit to use the current channel.' },
35
36
  },
36
37
  },
37
38
  },
@@ -341,7 +342,8 @@ class McpServer {
341
342
 
342
343
  case 'workspace_get_history': {
343
344
  const limit = args.limit || 20;
344
- const data = await this.ws.pollMessages(this.workspaceId, this.channelName, this.token, { limit });
345
+ const channel = args.channel || this.channelName;
346
+ const data = await this.ws.pollMessages(this.workspaceId, channel, this.token, { limit });
345
347
  const events = data.events || data || [];
346
348
  if (!events.length) return text('No messages yet.');
347
349
  const lines = events.map((e) => {
package/src/paths.js CHANGED
@@ -139,9 +139,18 @@ function _addWindowsPaths(dirs) {
139
139
  // System32 (cmd.exe, powershell, etc) — Electron may not have it
140
140
  _push(dirs, path.join(sysRoot, 'System32'));
141
141
 
142
- // npm global bin
142
+ // npm global bin (default location)
143
143
  if (appData) _push(dirs, path.join(appData, 'npm'));
144
144
 
145
+ // npm global bin (custom prefix — e.g. user configured npm prefix to D:\node\node_global)
146
+ try {
147
+ const npmPrefix = execSync('npm config get prefix', {
148
+ encoding: 'utf-8', timeout: 5000, windowsHide: true,
149
+ stdio: ['pipe', 'pipe', 'pipe'],
150
+ }).trim();
151
+ if (npmPrefix) _push(dirs, npmPrefix);
152
+ } catch {}
153
+
145
154
  // Portable Node.js installed by OpenAgents Launcher
146
155
  _push(dirs, path.join(HOME, '.openagents', 'nodejs'));
147
156
 
package/src/tui.js CHANGED
@@ -85,54 +85,11 @@ function loadAgentRows(connector) {
85
85
  workspace = agent.network;
86
86
  }
87
87
  }
88
- // Check if agent type needs configuration (API key etc.)
89
88
  let notReadyMsg = '';
89
+ let health = null;
90
90
  try {
91
- const agentType = agent.type || 'openclaw';
92
- const entry = connector.registry.getEntry(agentType);
93
- if (entry && entry.check_ready) {
94
- const cr = entry.check_ready;
95
- let isReady = false;
96
- // Check saved env
97
- if (cr.saved_env_key) {
98
- const saved = connector.env.load(agentType);
99
- if (saved[cr.saved_env_key]) isReady = true;
100
- }
101
- // Check process env vars
102
- if (!isReady && cr.env_vars) {
103
- for (const v of cr.env_vars) {
104
- if (process.env[v]) { isReady = true; break; }
105
- }
106
- }
107
- // Check creds file/directory (for claude)
108
- if (!isReady && cr.creds_file) {
109
- const credsPath = cr.creds_file.replace('~', process.env.HOME || process.env.USERPROFILE || '');
110
- try {
111
- if (fs.existsSync(credsPath)) {
112
- const stat = fs.statSync(credsPath);
113
- if (stat.isDirectory()) {
114
- // Directory (e.g. ~/.claude/sessions) — check if it has files
115
- isReady = fs.readdirSync(credsPath).length > 0;
116
- } else {
117
- const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
118
- if (cr.creds_key) isReady = !!creds[cr.creds_key];
119
- else isReady = true;
120
- }
121
- }
122
- } catch {}
123
- }
124
- // Also check OAuth credentials (Claude Code stores tokens in .credentials.json)
125
- if (!isReady) {
126
- try {
127
- const oauthFile = path.join(process.env.HOME || '', '.claude', '.credentials.json');
128
- if (fs.existsSync(oauthFile)) {
129
- const creds = JSON.parse(fs.readFileSync(oauthFile, 'utf-8'));
130
- if (creds.claudeAiOauth && creds.claudeAiOauth.accessToken) isReady = true;
131
- }
132
- } catch {}
133
- }
134
- if (!isReady) notReadyMsg = cr.not_ready_message || 'Not configured';
135
- }
91
+ health = connector.healthCheck(agent.type || 'openclaw');
92
+ if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
136
93
  } catch {}
137
94
 
138
95
  return {
@@ -144,11 +101,24 @@ function loadAgentRows(connector) {
144
101
  network: agent.network || '',
145
102
  lastError: info.last_error || '',
146
103
  notReadyMsg,
104
+ health,
147
105
  configured: true,
148
106
  };
149
107
  });
150
108
  }
151
109
 
110
+ function describeHealth(health) {
111
+ if (!health) return '';
112
+ if (!health.ready) return health.message || 'Not configured';
113
+ const parts = ['Ready'];
114
+ if (health.auth_mode === 'api_key') parts.push('API key');
115
+ else if (health.auth_mode === 'cli_login') parts.push('CLI login');
116
+ if (health.execution_mode && health.execution_mode !== 'unavailable') {
117
+ parts.push(health.execution_mode);
118
+ }
119
+ return parts.join(' | ');
120
+ }
121
+
152
122
  function loadCatalog(connector) {
153
123
  const { execSync } = require('child_process');
154
124
  const entries = connector.registry.getCatalogSync();
@@ -372,6 +342,7 @@ function createTUI() {
372
342
  // Detail row: working dir + config warning
373
343
  const details = [];
374
344
  details.push(r.path || process.env.HOME || '~');
345
+ if (r.health) details.push(`{cyan-fg}${describeHealth(r.health)}{/cyan-fg}`);
375
346
  if (r.notReadyMsg) details.push(`{yellow-fg}⚠ ${r.notReadyMsg}{/yellow-fg}`);
376
347
  items.push(` {gray-fg} ${details.join(' | ')}{/gray-fg}`);
377
348
  }
@@ -5,6 +5,19 @@ const http = require('http');
5
5
 
6
6
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
7
7
 
8
+ /**
9
+ * Thrown when the workspace rejects a request because our session_id has
10
+ * been revoked by a newer /v1/join as the same agent. Callers should
11
+ * stop the adapter rather than retry.
12
+ */
13
+ class SessionRevokedError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = 'SessionRevokedError';
17
+ this.code = 'session_revoked';
18
+ }
19
+ }
20
+
8
21
  /**
9
22
  * HTTP client for workspace API operations.
10
23
  *
@@ -84,12 +97,18 @@ class WorkspaceClient {
84
97
 
85
98
  /**
86
99
  * Send heartbeat via POST /v1/heartbeat.
100
+ *
101
+ * @param {string} [sessionId] - optional session id returned by /v1/join.
102
+ * If the server's current session for this agent differs, _post()
103
+ * throws SessionRevokedError and the caller should stop its adapter.
87
104
  */
88
- async heartbeat(workspaceId, agentName, token) {
89
- const data = await this._post('/v1/heartbeat', {
105
+ async heartbeat(workspaceId, agentName, token, sessionId) {
106
+ const body = {
90
107
  agent_name: agentName,
91
108
  network: workspaceId,
92
- }, this._wsHeaders(token));
109
+ };
110
+ if (sessionId) body.session_id = sessionId;
111
+ const data = await this._post('/v1/heartbeat', body, this._wsHeaders(token));
93
112
  return data.data || data;
94
113
  }
95
114
 
@@ -107,9 +126,15 @@ class WorkspaceClient {
107
126
 
108
127
  /**
109
128
  * Post a raw event via POST /v1/events.
129
+ *
130
+ * @param {string} [sessionId] - if given, embedded in event.metadata so
131
+ * the server can reject stale sessions with SessionRevokedError.
110
132
  */
111
- async sendEvent(workspaceId, event, token) {
133
+ async sendEvent(workspaceId, event, token, sessionId) {
112
134
  event.network = workspaceId;
135
+ if (sessionId) {
136
+ event.metadata = { ...(event.metadata || {}), session_id: sessionId };
137
+ }
113
138
  const data = await this._post('/v1/events', event, this._wsHeaders(token));
114
139
  return data.data || data;
115
140
  }
@@ -118,7 +143,7 @@ class WorkspaceClient {
118
143
  * Send a chat message to a workspace channel.
119
144
  */
120
145
  async sendMessage(workspaceId, channelName, token, content, {
121
- senderType = 'agent', senderName, messageType = 'chat', metadata, attachments,
146
+ senderType = 'agent', senderName, messageType = 'chat', metadata, attachments, sessionId,
122
147
  } = {}) {
123
148
  const sourcePrefix = senderType === 'agent' ? 'openagents' : 'human';
124
149
  const source = senderName ? `${sourcePrefix}:${senderName}` : `${sourcePrefix}:unknown`;
@@ -132,7 +157,7 @@ class WorkspaceClient {
132
157
  target: `channel/${channelName}`,
133
158
  payload,
134
159
  metadata: metadata || {},
135
- }, token);
160
+ }, token, sessionId);
136
161
  }
137
162
 
138
163
  /**
@@ -175,24 +200,41 @@ class WorkspaceClient {
175
200
  cursor = events[events.length - 1].id || null;
176
201
  }
177
202
 
178
- // Filter for messages targeted at this agent
203
+ // Filter for messages targeted at this agent.
204
+ //
205
+ // target_agents semantics:
206
+ // • absent → legacy server with no routing decision
207
+ // (broadcast for human messages, ignore for agents)
208
+ // • [...agentNames] → only listed agents should respond
209
+ // • ["__no_response__"]
210
+ // → routing happened and decided nobody
211
+ // should respond. Sentinel is used instead
212
+ // of [] because pre-0.2.106 clients treat
213
+ // empty array as "broadcast" and every
214
+ // agent would reply. The sentinel is
215
+ // non-empty and matches no real agent.
179
216
  const messages = [];
180
217
  for (const e of events) {
181
218
  const source = e.source || '';
182
219
  const meta = e.metadata || {};
183
- const targetAgents = meta.target_agents || [];
220
+ const targetAgents = meta.target_agents;
221
+ const hasTargetList = Array.isArray(targetAgents);
184
222
 
185
223
  // Skip own messages
186
224
  if (source === `openagents:${agentName}`) continue;
187
225
 
188
226
  if (source.startsWith('human:')) {
189
- // Human messages: pick up if targeted at this agent or broadcast
190
- if (!targetAgents.length || targetAgents.includes(agentName)) {
227
+ if (hasTargetList) {
228
+ if (targetAgents.includes(agentName)) {
229
+ messages.push(this._eventToMessage(e));
230
+ }
231
+ } else {
232
+ // Legacy server (no target_agents): broadcast for compat
191
233
  messages.push(this._eventToMessage(e));
192
234
  }
193
235
  } else if (source.startsWith('openagents:')) {
194
- // Agent messages: only pick up if explicitly mentioned
195
- if (targetAgents.includes(agentName)) {
236
+ // Agent messages: only pick up if explicitly listed
237
+ if (hasTargetList && targetAgents.includes(agentName)) {
196
238
  messages.push(this._eventToMessage(e));
197
239
  }
198
240
  }
@@ -545,7 +587,11 @@ class WorkspaceClient {
545
587
  const parsed = JSON.parse(data);
546
588
  if (res.statusCode >= 400) {
547
589
  const msg = parsed.message || `HTTP ${res.statusCode}`;
548
- reject(new Error(msg));
590
+ if (typeof msg === 'string' && msg.toLowerCase().includes('session_revoked')) {
591
+ reject(new SessionRevokedError(msg));
592
+ } else {
593
+ reject(new Error(msg));
594
+ }
549
595
  } else {
550
596
  resolve(parsed);
551
597
  }
@@ -634,4 +680,4 @@ class WorkspaceClient {
634
680
  }
635
681
  }
636
682
 
637
- module.exports = { WorkspaceClient };
683
+ module.exports = { WorkspaceClient, SessionRevokedError };