ai-agentmanager 1.3.1 → 1.4.0

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/src/studio.js ADDED
@@ -0,0 +1,255 @@
1
+ // The studio: browse and edit an agent's files from the app (a file tree and an editor), search
2
+ // them, compare a file with where the agent started, and open the folder in VS Code or Cursor.
3
+ // Every path is resolved inside the agent's folder; links that point outside it are refused.
4
+ import { execFile, spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import * as git from './git.js';
8
+ import { NO_REPO } from './manager.js';
9
+
10
+ const err = (status, message) => Object.assign(new Error(message), { status });
11
+ export const MAX_FILE_BYTES = 2_000_000;
12
+ // Listed but not opened by default: noisy, huge or generated.
13
+ export const HIDDEN = new Set(['.git', 'node_modules', '.DS_Store', '__pycache__', '.venv', 'dist', 'build', '.next', 'target', 'coverage']);
14
+ const EDITORS = {
15
+ vscode: { label: 'VS Code', bins: ['code', '/usr/local/bin/code', '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'] },
16
+ cursor: { label: 'Cursor', bins: ['cursor', '/usr/local/bin/cursor', '/Applications/Cursor.app/Contents/Resources/app/bin/cursor'] },
17
+ };
18
+
19
+ /**
20
+ * `rel` inside `root`, as an absolute path, or an error. Refuses "..", absolute paths and
21
+ * symlinks that resolve outside the folder (checked on the nearest part that exists).
22
+ */
23
+ export function resolveInside(root, rel = '') {
24
+ const realRoot = fs.realpathSync(root);
25
+ const clean = String(rel ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
26
+ if (clean.split('/').includes('..')) throw err(400, 'Paths may not contain ".."');
27
+ const abs = path.join(realRoot, clean);
28
+ let probe = abs;
29
+ while (!fs.existsSync(probe) && probe !== realRoot) probe = path.dirname(probe);
30
+ const real = fs.realpathSync(probe);
31
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) throw err(403, 'That path is outside the agent folder');
32
+ return abs;
33
+ }
34
+
35
+ const statOf = (abs, rel) => {
36
+ try {
37
+ return fs.statSync(abs);
38
+ } catch {
39
+ throw err(404, `${rel || 'That path'} doesn't exist`);
40
+ }
41
+ };
42
+
43
+ /** True when the bytes look like text (no NUL in the first 8 KB). */
44
+ export const isText = (buf) => !buf.subarray(0, 8192).includes(0);
45
+
46
+ /** A language id for the editor from a file name. */
47
+ export function languageOf(name) {
48
+ const ext = path.extname(name).toLowerCase().slice(1);
49
+ const base = path.basename(name).toLowerCase();
50
+ if (base === 'dockerfile') return 'dockerfile';
51
+ if (base === 'makefile') return 'makefile';
52
+ const map = {
53
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', json: 'json', md: 'markdown',
54
+ py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', swift: 'swift', c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp',
55
+ cs: 'csharp', php: 'php', sh: 'shell', bash: 'shell', zsh: 'shell', yml: 'yaml', yaml: 'yaml', toml: 'ini', ini: 'ini', html: 'html', css: 'css',
56
+ scss: 'scss', less: 'less', sql: 'sql', xml: 'xml', svg: 'xml', vue: 'html', graphql: 'graphql', lua: 'lua', r: 'r', scala: 'scala', dart: 'dart',
57
+ };
58
+ return map[ext] ?? 'plaintext';
59
+ }
60
+
61
+ const which = (bin) => new Promise((resolve) => (bin.startsWith('/') ? resolve(fs.existsSync(bin) ? bin : null) : execFile('/usr/bin/env', ['which', bin], (e, out) => resolve(e ? null : out.trim() || null))));
62
+
63
+ export class Studio {
64
+ constructor({ manager, review }) {
65
+ this.manager = manager;
66
+ this.review = review;
67
+ }
68
+
69
+ agentDir(ref) {
70
+ const agent = this.manager.mustGet(ref);
71
+ if (NO_REPO.has(agent.kind) && !agent.workdir) throw err(400, 'This session has no folder');
72
+ if (!agent.workdir || !fs.existsSync(agent.workdir)) throw err(409, 'The agent folder is not ready yet');
73
+ return { agent, root: agent.workdir };
74
+ }
75
+
76
+ check(agent, action, details = {}) {
77
+ this.manager.plugins.check(action, { agent: this.manager.auditView(agent), ...details });
78
+ }
79
+
80
+ /** One folder's entries: folders first, then files, by name. */
81
+ list(ref, dir = '') {
82
+ const { root } = this.agentDir(ref);
83
+ const abs = resolveInside(root, dir);
84
+ if (!statOf(abs, dir).isDirectory()) throw err(400, 'Not a folder');
85
+ const entries = fs
86
+ .readdirSync(abs, { withFileTypes: true })
87
+ .map((d) => {
88
+ const rel = path.posix.join(String(dir).replace(/^\/+|\/+$/g, ''), d.name);
89
+ let type = d.isDirectory() ? 'dir' : 'file';
90
+ let size = null;
91
+ if (d.isSymbolicLink()) {
92
+ try {
93
+ const st = fs.statSync(resolveInside(root, rel));
94
+ type = st.isDirectory() ? 'dir' : 'file';
95
+ size = st.isFile() ? st.size : null;
96
+ } catch {
97
+ type = 'link';
98
+ }
99
+ } else if (type === 'file') {
100
+ try {
101
+ size = fs.statSync(path.join(abs, d.name)).size;
102
+ } catch {}
103
+ }
104
+ return { name: d.name, path: rel, type, size, hidden: HIDDEN.has(d.name) };
105
+ })
106
+ .sort((a, b) => (a.type === 'dir') - (b.type === 'dir') ? (b.type === 'dir') - (a.type === 'dir') : a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
107
+ return { root, dir: String(dir).replace(/^\/+|\/+$/g, ''), entries };
108
+ }
109
+
110
+ read(ref, rel) {
111
+ const { root } = this.agentDir(ref);
112
+ const abs = resolveInside(root, rel);
113
+ const st = statOf(abs, rel);
114
+ if (!st.isFile()) throw err(400, 'Not a file');
115
+ const base = { path: rel, size: st.size, mtimeMs: st.mtimeMs, language: languageOf(rel) };
116
+ if (st.size > MAX_FILE_BYTES) return { ...base, tooLarge: true, max: MAX_FILE_BYTES };
117
+ const buf = fs.readFileSync(abs);
118
+ if (!isText(buf)) return { ...base, binary: true };
119
+ return { ...base, content: buf.toString('utf8') };
120
+ }
121
+
122
+ /** Save a file. With `mtimeMs` (from when it was opened), refuses if it changed since. */
123
+ write(ref, rel, content, { mtimeMs = null, force = false } = {}) {
124
+ const { agent, root } = this.agentDir(ref);
125
+ if (typeof content !== 'string') throw err(400, 'content (string) is required');
126
+ if (Buffer.byteLength(content) > MAX_FILE_BYTES) throw err(413, `Files over ${MAX_FILE_BYTES / 1e6} MB can't be saved from the editor`);
127
+ const abs = resolveInside(root, rel);
128
+ this.check(agent, 'agent.files', { op: 'write', path: rel });
129
+ if (fs.existsSync(abs)) {
130
+ const st = fs.statSync(abs);
131
+ if (!st.isFile()) throw err(400, 'Not a file');
132
+ if (mtimeMs != null && !force && Math.abs(st.mtimeMs - Number(mtimeMs)) > 1) {
133
+ throw Object.assign(err(409, 'The file changed on disk since you opened it (the agent may have edited it)'), { conflict: true });
134
+ }
135
+ }
136
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
137
+ fs.writeFileSync(abs, content);
138
+ const st = fs.statSync(abs);
139
+ return { path: rel, size: st.size, mtimeMs: st.mtimeMs };
140
+ }
141
+
142
+ create(ref, rel, type = 'file') {
143
+ const { agent, root } = this.agentDir(ref);
144
+ if (!String(rel ?? '').trim()) throw err(400, 'A name is required');
145
+ const abs = resolveInside(root, rel);
146
+ if (fs.existsSync(abs)) throw err(409, `${rel} already exists`);
147
+ this.check(agent, 'agent.files', { op: 'create', path: rel });
148
+ if (type === 'dir') fs.mkdirSync(abs, { recursive: true });
149
+ else {
150
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
151
+ fs.writeFileSync(abs, '');
152
+ }
153
+ return { path: rel, type };
154
+ }
155
+
156
+ rename(ref, from, to) {
157
+ const { agent, root } = this.agentDir(ref);
158
+ const a = resolveInside(root, from);
159
+ const b = resolveInside(root, to);
160
+ if (!fs.existsSync(a)) throw err(404, `${from} doesn't exist`);
161
+ if (fs.existsSync(b)) throw err(409, `${to} already exists`);
162
+ if (a === fs.realpathSync(root)) throw err(400, "The agent folder itself can't be renamed");
163
+ this.check(agent, 'agent.files', { op: 'rename', path: from, to });
164
+ fs.mkdirSync(path.dirname(b), { recursive: true });
165
+ fs.renameSync(a, b);
166
+ return { from, to };
167
+ }
168
+
169
+ remove(ref, rel) {
170
+ const { agent, root } = this.agentDir(ref);
171
+ const abs = resolveInside(root, rel);
172
+ if (abs === fs.realpathSync(root) || !String(rel ?? '').trim()) throw err(400, "The agent folder itself can't be deleted here");
173
+ if (!fs.existsSync(abs)) throw err(404, `${rel} doesn't exist`);
174
+ this.check(agent, 'agent.files', { op: 'delete', path: rel });
175
+ fs.rmSync(abs, { recursive: true, force: true });
176
+ return { path: rel, deleted: true };
177
+ }
178
+
179
+ /** Text search: git grep in a repository (ignored files skipped), else a bounded walk. */
180
+ async search(ref, q, { max = 200 } = {}) {
181
+ const { root } = this.agentDir(ref);
182
+ const term = String(q ?? '');
183
+ if (term.trim().length < 2) throw err(400, 'Search for at least 2 characters');
184
+ const hits = [];
185
+ if (await git.isGitRepo(root)) {
186
+ const out = await git
187
+ .run('git', ['grep', '-n', '-I', '--untracked', '--fixed-strings', '-i', '--full-name', '-e', term], { cwd: root, timeout: 20_000 })
188
+ .catch((e) => (e.code === 1 ? '' : Promise.reject(e)));
189
+ for (const line of out.split('\n')) {
190
+ const m = line.match(/^(.*?):(\d+):(.*)$/);
191
+ if (m) hits.push({ path: m[1], line: Number(m[2]), text: m[3].slice(0, 300) });
192
+ if (hits.length >= max) break;
193
+ }
194
+ return { q: term, hits, truncated: hits.length >= max };
195
+ }
196
+ const lower = term.toLowerCase();
197
+ const walk = (dir) => {
198
+ for (const d of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
199
+ if (hits.length >= max) return;
200
+ if (HIDDEN.has(d.name)) continue;
201
+ const rel = path.posix.join(dir, d.name);
202
+ if (d.isDirectory()) walk(rel);
203
+ else if (d.isFile()) {
204
+ const buf = fs.readFileSync(path.join(root, rel));
205
+ if (buf.length > MAX_FILE_BYTES || !isText(buf)) continue;
206
+ buf.toString('utf8').split('\n').forEach((text, i) => {
207
+ if (hits.length < max && text.toLowerCase().includes(lower)) hits.push({ path: rel, line: i + 1, text: text.slice(0, 300) });
208
+ });
209
+ }
210
+ }
211
+ };
212
+ walk('');
213
+ return { q: term, hits, truncated: hits.length >= max };
214
+ }
215
+
216
+ /** The file as it was where the agent started (its base), for a side-by-side diff. */
217
+ async original(ref, rel) {
218
+ const { agent, root } = this.agentDir(ref);
219
+ resolveInside(root, rel);
220
+ if (!(await git.isGitRepo(root))) return { path: rel, content: null, why: 'Not a git repository' };
221
+ const base = await this.review.base(agent).catch(() => null);
222
+ const at = base?.sha ?? 'HEAD';
223
+ const prefix = (await git.run('git', ['rev-parse', '--show-prefix'], { cwd: root }).catch(() => '')).trim();
224
+ const content = await git.run('git', ['show', `${at}:${prefix}${rel}`], { cwd: root, timeout: 10_000, trim: false }).catch(() => null);
225
+ return { path: rel, content, base: base?.ref ?? 'HEAD', new: content === null };
226
+ }
227
+
228
+ /** Editors on this machine that can open the folder. */
229
+ async editors() {
230
+ const found = [];
231
+ for (const [id, e] of Object.entries(EDITORS)) {
232
+ for (const bin of e.bins) {
233
+ const at = await which(bin);
234
+ if (at) {
235
+ found.push({ id, label: e.label, bin: at });
236
+ break;
237
+ }
238
+ }
239
+ }
240
+ return found;
241
+ }
242
+
243
+ /** Open the agent's folder (and a file in it) in VS Code or Cursor on this machine. */
244
+ async openIn(ref, editor = 'vscode', rel = null) {
245
+ const { root } = this.agentDir(ref);
246
+ const e = (await this.editors()).find((x) => x.id === editor);
247
+ if (!e) throw err(404, `${EDITORS[editor]?.label ?? editor} isn't installed (its "${editor === 'cursor' ? 'cursor' : 'code'}" command wasn't found)`);
248
+ const args = [root];
249
+ if (rel) args.push('--goto', resolveInside(root, rel));
250
+ const child = spawn(e.bin, args, { detached: true, stdio: 'ignore' });
251
+ child.on('error', () => {});
252
+ child.unref();
253
+ return { ok: true, editor: e.label, folder: root };
254
+ }
255
+ }
package/src/update.js ADDED
@@ -0,0 +1,160 @@
1
+ // Updating Agent Manager in place, then restarting so agents resume on the new version. Two kinds
2
+ // of install: a git checkout (cloned at a release tag, or `npm link`ed from one) moves to the
3
+ // newest release tag; an npm install runs `npm install -g <package>@<version>`. A checkout on a
4
+ // branch or with uncommitted changes is someone's working copy, so it is left alone.
5
+ import { execFile, spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { paths } from './config.js';
10
+ import { latestRelease, newerThan } from './about.js';
11
+
12
+ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const BIN = path.join(root, 'bin', 'agentmgr.js');
14
+ const err = (status, message) => Object.assign(new Error(message), { status });
15
+ const CHECK_EVERY_MS = 12 * 3_600_000;
16
+
17
+ const run = (cmd, args, { cwd = root, timeoutMs = 120_000 } = {}) =>
18
+ new Promise((resolve, reject) => {
19
+ execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 8 << 20, env: process.env }, (e, stdout, stderr) => {
20
+ if (e) reject(Object.assign(new Error(`${cmd} ${args.join(' ')}: ${(stderr || e.message).trim().split('\n').pop()}`), { status: 500 }));
21
+ else resolve(stdout.trim());
22
+ });
23
+ });
24
+
25
+ const pkg = (dir = root) => JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
26
+
27
+ /** The newest release tag (vX.Y.Z, no pre-releases) among `tags`, or null. */
28
+ export function newestTag(tags) {
29
+ return (
30
+ tags
31
+ .map((t) => t.trim())
32
+ .filter((t) => /^v\d+\.\d+\.\d+$/.test(t))
33
+ .sort((a, b) => (newerThan(a.slice(1), b.slice(1)) ? -1 : newerThan(b.slice(1), a.slice(1)) ? 1 : 0))[0] ?? null
34
+ );
35
+ }
36
+
37
+ /** How this copy was installed: 'git' (a checkout, worktrees included) or 'npm'. */
38
+ export const installKind = (dir = root) => (fs.existsSync(path.join(dir, '.git')) ? 'git' : 'npm');
39
+
40
+ /** Why a git checkout can't be moved to a release tag, or null when it can. */
41
+ export async function gitBlocker(dir = root) {
42
+ const branch = await run('git', ['symbolic-ref', '-q', '--short', 'HEAD'], { cwd: dir }).catch(() => '');
43
+ if (branch) return `This copy is a git checkout on branch "${branch}" (someone's working copy); update it with git, or run a release from a checkout at a release tag`;
44
+ const changed = await run('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: dir });
45
+ if (changed) return 'This checkout has uncommitted changes; commit or discard them first';
46
+ return null;
47
+ }
48
+
49
+ /** What is installed, the newest release, and whether this copy can update itself to it. */
50
+ export async function checkUpdate({ dir = root, fetchTags = true } = {}) {
51
+ const kind = installKind(dir);
52
+ const current = pkg(dir).version;
53
+ if (kind === 'git') {
54
+ if (fetchTags) await run('git', ['fetch', '--tags', '--quiet', 'origin'], { cwd: dir, timeoutMs: 60_000 });
55
+ const tag = newestTag((await run('git', ['tag', '-l', 'v*'], { cwd: dir })).split('\n'));
56
+ const latest = tag ? tag.slice(1) : current;
57
+ const newer = newerThan(latest, current);
58
+ return { kind, current, latest, newer, blocker: newer ? await gitBlocker(dir) : null, checkedAt: new Date().toISOString() };
59
+ }
60
+ const r = await latestRelease();
61
+ return { kind, current, latest: r.latest, newer: r.newer, blocker: null, checkedAt: new Date().toISOString() };
62
+ }
63
+
64
+ /** Put the newest release in place (no restart). Returns { from, to } or { upToDate }. */
65
+ export async function installUpdate({ dir = root, log = () => {} } = {}) {
66
+ const info = await checkUpdate({ dir });
67
+ if (!info.newer) return { upToDate: true, current: info.current };
68
+ if (info.blocker) throw err(409, info.blocker);
69
+ if (info.kind === 'git') {
70
+ const lockBefore = await run('git', ['rev-parse', 'HEAD:package-lock.json'], { cwd: dir }).catch(() => '');
71
+ log(`checking out v${info.latest}`);
72
+ await run('git', ['checkout', '--quiet', '--detach', `v${info.latest}`], { cwd: dir });
73
+ const lockAfter = await run('git', ['rev-parse', 'HEAD:package-lock.json'], { cwd: dir }).catch(() => '');
74
+ if (lockAfter !== lockBefore || !fs.existsSync(path.join(dir, 'node_modules'))) {
75
+ log('installing dependencies');
76
+ await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, timeoutMs: 600_000 });
77
+ }
78
+ } else {
79
+ const name = pkg(dir).name;
80
+ log(`npm install -g ${name}@${info.latest}`);
81
+ await run('npm', ['install', '-g', `${name}@${info.latest}`], { timeoutMs: 600_000 }).catch((e) => {
82
+ throw err(500, `${e.message}${/EACCES|permission/i.test(e.message) ? ' (npm needs write access to its global folder)' : ''}`);
83
+ });
84
+ }
85
+ return { from: info.current, to: info.latest, kind: info.kind };
86
+ }
87
+
88
+ /** Restart the daemon from a separate process, so it outlives this one (`agentmgr restart`). */
89
+ export function restartDetached() {
90
+ const out = fs.openSync(paths.log, 'a');
91
+ const child = spawn(process.execPath, [BIN, 'restart'], { detached: true, stdio: ['ignore', out, out], env: process.env });
92
+ child.unref();
93
+ }
94
+
95
+ /** The daemon's side: a check twice a day (config "updates": { "check": false } turns it off). */
96
+ export class Updates {
97
+ constructor({ config = {}, log = (m) => console.log(`[agentmgr] ${m}`), check = checkUpdate } = {}) {
98
+ this.config = config;
99
+ this.log = log;
100
+ this.check = check;
101
+ this.last = null;
102
+ this.busy = null;
103
+ this.schedule();
104
+ }
105
+
106
+ get enabled() {
107
+ return this.config.updates?.check !== false && process.env.AGENTMGR_UPDATE_CHECK !== '0';
108
+ }
109
+
110
+ /** Start or stop the periodic check to match the setting. */
111
+ schedule() {
112
+ this.close();
113
+ if (!this.enabled) return;
114
+ this.first = setTimeout(() => this.refresh().catch(() => {}), 30_000);
115
+ this.timer = setInterval(() => this.refresh().catch(() => {}), CHECK_EVERY_MS);
116
+ this.first.unref?.();
117
+ this.timer.unref?.();
118
+ }
119
+
120
+ setCheck(on) {
121
+ this.config.updates = { ...this.config.updates, check: Boolean(on) };
122
+ this.schedule();
123
+ return this.config.updates;
124
+ }
125
+
126
+ close() {
127
+ clearTimeout(this.first);
128
+ clearInterval(this.timer);
129
+ }
130
+
131
+ async refresh() {
132
+ try {
133
+ this.last = await this.check();
134
+ } catch (e) {
135
+ this.last = { ...(this.last ?? {}), error: e.message, checkedAt: new Date().toISOString() };
136
+ throw e;
137
+ }
138
+ return this.last;
139
+ }
140
+
141
+ status() {
142
+ return { check: this.enabled, updating: Boolean(this.busy), ...(this.last ?? {}) };
143
+ }
144
+
145
+ /** Install the newest release, then restart onto it. Answers before the restart begins. */
146
+ async apply({ restart = restartDetached } = {}) {
147
+ if (this.busy) throw err(409, 'An update is already running');
148
+ this.busy = installUpdate({ log: (m) => this.log(`update: ${m}`) });
149
+ try {
150
+ const r = await this.busy;
151
+ if (r.upToDate) return r;
152
+ this.log(`update: ${r.from} → ${r.to} installed; restarting`);
153
+ this.last = { ...(this.last ?? {}), current: r.to, newer: false };
154
+ setTimeout(restart, 200);
155
+ return { ...r, restarting: true };
156
+ } finally {
157
+ this.busy = null;
158
+ }
159
+ }
160
+ }