@openagents-org/agent-launcher 0.2.109 → 0.2.111

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.109",
3
+ "version": "0.2.111",
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
+ ]
@@ -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;
package/src/cli.js CHANGED
@@ -459,6 +459,28 @@ async function cmdVersion() {
459
459
  print(`${pkg.name} v${pkg.version}`);
460
460
  }
461
461
 
462
+ async function cmdUpdate() {
463
+ const { checkForUpdate, runUpdate, currentVersion } = require('./update-check');
464
+ const info = await checkForUpdate();
465
+ if (!info) {
466
+ print('Could not reach the npm registry. Check your network.');
467
+ process.exitCode = 1;
468
+ return;
469
+ }
470
+ if (!info.isNewer) {
471
+ print(`Already on the latest version (${currentVersion()}).`);
472
+ return;
473
+ }
474
+ print(`Updating ${info.current} → ${info.latest}...`);
475
+ const ok = runUpdate();
476
+ if (!ok) {
477
+ print('Update failed.');
478
+ process.exitCode = 1;
479
+ return;
480
+ }
481
+ print(`Updated to ${info.latest}.`);
482
+ }
483
+
462
484
  async function cmdHelp() {
463
485
  print(`Usage: agn <command> [options]
464
486
 
@@ -485,6 +507,7 @@ Commands:
485
507
  workspace join <token> Join workspace with token
486
508
  workspace list List configured workspaces
487
509
  mcp-server Start MCP server (stdio) for workspace tools
510
+ update Upgrade launcher to the latest npm release
488
511
  version Show version
489
512
  help Show this help
490
513
 
@@ -503,6 +526,26 @@ async function main() {
503
526
  if (cmd === 'help' || flags.help) { await cmdHelp(); return; }
504
527
  if (cmd === 'version' || flags.version) { await cmdVersion(); return; }
505
528
 
529
+ // Check for a newer launcher version and offer to install it. Skip for:
530
+ // - mcp-server: JSON-RPC subprocess spawned by Claude Code
531
+ // - up --foreground: the backgrounded daemon child
532
+ // - tui / auto-TUI: interactive UI manages its own rendering
533
+ // - update: already updating; avoid recursion
534
+ const skipUpdateCheck =
535
+ cmd === 'mcp-server' ||
536
+ (cmd === 'up' && flags.foreground) ||
537
+ cmd === 'tui' ||
538
+ cmd === 'update' ||
539
+ flags['no-update-check'] ||
540
+ process.env.OPENAGENTS_SKIP_UPDATE_CHECK === '1' ||
541
+ (cmd === 'status' && process.argv.length <= 2 && process.stdin.isTTY);
542
+ if (!skipUpdateCheck) {
543
+ try {
544
+ const { notifyAndMaybeUpdate } = require('./update-check');
545
+ await notifyAndMaybeUpdate();
546
+ } catch {}
547
+ }
548
+
506
549
  const connector = getConnector(flags);
507
550
 
508
551
  // Launch TUI if command is 'tui' or no command with interactive terminal
@@ -542,6 +585,7 @@ async function main() {
542
585
  workspace: () => cmdWorkspace(connector, flags, positional),
543
586
  env: () => cmdEnv(connector, flags, positional),
544
587
  'test-llm': () => cmdTestLLM(connector, flags, positional),
588
+ update: () => cmdUpdate(),
545
589
  'mcp-server': () => {
546
590
  const { runMcpServer } = require('./mcp-server');
547
591
  const workspaceId = flags['workspace-id'] || process.env.OPENAGENTS_WORKSPACE_ID;
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/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
  }
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Update check + prompt flow.
5
+ *
6
+ * Queries the npm registry for the latest @openagents-org/agent-launcher
7
+ * version and, if newer than the running version, prints a notification
8
+ * and (for TTY invocations) prompts the user to update in place.
9
+ */
10
+
11
+ const https = require('https');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const readline = require('readline');
16
+ const { execSync, spawnSync } = require('child_process');
17
+
18
+ const PKG_NAME = '@openagents-org/agent-launcher';
19
+ const CACHE_FILE = path.join(os.homedir(), '.openagents', '.update-check.json');
20
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
21
+
22
+ function currentVersion() {
23
+ return require('../package.json').version;
24
+ }
25
+
26
+ function compareSemver(a, b) {
27
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
28
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
29
+ for (let i = 0; i < 3; i++) {
30
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
31
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ function loadCache() {
37
+ try {
38
+ const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
39
+ if (data && data.latest && data.checkedAt &&
40
+ Date.now() - data.checkedAt < CACHE_TTL_MS) {
41
+ return data.latest;
42
+ }
43
+ } catch {}
44
+ return null;
45
+ }
46
+
47
+ function saveCache(latest) {
48
+ try {
49
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
50
+ fs.writeFileSync(CACHE_FILE, JSON.stringify({ latest, checkedAt: Date.now() }));
51
+ } catch {}
52
+ }
53
+
54
+ function fetchLatest(timeoutMs = 2500) {
55
+ return new Promise((resolve) => {
56
+ const req = https.request(
57
+ `https://registry.npmjs.org/${PKG_NAME}/latest`,
58
+ { method: 'GET', timeout: timeoutMs, headers: { Accept: 'application/json' } },
59
+ (res) => {
60
+ let body = '';
61
+ res.on('data', (c) => { body += c; });
62
+ res.on('end', () => {
63
+ try {
64
+ const parsed = JSON.parse(body);
65
+ resolve(parsed.version || null);
66
+ } catch { resolve(null); }
67
+ });
68
+ }
69
+ );
70
+ req.on('error', () => resolve(null));
71
+ req.on('timeout', () => { req.destroy(); resolve(null); });
72
+ req.end();
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Return { current, latest, isNewer } or null if the check failed / offline.
78
+ */
79
+ async function checkForUpdate() {
80
+ const current = currentVersion();
81
+ let latest = loadCache();
82
+ if (!latest) {
83
+ latest = await fetchLatest();
84
+ if (latest) saveCache(latest);
85
+ }
86
+ if (!latest) return null;
87
+ return { current, latest, isNewer: compareSemver(latest, current) > 0 };
88
+ }
89
+
90
+ /**
91
+ * Walk up from __dirname until we find the enclosing node_modules; the
92
+ * directory containing node_modules is the install prefix.
93
+ */
94
+ function detectInstallPrefix() {
95
+ let dir = __dirname;
96
+ for (let i = 0; i < 10; i++) {
97
+ const parent = path.dirname(dir);
98
+ if (parent === dir) break;
99
+ if (path.basename(parent) === 'node_modules') {
100
+ return path.dirname(parent);
101
+ }
102
+ dir = parent;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function findNpmBin() {
108
+ const nodeDir = path.dirname(process.execPath);
109
+ for (const name of ['npm', 'npm.cmd']) {
110
+ const candidate = path.join(nodeDir, name);
111
+ if (fs.existsSync(candidate)) return candidate;
112
+ }
113
+ try {
114
+ return execSync(process.platform === 'win32' ? 'where npm' : 'which npm', {
115
+ encoding: 'utf-8', timeout: 3000,
116
+ }).trim().split(/\r?\n/)[0];
117
+ } catch { return 'npm'; }
118
+ }
119
+
120
+ /**
121
+ * Run npm install to update to the latest version. Blocking.
122
+ * Returns true on success.
123
+ */
124
+ function runUpdate() {
125
+ const prefix = detectInstallPrefix();
126
+ const npmBin = findNpmBin();
127
+ const args = ['install', '--no-save', `${PKG_NAME}@latest`];
128
+ if (prefix) args.push('--prefix', prefix);
129
+ else args.push('-g');
130
+ process.stderr.write(`[launcher] Running: ${npmBin} ${args.join(' ')}\n`);
131
+ const r = spawnSync(npmBin, args, { stdio: 'inherit' });
132
+ return r.status === 0;
133
+ }
134
+
135
+ /**
136
+ * Prompt for a Y/n keystroke. Defaults to Y on empty input.
137
+ * Times out after `timeoutMs` and returns false.
138
+ */
139
+ function promptYes(question, timeoutMs = 30000) {
140
+ return new Promise((resolve) => {
141
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return resolve(false);
142
+ process.stderr.write(question);
143
+ let answered = false;
144
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
145
+ const timer = setTimeout(() => {
146
+ if (answered) return;
147
+ answered = true;
148
+ process.stderr.write('\n');
149
+ rl.close();
150
+ resolve(false);
151
+ }, timeoutMs);
152
+ rl.question('', (ans) => {
153
+ if (answered) return;
154
+ answered = true;
155
+ clearTimeout(timer);
156
+ rl.close();
157
+ const a = (ans || '').trim().toLowerCase();
158
+ resolve(a === '' || a === 'y' || a === 'yes');
159
+ });
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Check, notify, and offer to update. Exits the process on successful update.
165
+ * Safe to call at the top of any user-facing CLI command.
166
+ */
167
+ async function notifyAndMaybeUpdate() {
168
+ let info;
169
+ try { info = await checkForUpdate(); } catch { return; }
170
+ if (!info || !info.isNewer) return;
171
+
172
+ process.stderr.write(
173
+ `\n[launcher] Update available: ${info.current} → ${info.latest}\n`
174
+ );
175
+
176
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
177
+ if (!interactive) {
178
+ process.stderr.write(
179
+ '[launcher] Run `agn update` (or re-run install.sh) to upgrade.\n\n'
180
+ );
181
+ return;
182
+ }
183
+
184
+ const accepted = await promptYes('[launcher] Update now? [Y/n] ');
185
+ if (!accepted) {
186
+ process.stderr.write('[launcher] Skipped. Run `agn update` later to upgrade.\n\n');
187
+ return;
188
+ }
189
+
190
+ const ok = runUpdate();
191
+ if (ok) {
192
+ process.stderr.write(`[launcher] Updated to ${info.latest}. Re-run your command.\n`);
193
+ process.exit(0);
194
+ }
195
+ process.stderr.write('[launcher] Update failed — continuing with current version.\n\n');
196
+ }
197
+
198
+ module.exports = {
199
+ checkForUpdate,
200
+ notifyAndMaybeUpdate,
201
+ runUpdate,
202
+ currentVersion,
203
+ };