@openagents-org/agent-launcher 0.2.128 → 0.2.131

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.
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ const DEFAULT_BASE_URL = 'https://api.github.com';
6
+ const USER_AGENT = '@openagents-org/agent-launcher';
7
+
8
+ /**
9
+ * Thin GitHub REST API client used by the launcher's GitHub integration.
10
+ *
11
+ * Auth is via a personal access token (classic or fine-grained) or GitHub
12
+ * App installation token. Each call accepts an explicit token so the caller
13
+ * can resolve it from the launcher's credentials store at request time.
14
+ */
15
+ class GitHubClient {
16
+ constructor(opts = {}) {
17
+ this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
18
+ }
19
+
20
+ /**
21
+ * Verify a token and return identity / rate-limit info.
22
+ * Mirrors `GET /user` + `GET /rate_limit`.
23
+ */
24
+ async probe(token) {
25
+ if (!token) throw new Error('Missing GitHub token');
26
+ const user = await this._get('/user', token);
27
+ const rate = await this._get('/rate_limit', token).catch(() => null);
28
+ return {
29
+ ok: true,
30
+ login: user.login,
31
+ name: user.name,
32
+ avatarUrl: user.avatar_url,
33
+ scopes: user.__scopes || [],
34
+ rate: rate ? rate.rate : null,
35
+ };
36
+ }
37
+
38
+ async getRepo(owner, name, token) {
39
+ return this._get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, token);
40
+ }
41
+
42
+ /**
43
+ * @param {string} owner
44
+ * @param {string} name
45
+ * @param {{ state?: 'open'|'closed'|'all', perPage?: number, page?: number }} opts
46
+ * @param {string} token
47
+ */
48
+ async listIssues(owner, name, opts = {}, token) {
49
+ const state = opts.state || 'open';
50
+ const perPage = opts.perPage || 20;
51
+ const page = opts.page || 1;
52
+ // GitHub's /issues endpoint returns PRs too — filter to issue-only.
53
+ const items = await this._get(
54
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues?state=${state}&per_page=${perPage}&page=${page}`,
55
+ token,
56
+ );
57
+ return items.filter((i) => !i.pull_request);
58
+ }
59
+
60
+ async listPullRequests(owner, name, opts = {}, token) {
61
+ const state = opts.state || 'open';
62
+ const perPage = opts.perPage || 20;
63
+ const page = opts.page || 1;
64
+ return this._get(
65
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?state=${state}&per_page=${perPage}&page=${page}`,
66
+ token,
67
+ );
68
+ }
69
+
70
+ async createIssueComment(owner, name, issueNumber, body, token) {
71
+ return this._post(
72
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${issueNumber}/comments`,
73
+ { body },
74
+ token,
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Parse a GitHub-ish input into { owner, name }.
80
+ * Accepts: "owner/name", "https://github.com/owner/name", "git@github.com:owner/name.git".
81
+ */
82
+ static parseRepo(input) {
83
+ if (!input) return null;
84
+ const v = String(input).trim();
85
+ if (!v) return null;
86
+ // SSH form: git@github.com:owner/name(.git)
87
+ const ssh = v.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
88
+ if (ssh) return { owner: ssh[1], name: ssh[2] };
89
+ // URL form
90
+ try {
91
+ const u = new URL(v);
92
+ if (/github\.com$/i.test(u.hostname)) {
93
+ const parts = u.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
94
+ if (parts.length >= 2 && parts[0] && parts[1]) {
95
+ return { owner: parts[0], name: parts[1] };
96
+ }
97
+ }
98
+ } catch {
99
+ // not a URL — fall through
100
+ }
101
+ // Plain "owner/name"
102
+ const plain = v.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
103
+ if (plain) return { owner: plain[1], name: plain[2] };
104
+ return null;
105
+ }
106
+
107
+ _headers(token, extra = {}) {
108
+ return {
109
+ Accept: 'application/vnd.github+json',
110
+ 'X-GitHub-Api-Version': '2022-11-28',
111
+ 'User-Agent': USER_AGENT,
112
+ Authorization: `Bearer ${token}`,
113
+ ...extra,
114
+ };
115
+ }
116
+
117
+ _get(urlPath, token, timeout = 20000) {
118
+ return this._request('GET', urlPath, null, token, timeout);
119
+ }
120
+
121
+ _post(urlPath, body, token, timeout = 30000) {
122
+ return this._request('POST', urlPath, body, token, timeout);
123
+ }
124
+
125
+ _request(method, urlPath, body, token, timeout) {
126
+ const fullUrl = this.baseUrl + urlPath;
127
+ const jsonBody = body == null ? null : JSON.stringify(body);
128
+ const headers = this._headers(token);
129
+ if (jsonBody != null) headers['Content-Type'] = 'application/json';
130
+
131
+ return new Promise((resolve, reject) => {
132
+ const req = https.request(fullUrl, { method, headers, timeout }, (res) => {
133
+ let data = '';
134
+ res.on('data', (chunk) => { data += chunk; });
135
+ res.on('end', () => {
136
+ const status = res.statusCode || 0;
137
+ let parsed = null;
138
+ try { parsed = data ? JSON.parse(data) : null; } catch { parsed = null; }
139
+ if (status >= 400) {
140
+ const msg = (parsed && (parsed.message || parsed.error)) || `HTTP ${status}`;
141
+ const err = new Error(`GitHub ${method} ${urlPath} failed: ${msg}`);
142
+ err.status = status;
143
+ err.body = parsed;
144
+ return reject(err);
145
+ }
146
+ // Attach OAuth scopes for /user probe convenience.
147
+ if (parsed && method === 'GET' && urlPath === '/user') {
148
+ const scopes = res.headers['x-oauth-scopes'];
149
+ if (typeof scopes === 'string') {
150
+ parsed.__scopes = scopes.split(',').map((s) => s.trim()).filter(Boolean);
151
+ }
152
+ }
153
+ resolve(parsed);
154
+ });
155
+ });
156
+
157
+ req.on('error', reject);
158
+ req.on('timeout', () => { req.destroy(); reject(new Error('GitHub request timed out')); });
159
+ if (jsonBody != null) req.write(jsonBody);
160
+ req.end();
161
+ });
162
+ }
163
+ }
164
+
165
+ module.exports = { GitHubClient };
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ const { Registry } = require('./registry');
6
6
  const { Installer } = require('./installer');
7
7
  const { Daemon } = require('./daemon');
8
8
  const { WorkspaceClient } = require('./workspace-client');
9
+ const { GitHubClient } = require('./github-client');
9
10
 
10
11
  /**
11
12
  * Main entry point for the agent-connector library.
@@ -274,4 +275,4 @@ class AgentConnector {
274
275
  const adapters = require('./adapters');
275
276
 
276
277
  const paths = require('./paths');
277
- module.exports = { AgentConnector, Daemon, WorkspaceClient, adapters, paths };
278
+ module.exports = { AgentConnector, Daemon, WorkspaceClient, GitHubClient, adapters, paths };
package/src/installer.js CHANGED
@@ -433,8 +433,15 @@ class Installer {
433
433
  if (proc.stdout) proc.stdout.setEncoding('utf-8');
434
434
  if (proc.stderr) proc.stderr.setEncoding('utf-8');
435
435
 
436
- if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d); });
437
- if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d); });
436
+ let outputTail = '';
437
+ const captureOutput = (d) => {
438
+ const text = String(d);
439
+ outputTail = (outputTail + text).slice(-4000);
440
+ if (onData) onData(text);
441
+ };
442
+
443
+ if (proc.stdout) proc.stdout.on('data', captureOutput);
444
+ if (proc.stderr) proc.stderr.on('data', captureOutput);
438
445
 
439
446
  proc.on('error', (err) => reject(err));
440
447
  proc.on('close', (code) => {
@@ -443,7 +450,21 @@ class Installer {
443
450
  if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
444
451
  resolve({ success: true, command: cmd });
445
452
  } else {
446
- const msg = `Install failed with exit code ${code}`;
453
+ // A partial install can leave a placeholder stub at
454
+ // bin/<binary>.exe and npm-generated cmd-shims under
455
+ // node_modules/.bin/<name>{,.cmd,.ps1} that point to it. On
456
+ // Windows, anything that later tries to run the binary (a health
457
+ // check, a shell open) triggers a "this app can't run on your PC"
458
+ // dialog because the stub isn't a real PE executable. Sweep them
459
+ // up so the next install (or uninstall+reinstall) starts clean.
460
+ try {
461
+ this._cleanStaleShims(agentType);
462
+ this._cleanStubBinary(agentType);
463
+ } catch {}
464
+ const tail = outputTail.trim();
465
+ const msg = tail
466
+ ? `Install failed with exit code ${code}\nCommand: ${cmd}\n\n${tail}`
467
+ : `Install failed with exit code ${code}\nCommand: ${cmd}`;
447
468
  if (onData) onData(`\n${msg}\n`);
448
469
  reject(new Error(msg));
449
470
  }
@@ -451,6 +472,35 @@ class Installer {
451
472
  });
452
473
  }
453
474
 
475
+ /**
476
+ * Remove the placeholder bin/<binary>.exe (and the npm package directory
477
+ * if it's effectively empty) left over from a failed postinstall.
478
+ */
479
+ _cleanStubBinary(agentType) {
480
+ const entry = this.registry.getEntry(agentType);
481
+ const install = entry && entry.install;
482
+ if (!install) return;
483
+ const binary = install.binary || agentType;
484
+ const npmPkg = install.npm_package
485
+ || (() => {
486
+ const cmd = this._getInstallCommand(install);
487
+ if (!cmd || !cmd.includes('npm install')) return null;
488
+ const m = cmd.match(/npm install\s+(?:-g\s+)?(@?[\w-]+(?:\/[\w-]+)?)(?:@\S*)?$/);
489
+ return m ? m[1] : null;
490
+ })();
491
+ if (!npmPkg) return;
492
+
493
+ const prefix = getRuntimePrefix(agentType);
494
+ const pkgDir = path.join(prefix, 'node_modules', npmPkg);
495
+ const stubDir = path.join(pkgDir, 'bin');
496
+ for (const ext of ['', '.exe', '.cmd', '.ps1']) {
497
+ try {
498
+ const f = path.join(stubDir, binary + ext);
499
+ if (fs.existsSync(f) && fs.statSync(f).size < 4096) fs.unlinkSync(f);
500
+ } catch {}
501
+ }
502
+ }
503
+
454
504
  /**
455
505
  * Uninstall an agent runtime.
456
506
  * @returns {Promise<{success: boolean, output: string}>}
@@ -566,7 +616,7 @@ class Installer {
566
616
  */
567
617
  _getInstallCommand(installCfg) {
568
618
  const plat = Installer.platform();
569
- return installCfg[plat] || installCfg.command || null;
619
+ return installCfg[plat] || installCfg.command || installCfg.npm || null;
570
620
  }
571
621
 
572
622
  /**
@@ -608,7 +658,14 @@ class Installer {
608
658
  _whichBinary(agentType) {
609
659
  const entry = this.registry.getEntry(agentType);
610
660
  const binary = entry && entry.install ? entry.install.binary : agentType;
611
- return whichBinary(binary);
661
+ const aliases = entry && entry.install && Array.isArray(entry.install.binary_aliases)
662
+ ? entry.install.binary_aliases
663
+ : [];
664
+ for (const candidate of [binary, ...aliases]) {
665
+ const found = whichBinary(candidate);
666
+ if (found) return found;
667
+ }
668
+ return null;
612
669
  }
613
670
 
614
671
  // -- Markers --
@@ -680,28 +737,58 @@ class Installer {
680
737
  const extraDirs = [];
681
738
  try { extraDirs.push(path.dirname(process.execPath)); } catch {}
682
739
 
683
- // Check for bundled Node.js in ~/.openagents/nodejs/
740
+ // Whether a usable `node` is already reachable WITHOUT the bundled
741
+ // standalone node.exe. We must avoid shadowing a working system node with
742
+ // the launcher's bundled exe: that file is an unsigned standalone
743
+ // node.exe and on some Windows machines Defender/SmartScreen blocks
744
+ // CreateProcess on it, which surfaces as `cmd: 拒绝访问` ("Access
745
+ // Denied") when npm runs `node install.cjs` for packages like
746
+ // @anthropic-ai/claude-code, plus a "this app can't run on your PC"
747
+ // dialog.
748
+ const bundledDir = path.join(this.configDir, 'nodejs');
749
+ const hasSystemNode = this._hasSystemNode(bundledDir);
750
+
684
751
  if (process.platform === 'win32') {
685
- try {
686
- const bundledDir = path.join(this.configDir, 'nodejs');
687
- if (fs.existsSync(bundledDir)) {
688
- // Node.js may be directly in nodejs/ (launcher symlink style) or in nodejs/node-v*/
689
- if (fs.existsSync(path.join(bundledDir, 'node.exe'))) {
690
- extraDirs.push(bundledDir);
691
- }
692
- const entries = fs.readdirSync(bundledDir).filter(e => e.startsWith('node-'));
693
- if (entries.length > 0) {
694
- extraDirs.push(path.join(bundledDir, entries[0]));
695
- }
696
- }
697
- } catch {}
698
752
  const appData = env.APPDATA || '';
699
753
  if (appData) extraDirs.push(path.join(appData, 'npm'));
700
754
  extraDirs.push(env.ProgramFiles ? path.join(env.ProgramFiles, 'nodejs') : 'C:\\Program Files\\nodejs');
701
755
  extraDirs.push(env.SystemRoot ? path.join(env.SystemRoot, 'System32') : 'C:\\Windows\\System32');
702
756
  extraDirs.push(env.ProgramFiles ? path.join(env.ProgramFiles, 'Git', 'cmd') : 'C:\\Program Files\\Git\\cmd');
757
+
758
+ // Bundled node — fallback only. Skip if system node works, otherwise
759
+ // verify the bundled exe can actually launch before exposing it.
760
+ if (!hasSystemNode) {
761
+ try {
762
+ if (fs.existsSync(bundledDir)) {
763
+ const directExe = path.join(bundledDir, 'node.exe');
764
+ if (fs.existsSync(directExe) && this._canExecute(directExe)) {
765
+ extraDirs.unshift(bundledDir);
766
+ }
767
+ for (const entry of fs.readdirSync(bundledDir).filter(e => e.startsWith('node-'))) {
768
+ const nested = path.join(bundledDir, entry);
769
+ if (fs.existsSync(path.join(nested, 'node.exe')) && this._canExecute(path.join(nested, 'node.exe'))) {
770
+ extraDirs.unshift(nested);
771
+ }
772
+ }
773
+ }
774
+ } catch {}
775
+ }
703
776
  } else {
704
777
  extraDirs.push('/usr/local/bin', '/opt/homebrew/bin');
778
+ if (!hasSystemNode) {
779
+ try {
780
+ const candidates = [
781
+ path.join(bundledDir, 'bin', 'node'),
782
+ path.join(bundledDir, 'node'),
783
+ ];
784
+ for (const bin of candidates) {
785
+ if (fs.existsSync(bin) && this._canExecute(bin)) {
786
+ extraDirs.unshift(path.dirname(bin));
787
+ break;
788
+ }
789
+ }
790
+ } catch {}
791
+ }
705
792
  }
706
793
  for (const d of extraDirs) {
707
794
  if (d && !(env.PATH || '').includes(d)) {
@@ -711,6 +798,51 @@ class Installer {
711
798
  return env;
712
799
  }
713
800
 
801
+ /**
802
+ * True if `node` exists outside the bundled launcher directory. Uses raw
803
+ * process.env.PATH (no enhancement) so we don't accidentally detect the
804
+ * bundled node and conclude that "system node exists".
805
+ */
806
+ _hasSystemNode(bundledDir) {
807
+ try {
808
+ const { execFileSync } = require('child_process');
809
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
810
+ const args = process.platform === 'win32' ? ['node'] : ['node'];
811
+ const out = execFileSync(cmd, args, {
812
+ encoding: 'utf-8',
813
+ stdio: ['ignore', 'pipe', 'ignore'],
814
+ timeout: 5000,
815
+ windowsHide: true,
816
+ env: { ...process.env },
817
+ });
818
+ const prefix = (bundledDir || '').toLowerCase();
819
+ for (const line of out.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
820
+ if (!prefix || !line.toLowerCase().startsWith(prefix)) return true;
821
+ }
822
+ return false;
823
+ } catch {
824
+ return false;
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Smoke-test a node binary by running `--version`. Returns false on any
830
+ * failure (missing exe, blocked by AV, arch mismatch, signature policy).
831
+ */
832
+ _canExecute(binaryPath) {
833
+ try {
834
+ const { spawnSync } = require('child_process');
835
+ const r = spawnSync(binaryPath, ['--version'], {
836
+ timeout: 5000,
837
+ windowsHide: true,
838
+ stdio: ['ignore', 'pipe', 'pipe'],
839
+ });
840
+ return r.status === 0 && !r.error;
841
+ } catch {
842
+ return false;
843
+ }
844
+ }
845
+
714
846
  /**
715
847
  * Check if Node.js/npm is available on the system.
716
848
  */
package/src/mcp-server.js CHANGED
@@ -301,21 +301,26 @@ function buildToolDefs(disabledModules) {
301
301
  },
302
302
  {
303
303
  name: 'workspace_create_routine',
304
- description: 'Create a recurring scheduled routine that posts a message on a repeating schedule.',
304
+ description: 'Create a recurring scheduled routine. Each routine gets its own dedicated thread with full context. Two schedule modes: daily (hour+minute, optional days) or interval (every N minutes).',
305
305
  inputSchema: {
306
306
  type: 'object',
307
307
  properties: {
308
308
  name: { type: 'string', description: 'Human-readable label for the routine (e.g. "Daily PR Review")' },
309
- message: { type: 'string', description: 'Message to post each time the routine fires' },
310
- hour: { type: 'integer', description: 'Hour in UTC (0-23)' },
311
- minute: { type: 'integer', description: 'Minute (0-59)' },
309
+ message: { type: 'string', description: 'Short trigger message posted each time the routine fires' },
310
+ context: { type: 'string', description: 'Comprehensive context for the routine: what it should do, background info, goals, relevant details from the current conversation. This is posted at the start of the routine\'s dedicated thread so you have full background every time it fires. Be thorough — this is your only memory of why this routine exists.' },
311
+ hour: { type: 'integer', description: 'Daily mode: hour in UTC (0-23). Omit if using interval_minutes.' },
312
+ minute: { type: 'integer', description: 'Daily mode: minute (0-59). Omit if using interval_minutes.' },
312
313
  days: {
313
314
  type: 'array',
314
315
  items: { type: 'integer' },
315
- description: 'Days of week to fire (0=Mon, 6=Sun). Omit for every day.',
316
+ description: 'Daily mode: days of week to fire (0=Mon, 6=Sun). Omit for every day. Not allowed in interval mode.',
317
+ },
318
+ interval_minutes: {
319
+ type: 'integer',
320
+ description: 'Interval mode: fire every N minutes (1-1440). Mutually exclusive with hour/minute.',
316
321
  },
317
322
  },
318
- required: ['name', 'message', 'hour', 'minute'],
323
+ required: ['name', 'message', 'context'],
319
324
  },
320
325
  },
321
326
  {
@@ -451,7 +456,14 @@ class McpServer {
451
456
  if (mt === 'status') return null;
452
457
  const sender = m.senderName || m.senderType || '';
453
458
  const content = m.content || '';
454
- return `[${sender}] ${content}`;
459
+ let line = `[${sender}] ${content}`;
460
+ if (m.attachments && m.attachments.length > 0) {
461
+ const atts = m.attachments.map((a) =>
462
+ `[Attached: ${a.filename || 'file'} (file_id: ${a.fileId || '?'})]`
463
+ ).join(' ');
464
+ line += `\n ${atts}`;
465
+ }
466
+ return line;
455
467
  }).filter(Boolean);
456
468
  if (!lines.length) return text('No messages yet.');
457
469
  return text(lines.join('\n'));
@@ -753,25 +765,41 @@ class McpServer {
753
765
  {
754
766
  name: args.name,
755
767
  message: args.message,
768
+ context: args.context,
756
769
  hour: args.hour,
757
770
  minute: args.minute,
758
771
  days: args.days,
772
+ interval_minutes: args.interval_minutes,
759
773
  source: `openagents:${this.agentName}`,
760
774
  },
761
775
  );
762
- const daysStr = args.days ? `days [${args.days.join(',')}]` : 'every day';
763
- return text(`Routine created: "${args.name}" at ${String(args.hour).padStart(2,'0')}:${String(args.minute).padStart(2,'0')} UTC, ${daysStr} (id: ${result.id})`);
776
+ let scheduleStr;
777
+ if (args.interval_minutes != null) {
778
+ scheduleStr = `every ${args.interval_minutes} min`;
779
+ } else {
780
+ const daysStr = args.days ? `days [${args.days.join(',')}]` : 'every day';
781
+ scheduleStr = `at ${String(args.hour).padStart(2,'0')}:${String(args.minute).padStart(2,'0')} UTC, ${daysStr}`;
782
+ }
783
+ const channel = (result && result.channel_name) || `routine:${result.id}`;
784
+ return text(
785
+ `Routine created: "${args.name}" ${scheduleStr} in dedicated thread \`${channel}\` (id: ${result.id})`,
786
+ );
764
787
  }
