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.
- package/CHANGELOG.md +17 -0
- package/README.md +23 -5
- package/docs/distributed.md +2 -1
- package/docs/plugins.md +2 -1
- package/package.json +1 -1
- package/public/app.js +107 -4
- package/public/code.js +449 -0
- package/public/hub/hub.js +33 -5
- package/public/index.html +62 -6
- package/public/machine.js +114 -0
- package/public/menu.js +92 -0
- package/public/style.css +80 -0
- package/public/update.js +88 -0
- package/src/cleanup.js +71 -2
- package/src/cli.js +74 -12
- package/src/client.js +46 -6
- 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 +25 -17
- package/src/server.js +50 -9
- package/src/studio.js +255 -0
- package/src/update.js +160 -0
package/src/identity.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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
|
-
|
|
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 =
|
|
660
|
-
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 });
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|