@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.
- package/package.json +1 -1
- package/registry.json +19 -22
- package/src/adapters/base.js +228 -9
- package/src/adapters/claude.js +30 -3
- package/src/adapters/cursor.js +618 -9
- package/src/adapters/utils.js +34 -10
- package/src/adapters/workspace-prompt.js +271 -83
- package/src/github-client.js +165 -0
- package/src/index.js +2 -1
- package/src/installer.js +151 -19
- package/src/mcp-server.js +42 -14
- package/src/paths.js +31 -3
- package/src/registry.js +4 -4
- package/src/tui.js +1 -16
- package/src/workspace-client.js +59 -6
|
@@ -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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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: '
|
|
310
|
-
|
|
311
|
-
|
|
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: '
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
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
|
-
|
|
772
|
-
(r.
|
|
773
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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 (
|
|
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,
|