765
788
 
766
789
  case 'workspace_list_routines': {
767
790
  const data = await this.ws.listRoutines(this.workspaceId, this.channelName, this.token);
768
791
  const routines = (data && data.routines) || [];
769
792
  if (!routines.length) return text('No active routines.');
770
- const lines = routines.map((r) =>
771
- `- ${r.id}: "${r.name}" at ${String(r.schedule_hour).padStart(2,'0')}:${String(r.schedule_minute).padStart(2,'0')} UTC` +
772
- (r.schedule_days ? ` [days: ${r.schedule_days.join(',')}]` : ' (daily)') +
773
- ` — next: ${r.next_fires_at} (by ${r.created_by})`
774
- );
793
+ const lines = routines.map((r) => {
794
+ let when;
795
+ if (r.schedule_interval_minutes != null) {
796
+ when = `every ${r.schedule_interval_minutes} min`;
797
+ } else {
798
+ when = `at ${String(r.schedule_hour).padStart(2,'0')}:${String(r.schedule_minute).padStart(2,'0')} UTC` +
799
+ (r.schedule_days ? ` [days: ${r.schedule_days.join(',')}]` : ' (daily)');
800
+ }
801
+ return `- ${r.id}: "${r.name}" ${when} — next: ${r.next_fires_at} (by ${r.created_by})`;
802
+ });
775
803
  return text(lines.join('\n'));
776
804
  }
