ai-agentmanager 1.3.0 → 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.
@@ -0,0 +1,41 @@
1
+ // Who this Agent Manager runs as, for the menu in the top bar: the computer's user, the Claude
2
+ // account its agents use (as Claude Code recorded it in ~/.claude.json), and the git identity
3
+ // their commits get. Read on request; nothing here leaves the machine.
4
+ import { execFile } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const gitConfig = (key) =>
10
+ new Promise((resolve) => execFile('git', ['config', '--global', key], { timeout: 3000 }, (e, out) => resolve(e ? null : out.trim() || null)));
11
+
12
+ /** The signed-in Claude account from Claude Code's settings file, or null. */
13
+ export function claudeAccount(file = path.join(os.homedir(), '.claude.json')) {
14
+ try {
15
+ const a = JSON.parse(fs.readFileSync(file, 'utf8')).oauthAccount;
16
+ if (!a?.emailAddress) return null;
17
+ return { email: a.emailAddress, name: a.displayName || a.fullName || null, organization: a.organizationName || null };
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export async function identity() {
24
+ const [gitName, gitEmail] = await Promise.all([gitConfig('user.name'), gitConfig('user.email')]);
25
+ let user = null;
26
+ try {
27
+ user = os.userInfo().username;
28
+ } catch {
29
+ // No passwd entry (some containers).
30
+ }
31
+ const claude = claudeAccount();
32
+ return {
33
+ user,
34
+ host: os.hostname().replace(/\.local$/, ''),
35
+ claude,
36
+ git: gitName || gitEmail ? { name: gitName, email: gitEmail } : null,
37
+ // What the menu shows first: a name, then an email.
38
+ display: claude?.name || gitName || user || 'You',
39
+ email: claude?.email || gitEmail || null,
40
+ };
41
+ }
package/src/manager.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import pty from 'node-pty';
6
7
  import { expandHome, paths } from './config.js';
@@ -195,6 +196,9 @@ function loadScrollback(id) {
195
196
  }
196
197
  const baseLabel = ({ ref, sha }) => `${ref} @ ${sha.slice(0, 9)}`;
197
198
 
199
+ /** The id of the machine terminal's shells (agent ids are hex, so it can't clash). */
200
+ export const MACHINE = '__machine__';
201
+
198
202
  export class AgentManager extends EventEmitter {
199
203
  constructor({ store, config, plugins = new Plugins() }) {
200
204
  super();
@@ -791,6 +795,8 @@ export class AgentManager extends EventEmitter {
791
795
  start(ref, { fresh = false, sessionId } = {}) {
792
796
  const agent = this.mustGet(ref);
793
797
  if (this.closing) throw Object.assign(new Error('AI Agent Manager is shutting down'), { status: 503 });
798
+ // Its session continues on the other machine; two copies of one conversation would diverge.
799
+ if (agent.movedTo) throw Object.assign(new Error(`${agent.name} moved to ${agent.movedTo.machine}; it continues there`), { status: 409 });
794
800
  const live = this.runtimes.get(agent.id);
795
801
  if (!live?.pty) {
796
802
  this.plugins.check('agent.start', { agent: this.auditView(agent), fresh });
@@ -1354,7 +1360,8 @@ export class AgentManager extends EventEmitter {
1354
1360
 
1355
1361
  /** Start (if needed) login shell number `n` in the agent's folder, for running commands by hand. */
1356
1362
  openShell(ref, n = 1) {
1357
- const agent = this.mustGet(ref);
1363
+ // The machine terminal: a shell in the home folder, not tied to any agent.
1364
+ const agent = ref === MACHINE ? { id: MACHINE, workdir: os.homedir(), machine: true } : this.mustGet(ref);
1358
1365
  const shells = this.shellsOf(agent.id);
1359
1366
  const prev = shells.get(n);
1360
1367
  if (prev?.pty) return prev;
@@ -1375,7 +1382,7 @@ export class AgentManager extends EventEmitter {
1375
1382
  cols: rt.cols,
1376
1383
  rows: rt.rows,
1377
1384
  cwd: agent.workdir,
1378
- env: this.childEnv(agent),
1385
+ env: this.childEnv(agent.machine ? null : agent),
1379
1386
  });
1380
1387
  rt.pty = proc;
1381
1388
  shells.set(n, rt);
@@ -1390,7 +1397,7 @@ export class AgentManager extends EventEmitter {
1390
1397
  const a = this.store.get(agent.id);
1391
1398
  if (a) this.changed(a);
1392
1399
  });
1393
- this.changed(agent);
1400
+ if (!agent.machine) this.changed(agent);
1394
1401
  return rt;
1395
1402
  }
1396
1403
 
@@ -1403,8 +1410,8 @@ export class AgentManager extends EventEmitter {
1403
1410
  }
1404
1411
 
1405
1412
  attachShell(ref, client, n = 1) {
1406
- const agent = this.mustGet(ref);
1407
- this.plugins.check('terminal.open', { agent: this.auditView(agent), kind: `shell:${n}` });
1413
+ const agent = ref === MACHINE ? { id: MACHINE } : this.mustGet(ref);
1414
+ this.plugins.check('terminal.open', ref === MACHINE ? { agent: null, kind: 'machine' } : { agent: this.auditView(agent), kind: `shell:${n}` });
1408
1415
  const rt = this.openShell(agent.id, n);
1409
1416
  rt.clients.add(client);
1410
1417
  return {
@@ -1991,18 +1998,19 @@ export class AgentManager extends EventEmitter {
1991
1998
  const id = this.mustGet(ref).id;
1992
1999
  const running = this.refreshing.get(id);
1993
2000
  if (running) {
1994
- // A pull request check asked for during a plain refresh runs right after it.
1995
- if (!withPrs || running.withPrs) return running.promise.then(() => this.store.get(id) && this.view(this.store.get(id)));
1996
- await running.promise.catch(() => {});
1997
- return this.refresh(id, { withPrs });
1998
- }
1999
- const promise = this.doRefresh(id, { withPrs });
2000
- this.refreshing.set(id, { promise, withPrs });
2001
- try {
2002
- return await promise;
2003
- } finally {
2004
- this.refreshing.delete(id);
2005
- }
2001
+ if (!withPrs) return running.promise.then(() => this.store.get(id) && this.view(this.store.get(id)));
2002
+ // A pull request check runs again after the one in progress, which may have read PRs from
2003
+ // before this request; callers arriving meanwhile share that next run.
2004
+ running.next ??= running.promise.catch(() => {}).then(() => this.refresh(id, { withPrs }));
2005
+ return running.next;
2006
+ }
2007
+ // Cleared before anyone waiting on it continues, so the next run starts afresh.
2008
+ const entry = { withPrs };
2009
+ entry.promise = this.doRefresh(id, { withPrs }).finally(() => {
2010
+ if (this.refreshing.get(id) === entry) this.refreshing.delete(id);
2011
+ });
2012
+ this.refreshing.set(id, entry);
2013
+ return entry.promise;
2006
2014
  }
2007
2015
 
2008
2016
  async doRefresh(id, { withPrs }) {
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.
@@ -193,7 +199,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
193
199
  };
194
200
  const fail = (status, message) => Object.assign(new Error(message), { status });
195
201
 
196
- api.get('/health', wrap(() => ({ ok: true, pid: process.pid, home: paths.home, claudeBin: config.claudeBin })));
202
+ // `closing` while shutting down: it still answers until every agent is closed, then exits.
203
+ api.get('/health', wrap(() => ({ ok: true, pid: process.pid, home: paths.home, claudeBin: config.claudeBin, closing })));
197
204
  api.get('/agents', wrap(() => manager.list()));
198
205
  api.get('/engines', wrap(() => listEngines(config)));
199
206
  api.post(
@@ -232,6 +239,23 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
232
239
  api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));
233
240
  api.post('/agents/:id/plan/review', wrap((req) => manager.setPlanReview(req.params.id, req.body?.on === true)));
234
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)));
235
259
  api.post('/agents/:id/shell/close', wrap((req) => manager.closeShell(req.params.id, req.body?.n ? Number(req.body.n) : undefined)));
236
260
  api.post('/agents/:id/shell/new', wrap((req) => ({ n: manager.nextShellNumber(manager.mustGet(req.params.id).id) })));
237
261
  api.post('/agents/:id/shell/drop', wrap((req) => manager.dropShell(req.params.id, Number(req.body?.n) || 1)));
@@ -408,6 +432,18 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
408
432
  api.post('/agents/:id/review/ack', wrap((req) => manager.ackReview(req.params.id, req.body?.action)));
409
433
  api.get('/about', wrap(() => about({ port, config: { home: paths.home, claudeBin: config.claudeBin } })));
410
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
+ );
411
447
  api.get('/judge', wrap(() => manager.judge.status()));
412
448
  // On or off for this daemon, kept in config.json. The judge spends calls on your own engine plan.
413
449
  api.put('/judge', wrap((req) => {
@@ -650,14 +686,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
650
686
  });
651
687
 
652
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));
653
691
  const attachTerm = (id, kind, client) =>
654
692
  shellNumber(kind) ? manager.attachShell(id, client, shellNumber(kind)) : manager.attach(id, client);
655
693
  api.get('/agents/:id/term/stream', (req, res) => {
656
694
  let agent;
657
695
  const kind = termKind(req.query.kind);
658
696
  try {
659
- agent = manager.mustGet(req.params.id);
660
- 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 });
661
700
  } catch (err) {
662
701
  return res.status(err.status || 404).json({ error: err.message });
663
702
  }
@@ -698,7 +737,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
698
737
  api.post(
699
738
  '/agents/:id/term/input',
700
739
  wrap((req) => {
701
- const agent = manager.mustGet(req.params.id);
740
+ const agent = termAgent(req.params.id);
702
741
  if (typeof req.body?.d !== 'string') throw fail(400, 'd (string) is required');
703
742
  manager.write(agent.id, req.body.d, termKind(req.body.kind));
704
743
  return { ok: true };
@@ -707,7 +746,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
707
746
  api.post(
708
747
  '/agents/:id/term/resize',
709
748
  wrap((req) => {
710
- const agent = manager.mustGet(req.params.id);
749
+ const agent = termAgent(req.params.id);
711
750
  manager.resize(agent.id, req.body?.cols | 0, req.body?.rows | 0, termKind(req.body?.kind));
712
751
  return { ok: true };
713
752
  }),
@@ -779,12 +818,13 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
779
818
  if (!m) return ws.close(1008, 'unknown endpoint');
780
819
  let session;
781
820
  try {
782
- 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]);
783
823
  const client = {
784
824
  send: (msg) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(msg)),
785
825
  close: () => ws.close(1000, 'agent removed'),
786
826
  };
787
- 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'));
788
828
  session = attachTerm(agent.id, kind, client);
789
829
  client.send({ t: 'hello', id: agent.id, kind, running: session.running, backlog: session.backlog });
790
830
  ws.on('message', (raw) => {
@@ -853,6 +893,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
853
893
  triggers.close();
854
894
  budgets.close();
855
895
  playbooks.close();
896
+ updates.close();
856
897
  assistant.close();
857
898
  console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
858
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
+ }