@oddessentials/agent-guild 0.6.0 → 0.7.1
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/README.md +185 -135
- package/package.json +1 -1
- package/src/manager/config.mjs +1 -0
- package/src/manager/github.mjs +842 -0
- package/src/manager/main.mjs +7 -3
- package/src/manager/server.mjs +43 -0
- package/src/manager/session-manager.mjs +32 -3
- package/src/manager/session.mjs +4 -1
- package/web/app.js +652 -3
- package/web/art/art.css +2 -0
- package/web/index.html +66 -4
- package/web/styles.css +73 -1
package/src/manager/main.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { SessionHistory } from './session-history.mjs';
|
|
|
11
11
|
import { ModelStats } from './model-stats.mjs';
|
|
12
12
|
import { NewsFeed } from './news.mjs';
|
|
13
13
|
import { Changelog } from './changelog.mjs';
|
|
14
|
+
import { GitHub } from './github.mjs';
|
|
14
15
|
import { createManagerServer } from './server.mjs';
|
|
15
16
|
import { SelfUpdate } from './self-update.mjs';
|
|
16
17
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
@@ -34,8 +35,8 @@ const rootDir = path.resolve(here, '../..');
|
|
|
34
35
|
|
|
35
36
|
const VERSION_REFRESH_MS = 60 * 60 * 1000;
|
|
36
37
|
|
|
37
|
-
/** `version` and `
|
|
38
|
-
export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE } = {}) {
|
|
38
|
+
/** `version`, `packageFile` and `github` (GitHub's URLs and client id) stand in for the real ones in tests. */
|
|
39
|
+
export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {} } = {}) {
|
|
39
40
|
ensureDataDir();
|
|
40
41
|
const token = loadOrCreateToken();
|
|
41
42
|
const baseEnv = resolveBaseEnv();
|
|
@@ -60,7 +61,8 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
60
61
|
|
|
61
62
|
let api;
|
|
62
63
|
const selfUpdate = new SelfUpdate({ pkg: PACKAGE_NAME, version, packageFile, registry });
|
|
63
|
-
const
|
|
64
|
+
const github = new GitHub({ dir: paths.github, registry, ...githubOptions });
|
|
65
|
+
const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github });
|
|
64
66
|
const usage = new UsageMonitor({ registry, env: baseEnv });
|
|
65
67
|
const history = new SessionHistory({ registry, env: baseEnv });
|
|
66
68
|
const modelStats = new ModelStats({ registry });
|
|
@@ -84,6 +86,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
84
86
|
if (closing) return closing;
|
|
85
87
|
console.log(`[manager] ${restart ? 'restarting' : 'stopping'} (${reason}); ending ${manager.sessions.size} session(s)`);
|
|
86
88
|
clearInterval(versionTimer);
|
|
89
|
+
github.close();
|
|
87
90
|
removeRuntimeFile();
|
|
88
91
|
// Sessions end before the API closes, and the last event says whether
|
|
89
92
|
// every process confirmed its exit, so a client can tell a clean stop
|
|
@@ -116,6 +119,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
116
119
|
modelStats,
|
|
117
120
|
news,
|
|
118
121
|
changelog,
|
|
122
|
+
github,
|
|
119
123
|
token,
|
|
120
124
|
host,
|
|
121
125
|
port,
|
package/src/manager/server.mjs
CHANGED
|
@@ -107,6 +107,7 @@ export function createManagerServer({
|
|
|
107
107
|
modelStats,
|
|
108
108
|
news = null,
|
|
109
109
|
changelog = null,
|
|
110
|
+
github = null,
|
|
110
111
|
token,
|
|
111
112
|
host = '127.0.0.1',
|
|
112
113
|
port = 0,
|
|
@@ -259,6 +260,7 @@ export function createManagerServer({
|
|
|
259
260
|
if (route === '/changelog' && method === 'GET' && changelog) {
|
|
260
261
|
return sendJson(res, 200, changelog.snapshot());
|
|
261
262
|
}
|
|
263
|
+
if (route.startsWith('/github') && github) return handleGitHub(req, res, url, route, method);
|
|
262
264
|
const historyMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/history$/);
|
|
263
265
|
if (historyMatch && method === 'GET') {
|
|
264
266
|
const provider = registry.get(historyMatch[1]);
|
|
@@ -328,6 +330,45 @@ export function createManagerServer({
|
|
|
328
330
|
throw new HttpError(404, `no route for ${method} ${url.pathname}`, 'not_found');
|
|
329
331
|
}
|
|
330
332
|
|
|
333
|
+
async function handleGitHub(req, res, url, route, method) {
|
|
334
|
+
const snapshot = () => ({ github: github.snapshot() });
|
|
335
|
+
if (route === '/github' && method === 'GET') return sendJson(res, 200, snapshot());
|
|
336
|
+
if (route === '/github/sign-in' && method === 'POST') {
|
|
337
|
+
await github.startSignIn();
|
|
338
|
+
return sendJson(res, 202, snapshot());
|
|
339
|
+
}
|
|
340
|
+
if (route === '/github/sign-in' && method === 'DELETE') {
|
|
341
|
+
github.cancelSignIn();
|
|
342
|
+
return sendJson(res, 200, snapshot());
|
|
343
|
+
}
|
|
344
|
+
if (route === '/github/clone' && method === 'POST') {
|
|
345
|
+
const body = await readJsonBody(req);
|
|
346
|
+
const session = manager.clone({ account: body.account, repo: body.repo, parent: body.parent });
|
|
347
|
+
return sendJson(res, 201, { session: session.toJSON() });
|
|
348
|
+
}
|
|
349
|
+
const match = route.match(/^\/github\/accounts\/([^/]+)(\/repos|\/ssh)?$/);
|
|
350
|
+
if (match) {
|
|
351
|
+
const [, id, action] = match;
|
|
352
|
+
if (!action && method === 'DELETE') {
|
|
353
|
+
github.signOut(id);
|
|
354
|
+
return sendJson(res, 200, snapshot());
|
|
355
|
+
}
|
|
356
|
+
if (action === '/repos' && method === 'POST') {
|
|
357
|
+
const body = await readJsonBody(req);
|
|
358
|
+
return sendJson(res, 201, { repo: await github.createRepo(id, body) });
|
|
359
|
+
}
|
|
360
|
+
if (action === '/repos' && method === 'GET') {
|
|
361
|
+
const raw = url.searchParams.get('parent');
|
|
362
|
+
const parent = raw && raw.trim() ? manager.resolveCwd(raw) : null;
|
|
363
|
+
return sendJson(res, 200, { repos: await github.repos(id, { parent, refresh: url.searchParams.get('refresh') === '1' }) });
|
|
364
|
+
}
|
|
365
|
+
if (action === '/ssh' && method === 'POST') {
|
|
366
|
+
return sendJson(res, 200, { account: await github.setupSsh(id) });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
throw new HttpError(404, `no route for ${method} ${url.pathname}`, 'not_found');
|
|
370
|
+
}
|
|
371
|
+
|
|
331
372
|
const server = http.createServer(async (req, res) => {
|
|
332
373
|
let url;
|
|
333
374
|
try {
|
|
@@ -341,6 +382,7 @@ export function createManagerServer({
|
|
|
341
382
|
if (!res.headersSent) {
|
|
342
383
|
const error = { code: err.code || 'error', message: err.message };
|
|
343
384
|
if (err.running !== undefined) error.running = err.running;
|
|
385
|
+
if (err.target !== undefined) error.target = err.target;
|
|
344
386
|
sendJson(res, status, { error });
|
|
345
387
|
}
|
|
346
388
|
}
|
|
@@ -383,6 +425,7 @@ export function createManagerServer({
|
|
|
383
425
|
selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
|
|
384
426
|
news?.on('updated', () => broadcast({ type: 'news.updated' }));
|
|
385
427
|
changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
|
|
428
|
+
github?.on('updated', () => broadcast({ type: 'github.updated' }));
|
|
386
429
|
|
|
387
430
|
function handleEvents(ws) {
|
|
388
431
|
eventClients.add(ws);
|
|
@@ -11,6 +11,7 @@ import { Session, newId, clampDimension, cleanName } from './session.mjs';
|
|
|
11
11
|
import { prependPath } from './report-shims.mjs';
|
|
12
12
|
import { CHANNEL_LABELS } from './install-channels.mjs';
|
|
13
13
|
import { SELF_PROVIDER } from './self-update.mjs';
|
|
14
|
+
import { GITHUB_PROVIDER, dropsFromCloneEnv, parseRepo } from './github.mjs';
|
|
14
15
|
|
|
15
16
|
export const MAX_SESSIONS = 32;
|
|
16
17
|
const examplesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../examples');
|
|
@@ -50,8 +51,9 @@ export class SessionManager extends EventEmitter {
|
|
|
50
51
|
* @param {object} [opts.sessionDefaults] passed through to Session
|
|
51
52
|
* @param {string|null} [opts.shimDir] folder with the agent-guild-report launchers, put first on PATH
|
|
52
53
|
* @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
|
|
54
|
+
* @param {import('./github.mjs').GitHub|null} [opts.github]
|
|
53
55
|
*/
|
|
54
|
-
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null }) {
|
|
56
|
+
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null }) {
|
|
55
57
|
super();
|
|
56
58
|
this.registry = registry;
|
|
57
59
|
this.baseEnv = baseEnv;
|
|
@@ -59,6 +61,7 @@ export class SessionManager extends EventEmitter {
|
|
|
59
61
|
this.sessionDefaults = sessionDefaults;
|
|
60
62
|
this.shimDir = shimDir;
|
|
61
63
|
this.selfUpdate = selfUpdate;
|
|
64
|
+
this.github = github;
|
|
62
65
|
this.sessions = new Map();
|
|
63
66
|
/** Removed sessions whose process has not exited yet. */
|
|
64
67
|
this.exiting = new Set();
|
|
@@ -191,6 +194,26 @@ export class SessionManager extends EventEmitter {
|
|
|
191
194
|
return session;
|
|
192
195
|
}
|
|
193
196
|
|
|
197
|
+
/** Clone a GitHub repository into a folder under `parent`, in a visible session. */
|
|
198
|
+
clone({ account, repo, parent } = {}) {
|
|
199
|
+
if (!this.github) throw httpError(400, 'this manager has no GitHub integration', 'github_unavailable');
|
|
200
|
+
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
201
|
+
if (parent === undefined || parent === null || String(parent).trim() === '') throw httpError(400, 'parent must name the folder to clone into', 'bad_cwd');
|
|
202
|
+
const dir = this.resolveCwd(parent);
|
|
203
|
+
const target = path.join(dir, parseRepo(repo).name);
|
|
204
|
+
for (const s of this.sessions.values()) {
|
|
205
|
+
if (s.status === 'running' && s.task === 'clone' && s.clone?.path === target) {
|
|
206
|
+
throw httpError(409, `${s.clone.repo} is already being cloned into ${target}`, 'clone_in_progress');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const spec = this.github.cloneSpec({ accountId: account, repo, parent: dir });
|
|
210
|
+
return this._spawn({
|
|
211
|
+
provider: GITHUB_PROVIDER, description: GITHUB_PROVIDER, spawnSpec: spec.spawnSpec, cwd: dir,
|
|
212
|
+
name: `Clone ${spec.fullName}`, task: 'clone', extraEnv: spec.env, dropEnv: dropsFromCloneEnv,
|
|
213
|
+
clone: { repo: spec.fullName, path: spec.target, accountId: spec.account.id },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
194
217
|
runningFor(providerId) {
|
|
195
218
|
let n = 0;
|
|
196
219
|
for (const s of this.sessions.values()) if (s.status === 'running' && s.task === null && s.provider.id === providerId) n++;
|
|
@@ -204,7 +227,10 @@ export class SessionManager extends EventEmitter {
|
|
|
204
227
|
return n;
|
|
205
228
|
}
|
|
206
229
|
|
|
207
|
-
_spawn({
|
|
230
|
+
_spawn({
|
|
231
|
+
provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, account = null,
|
|
232
|
+
extraEnv = null, dropEnv = null, clone = null,
|
|
233
|
+
}) {
|
|
208
234
|
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
209
235
|
if (this.sessions.size >= MAX_SESSIONS) {
|
|
210
236
|
throw httpError(429, `session limit reached (${MAX_SESSIONS}); remove finished sessions first`, 'too_many_sessions');
|
|
@@ -214,7 +240,7 @@ export class SessionManager extends EventEmitter {
|
|
|
214
240
|
|
|
215
241
|
// The tool's hooks run `agent-guild-report` by name, so the launchers
|
|
216
242
|
// go first on PATH, after any provider PATH override.
|
|
217
|
-
|
|
243
|
+
let env = prependPath(mergeEnv([this.baseEnv, provider.env, account?.env, {
|
|
218
244
|
TERM: 'xterm-256color',
|
|
219
245
|
COLORTERM: 'truecolor',
|
|
220
246
|
AGENT_GUILD_SESSION_ID: id,
|
|
@@ -228,6 +254,8 @@ export class SessionManager extends EventEmitter {
|
|
|
228
254
|
// agent-team panes in that tmux window, outside the page, and tools
|
|
229
255
|
// would tune their output to a terminal program that is not there.
|
|
230
256
|
for (const key of ['TMUX', 'TMUX_PANE', 'STY', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'ZELLIJ', 'ZELLIJ_SESSION_NAME', 'ZELLIJ_PANE_ID']) delete env[key];
|
|
257
|
+
if (dropEnv) for (const key of Object.keys(env)) if (dropEnv(key)) delete env[key];
|
|
258
|
+
if (extraEnv) env = mergeEnv([env, extraEnv]);
|
|
231
259
|
|
|
232
260
|
let session;
|
|
233
261
|
try {
|
|
@@ -245,6 +273,7 @@ export class SessionManager extends EventEmitter {
|
|
|
245
273
|
resume,
|
|
246
274
|
task,
|
|
247
275
|
account: account ? { id: account.id, label: account.label } : null,
|
|
276
|
+
clone,
|
|
248
277
|
});
|
|
249
278
|
} catch (err) {
|
|
250
279
|
throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
|
package/src/manager/session.mjs
CHANGED
|
@@ -55,8 +55,9 @@ export class Session extends EventEmitter {
|
|
|
55
55
|
* @param {number} opts.rows
|
|
56
56
|
* @param {string} [opts.name]
|
|
57
57
|
* @param {string|null} [opts.resume] id of the tool's own session being resumed
|
|
58
|
-
* @param {string|null} [opts.task] "install" for a package install, "upgrade" for the manager's own, else null
|
|
58
|
+
* @param {string|null} [opts.task] "install" for a package install, "upgrade" for the manager's own, "clone" for a GitHub clone, else null
|
|
59
59
|
* @param {{id: string, label: string}|null} [opts.account] the tool sign-in the session runs under
|
|
60
|
+
* @param {{repo: string, path: string, accountId: number}|null} [opts.clone] what a clone session clones, and where
|
|
60
61
|
* @param {string} opts.reportToken
|
|
61
62
|
* @param {number} [opts.scrollback]
|
|
62
63
|
* @param {number} [opts.activityIdleMs]
|
|
@@ -71,6 +72,7 @@ export class Session extends EventEmitter {
|
|
|
71
72
|
this.resume = opts.resume ?? null;
|
|
72
73
|
this.task = opts.task ?? null;
|
|
73
74
|
this.account = opts.account ?? null;
|
|
75
|
+
this.clone = opts.clone ?? null;
|
|
74
76
|
this.cwd = opts.cwd;
|
|
75
77
|
this.cols = opts.cols;
|
|
76
78
|
this.rows = opts.rows;
|
|
@@ -523,6 +525,7 @@ export class Session extends EventEmitter {
|
|
|
523
525
|
resume: this.resume,
|
|
524
526
|
task: this.task,
|
|
525
527
|
account: this.account,
|
|
528
|
+
clone: this.clone,
|
|
526
529
|
pid: this.pid,
|
|
527
530
|
status: this.status,
|
|
528
531
|
exitCode: this.exitCode,
|