777
805
 
package/src/paths.js CHANGED
@@ -16,12 +16,25 @@ const IS_WINDOWS = process.platform === 'win32';
16
16
  const IS_MACOS = process.platform === 'darwin';
17
17
  const SEP = IS_WINDOWS ? ';' : ':';
18
18
  const HOME = process.env.HOME || process.env.USERPROFILE || '';
19
+ const PATH_LOOKUP_CACHE_TTL_MS = 30 * 1000;
20
+
21
+ let extraBinDirsCache = { value: null, at: 0, path: '' };
22
+ const whichBinaryCache = new Map();
19
23
 
20
24
  /**
21
25
  * Get all extra binary directories that should be checked beyond process.env.PATH.
22
26
  * Returns deduplicated list of existing directories.
23
27
  */
24
28
  function getExtraBinDirs() {
29
+ const currentPATH = process.env.PATH || '';
30
+ if (
31
+ extraBinDirsCache.value &&
32
+ extraBinDirsCache.path === currentPATH &&
33
+ Date.now() - extraBinDirsCache.at < PATH_LOOKUP_CACHE_TTL_MS
34
+ ) {
35
+ return [...extraBinDirsCache.value];
36
+ }
37
+
25
38
  const dirs = [];
26
39
 
27
40
  if (IS_WINDOWS) {
@@ -62,8 +75,7 @@ function getExtraBinDirs() {
62
75
 
63
76
  // Filter to existing directories only, deduplicate
64
77
  const seen = new Set();
65
- const currentPATH = process.env.PATH || '';
66
- return dirs.filter(d => {
78
+ const value = dirs.filter(d => {
67
79
  if (!d || seen.has(d)) return false;
68
80
  // Skip if already in PATH (case-insensitive on Windows)
69
81
  if (IS_WINDOWS ? currentPATH.toLowerCase().includes(d.toLowerCase()) : currentPATH.includes(d)) return false;
@@ -74,6 +86,8 @@ function getExtraBinDirs() {
74
86
  return false;
75
87
  }
76
88
  });
89
+ extraBinDirsCache = { value, at: Date.now(), path: currentPATH };
90
+ return [...value];
77
91
  }
78
92
 
79
93
  /**
@@ -114,6 +128,13 @@ function getEnhancedEnv(baseEnv) {
114
128
  */
115
129
  function whichBinary(name) {
116
130
  if (!name) return null;
131
+ const currentPATH = process.env.PATH || '';
132
+ const cacheKey = `${name}\0${currentPATH}`;
133
+ const cached = whichBinaryCache.get(cacheKey);
134
+ if (cached && Date.now() - cached.at < PATH_LOOKUP_CACHE_TTL_MS) {
135
+ return cached.value;
136
+ }
137
+
117
138
  const cmd = IS_WINDOWS ? `where ${name}` : `which ${name}`;
118
139
  try {
119
140
  const result = execSync(cmd, {
@@ -121,9 +142,13 @@ function whichBinary(name) {
121
142
  stdio: ['pipe', 'pipe', 'pipe'],
122
143
  env: { ...process.env, PATH: getEnhancedPATH() },
123
144
  timeout: 5000,
145
+ windowsHide: true,
124
146
  }).trim();
125
- return result.split(/\r?\n/)[0] || null;
147
+ const value = result.split(/\r?\n/)[0] || null;
148
+ whichBinaryCache.set(cacheKey, { value, at: Date.now() });
149
+ return value;
126
150
  } catch {
151
+ whichBinaryCache.set(cacheKey, { value: null, at: Date.now() });
127
152
  return null;
128
153
  }
129
154
  }
@@ -154,6 +179,9 @@ function _addWindowsPaths(dirs) {
154
179
  // Portable Node.js installed by OpenAgents Launcher
155
180
  _push(dirs, path.join(HOME, '.openagents', 'nodejs'));
156
181
 
182
+ // Cursor CLI native installer
183
+ _push(dirs, path.join(HOME, '.cursor', 'bin'));
184
+
157
185
  // Node.js install
158
186
  _push(dirs, path.join(programFiles, 'nodejs'));
159
187
 
package/src/registry.js CHANGED
@@ -85,7 +85,7 @@ class Registry {
85
85
 
86
86
  /**
87
87
  * Merge bundled env_config/resolve_env/install into catalog entries.
88
- * Remote/cached entries may lack these fields.
88
+ * Remote/cached entries may lack these fields or carry stale install data.
89
89
  */
90
90
  _mergeBundled(catalog) {
91
91
  const bundled = this._loadBundled();
@@ -94,7 +94,7 @@ class Registry {
94
94
  if (b) {
95
95
  if ((!entry.env_config || entry.env_config.length === 0) && b.env_config && b.env_config.length > 0) entry.env_config = b.env_config;
96
96
  if (!entry.resolve_env && b.resolve_env) entry.resolve_env = b.resolve_env;
97
- if (!entry.install && b.install) entry.install = b.install;
97
+ if (b.install) entry.install = { ...b.install };
98
98
  if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
99
99
  if (!entry.launch && b.launch) entry.launch = b.launch;
100
100
  // Always take featured/order/support from bundled (source of truth for ordering)
@@ -136,11 +136,11 @@ class Registry {
136
136
  const catalog = this.getCatalogSync();
137
137
  const entry = catalog.find((e) => e.name === agentType) || null;
138
138
  if (!entry) return null;
139
- // Merge missing fields from bundled registry
139
+ // Merge bundled registry fields into cached/remote entries.
140
140
  const bundled = this._loadBundled();
141
141
  const b = bundled.find((e) => e.name === agentType);
142
142
  if (b) {
143
- if (!entry.install && b.install) entry.install = b.install;
143
+ if (b.install) entry.install = { ...b.install };
144
144
  if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
145
145
  if (!entry.launch && b.launch) entry.launch = b.launch;
146
146
  if ((!entry.env_config || !entry.env_config.length) && b.env_config?.length) entry.env_config = b.env_config;
package/src/tui.js CHANGED
@@ -120,25 +120,10 @@ function describeHealth(health) {
120
120
  }
121
121
 
122
122
  function loadCatalog(connector) {
123
- const { execSync } = require('child_process');
124
123
  const entries = connector.registry.getCatalogSync();
125
124
  return entries.map(e => {
126
125
  let installed = false;
127
-
128
- // If a verify command exists, use it for accurate detection
129
- const verifyCmd = IS_WINDOWS ? (e.install && e.install.verify_win) : (e.install && e.install.verify);
130
- if (verifyCmd) {
131
- try { execSync(verifyCmd, { stdio: 'ignore', timeout: 5000 }); installed = true; } catch {}
132
- } else {
133
- try { const { whichBinary } = require('./paths'); installed = !!whichBinary((e.install && e.install.binary) || e.name); } catch {}
134
- }
135
-
136
- if (!installed) {
137
- try {
138
- const f = path.join(connector._configDir, 'installed_agents.json');
139
- if (fs.existsSync(f)) installed = !!JSON.parse(fs.readFileSync(f, 'utf-8'))[e.name];
140
- } catch {}
141
- }
126
+ try { installed = !!connector.installer.getInstallInfo(e.name).installed; } catch {}
142
127
  return {
143
128
  name: e.name,
144
129
  label: e.label || e.name,