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/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 = manager.mustGet(req.params.id);
661
- manager.plugins.check('terminal.open', { agent: manager.auditView(agent), kind });
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 = manager.mustGet(req.params.id);
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 = manager.mustGet(req.params.id);
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
- const agent = manager.mustGet(m[1]);
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
+ }