@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.
@@ -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 `packageFile` stand in for the real ones in tests. */
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 manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate });
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,
@@ -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({ provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, account = null }) {
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
- const env = prependPath(mergeEnv([this.baseEnv, provider.env, account?.env, {
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');
@@ -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,