ai-agentmanager 1.3.1 → 1.5.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/CHANGELOG.md +22 -0
- package/README.md +28 -7
- package/docs/distributed.md +2 -1
- package/docs/engines.md +2 -1
- package/docs/plugins.md +2 -1
- package/package.json +1 -1
- package/public/app.js +185 -17
- package/public/code.js +449 -0
- package/public/hub/hub.js +34 -6
- package/public/index.html +70 -7
- package/public/machine.js +120 -0
- package/public/menu.js +92 -0
- package/public/setup.js +40 -14
- package/public/style.css +102 -2
- package/public/transport.js +19 -3
- package/public/update.js +88 -0
- package/src/brief.js +69 -0
- package/src/cleanup.js +71 -2
- package/src/cli.js +57 -2
- package/src/engines/index.js +10 -2
- package/src/engines/muse.js +303 -0
- package/src/fleet.js +1 -0
- package/src/git.js +2 -2
- package/src/handoff.js +240 -0
- package/src/hub.js +50 -2
- package/src/hubactions.js +14 -1
- package/src/identity.js +41 -0
- package/src/manager.js +73 -17
- package/src/server.js +48 -8
- package/src/studio.js +255 -0
- package/src/update.js +160 -0
package/src/server.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import https from 'node:https';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { createRequire } from 'node:module';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import express from 'express';
|
|
8
9
|
import { WebSocketServer } from 'ws';
|
|
9
10
|
import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.js';
|
|
10
|
-
import { AgentManager, shellNumber } from './manager.js';
|
|
11
|
+
import { AgentManager, MACHINE, shellNumber } from './manager.js';
|
|
11
12
|
import { Backlog } from './backlog.js';
|
|
12
13
|
import { Races } from './races.js';
|
|
13
14
|
import { Review } from './review.js';
|
|
@@ -18,6 +19,9 @@ import { Landing } from './landing.js';
|
|
|
18
19
|
import { Budgets } from './budgets.js';
|
|
19
20
|
import { stats } from './stats.js';
|
|
20
21
|
import { Playbooks } from './playbooks.js';
|
|
22
|
+
import { Updates } from './update.js';
|
|
23
|
+
import { identity } from './identity.js';
|
|
24
|
+
import { Studio } from './studio.js';
|
|
21
25
|
import { Memory } from './memory.js';
|
|
22
26
|
import { doctor } from './doctor.js';
|
|
23
27
|
import { Triggers, tokenMatches } from './triggers.js';
|
|
@@ -55,7 +59,7 @@ function isAllowedOrigin(origin, port) {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
58
|
-
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|shutdown$)/;
|
|
62
|
+
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|update(\/|$)|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
59
63
|
const HOOK_PATH = '/api/hooks/agents';
|
|
60
64
|
const NO_REPO_KINDS = new Set(['general', 'assistant']);
|
|
61
65
|
|
|
@@ -69,6 +73,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
69
73
|
const backlog = new Backlog({ manager, store, config });
|
|
70
74
|
const races = new Races({ manager, store });
|
|
71
75
|
const review = new Review({ manager });
|
|
76
|
+
const studio = new Studio({ manager, review });
|
|
72
77
|
const teams = new Teams({ manager, store, config });
|
|
73
78
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
74
79
|
const landing = new Landing({ manager, store, config });
|
|
@@ -87,6 +92,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
87
92
|
const budgets = new Budgets({ manager, store, config, notifier });
|
|
88
93
|
const playbooks = new Playbooks({ manager, store });
|
|
89
94
|
const memory = new Memory({ manager, config });
|
|
95
|
+
const updates = new Updates({ config });
|
|
90
96
|
let remoteServer = null;
|
|
91
97
|
let remotePort = null;
|
|
92
98
|
// Live browser tabs: what a page does is saved on the agent's tab; popups become new tabs.
|
|
@@ -233,6 +239,23 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
233
239
|
api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));
|
|
234
240
|
api.post('/agents/:id/plan/review', wrap((req) => manager.setPlanReview(req.params.id, req.body?.on === true)));
|
|
235
241
|
api.post('/agents/:id/handoff', wrap((req) => manager.maybeHandoff(manager.mustGet(req.params.id).id, { force: true })));
|
|
242
|
+
// ---------------------------------------------------------- studio: files and editor
|
|
243
|
+
api.get('/agents/:id/files', wrap((req) => studio.list(req.params.id, req.query.dir ?? '')));
|
|
244
|
+
api.post('/agents/:id/files', wrap((req) => studio.create(req.params.id, req.body?.path, req.body?.type)));
|
|
245
|
+
api.post('/agents/:id/files/rename', wrap((req) => studio.rename(req.params.id, req.body?.from, req.body?.to)));
|
|
246
|
+
api.get('/agents/:id/file', wrap((req) => studio.read(req.params.id, req.query.path)));
|
|
247
|
+
api.put('/agents/:id/file', wrap((req) => studio.write(req.params.id, req.body?.path, req.body?.content, { mtimeMs: req.body?.mtimeMs ?? null, force: Boolean(req.body?.force) })));
|
|
248
|
+
api.delete('/agents/:id/file', wrap((req) => studio.remove(req.params.id, req.query.path)));
|
|
249
|
+
api.get('/agents/:id/file/original', wrap((req) => studio.original(req.params.id, req.query.path)));
|
|
250
|
+
api.get('/agents/:id/search', wrap((req) => studio.search(req.params.id, req.query.q)));
|
|
251
|
+
// Opening VS Code or Cursor happens on this computer, so only from it.
|
|
252
|
+
api.get('/editors', wrap(() => studio.editors()));
|
|
253
|
+
api.post('/agents/:id/open-in', wrap((req) => studio.openIn(req.params.id, req.body?.editor, req.body?.path ?? null)));
|
|
254
|
+
// The machine terminal: shells in the home folder, not tied to an agent (Open Terminal).
|
|
255
|
+
api.get('/machine/shells', wrap(() => ({ shells: manager.shellNumbers(MACHINE), home: os.homedir() })));
|
|
256
|
+
api.post('/machine/shell/new', wrap(() => ({ n: manager.nextShellNumber(MACHINE) })));
|
|
257
|
+
api.post('/machine/shell/close', wrap((req) => manager.closeShell(MACHINE, req.body?.n ? Number(req.body.n) : undefined)));
|
|
258
|
+
api.post('/machine/shell/drop', wrap((req) => manager.dropShell(MACHINE, Number(req.body?.n) || 1)));
|
|
236
259
|
api.post('/agents/:id/shell/close', wrap((req) => manager.closeShell(req.params.id, req.body?.n ? Number(req.body.n) : undefined)));
|
|
237
260
|
api.post('/agents/:id/shell/new', wrap((req) => ({ n: manager.nextShellNumber(manager.mustGet(req.params.id).id) })));
|
|
238
261
|
api.post('/agents/:id/shell/drop', wrap((req) => manager.dropShell(req.params.id, Number(req.body?.n) || 1)));
|
|
@@ -409,6 +432,18 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
409
432
|
api.post('/agents/:id/review/ack', wrap((req) => manager.ackReview(req.params.id, req.body?.action)));
|
|
410
433
|
api.get('/about', wrap(() => about({ port, config: { home: paths.home, claudeBin: config.claudeBin } })));
|
|
411
434
|
api.get('/about/latest', wrap(() => latestRelease()));
|
|
435
|
+
api.get('/me', wrap(async (req) => ({ ...(await identity()), remote: Boolean(req.remote) })));
|
|
436
|
+
// Updating runs npm or git here and restarts the daemon, so only from this computer.
|
|
437
|
+
api.get('/update', wrap(() => updates.status()));
|
|
438
|
+
api.post('/update/check', wrap(async () => (await updates.refresh(), updates.status())));
|
|
439
|
+
api.post('/update', wrap(() => updates.apply()));
|
|
440
|
+
api.put(
|
|
441
|
+
'/update',
|
|
442
|
+
wrap((req) => {
|
|
443
|
+
saveConfig({ updates: updates.setCheck(req.body?.check !== false) });
|
|
444
|
+
return updates.status();
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
412
447
|
api.get('/judge', wrap(() => manager.judge.status()));
|
|
413
448
|
// On or off for this daemon, kept in config.json. The judge spends calls on your own engine plan.
|
|
414
449
|
api.put('/judge', wrap((req) => {
|
|
@@ -651,14 +686,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
651
686
|
});
|
|
652
687
|
|
|
653
688
|
const termKind = (q) => (shellNumber(q) ? `shell:${shellNumber(q)}` : 'claude');
|
|
689
|
+
// The machine terminal (Open Terminal) has no agent: its shells live under MACHINE.
|
|
690
|
+
const termAgent = (id) => (id === MACHINE ? { id: MACHINE } : manager.mustGet(id));
|
|
654
691
|
const attachTerm = (id, kind, client) =>
|
|
655
692
|
shellNumber(kind) ? manager.attachShell(id, client, shellNumber(kind)) : manager.attach(id, client);
|
|
656
693
|
api.get('/agents/:id/term/stream', (req, res) => {
|
|
657
694
|
let agent;
|
|
658
695
|
const kind = termKind(req.query.kind);
|
|
659
696
|
try {
|
|
660
|
-
agent =
|
|
661
|
-
manager.plugins.check('terminal.open', { agent:
|
|
697
|
+
agent = termAgent(req.params.id);
|
|
698
|
+
if (agent.id === MACHINE) manager.plugins.check('terminal.open', { agent: null, kind: 'machine' });
|
|
699
|
+
else manager.plugins.check('terminal.open', { agent: manager.auditView(agent), kind });
|
|
662
700
|
} catch (err) {
|
|
663
701
|
return res.status(err.status || 404).json({ error: err.message });
|
|
664
702
|
}
|
|
@@ -699,7 +737,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
699
737
|
api.post(
|
|
700
738
|
'/agents/:id/term/input',
|
|
701
739
|
wrap((req) => {
|
|
702
|
-
const agent =
|
|
740
|
+
const agent = termAgent(req.params.id);
|
|
703
741
|
if (typeof req.body?.d !== 'string') throw fail(400, 'd (string) is required');
|
|
704
742
|
manager.write(agent.id, req.body.d, termKind(req.body.kind));
|
|
705
743
|
return { ok: true };
|
|
@@ -708,7 +746,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
708
746
|
api.post(
|
|
709
747
|
'/agents/:id/term/resize',
|
|
710
748
|
wrap((req) => {
|
|
711
|
-
const agent =
|
|
749
|
+
const agent = termAgent(req.params.id);
|
|
712
750
|
manager.resize(agent.id, req.body?.cols | 0, req.body?.rows | 0, termKind(req.body?.kind));
|
|
713
751
|
return { ok: true };
|
|
714
752
|
}),
|
|
@@ -780,12 +818,13 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
780
818
|
if (!m) return ws.close(1008, 'unknown endpoint');
|
|
781
819
|
let session;
|
|
782
820
|
try {
|
|
783
|
-
|
|
821
|
+
// The machine terminal has no agent; its shells live under MACHINE.
|
|
822
|
+
const agent = m[1] === MACHINE ? { id: MACHINE } : manager.mustGet(m[1]);
|
|
784
823
|
const client = {
|
|
785
824
|
send: (msg) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(msg)),
|
|
786
825
|
close: () => ws.close(1000, 'agent removed'),
|
|
787
826
|
};
|
|
788
|
-
const kind = termKind(url.searchParams.get('kind'));
|
|
827
|
+
const kind = agent.id === MACHINE ? `shell:${shellNumber(url.searchParams.get('kind')) || 1}` : termKind(url.searchParams.get('kind'));
|
|
789
828
|
session = attachTerm(agent.id, kind, client);
|
|
790
829
|
client.send({ t: 'hello', id: agent.id, kind, running: session.running, backlog: session.backlog });
|
|
791
830
|
ws.on('message', (raw) => {
|
|
@@ -854,6 +893,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
854
893
|
triggers.close();
|
|
855
894
|
budgets.close();
|
|
856
895
|
playbooks.close();
|
|
896
|
+
updates.close();
|
|
857
897
|
assistant.close();
|
|
858
898
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
859
899
|
await manager.stopAll({ final: true });
|
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
|
+
}
|