ai-agentmanager 1.2.1 → 1.3.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/cli.js CHANGED
@@ -462,13 +462,14 @@ export async function main(argv) {
462
462
  .option('--priority <P0-P3>', 'priority')
463
463
  .option('-p, --permission-mode <mode>', 'permission mode, from the next launch ("" for the default)')
464
464
  .option('-m, --model <model>', 'model, from the next launch ("" for the default)')
465
+ .option('--budget <usd>', 'its spending limit in US dollars ("" for the default)')
465
466
  .action(async (ref, opts) => {
466
467
  await daemon();
467
468
  const a = await resolveAgent(ref);
468
469
  const patch = Object.fromEntries(
469
- [['name', opts.name], ['intent', opts.intent], ['project', opts.project], ['priority', opts.priority], ['permissionMode', opts.permissionMode], ['model', opts.model]].filter(([, v]) => v !== undefined),
470
+ [['name', opts.name], ['intent', opts.intent], ['project', opts.project], ['priority', opts.priority], ['permissionMode', opts.permissionMode], ['model', opts.model], ['budgetUsd', opts.budget === undefined ? undefined : opts.budget === '' ? null : Number(opts.budget)]].filter(([, v]) => v !== undefined),
470
471
  );
471
- if (!Object.keys(patch).length) throw new Error('Nothing to change: give --name, --intent, --project, --priority, --permission-mode or --model');
472
+ if (!Object.keys(patch).length) throw new Error('Nothing to change: give --name, --intent, --project, --priority, --permission-mode, --model or --budget');
472
473
  const res = await api('PATCH', `/agents/${a.id}`, patch);
473
474
  if (opts.tell && opts.intent) await api('POST', `/agents/${a.id}/tell`, { text: `The request changed. It is now:\n${opts.intent}` });
474
475
  console.log(`${green('✓')} ${bold(res.name)} ${dim(res.id)} ${res.projectInfo?.name ?? ''} P${res.priority}${res.permissionMode ? ` ${res.permissionMode}` : ''}${res.model ? ` ${res.model}` : ''}`);
@@ -487,6 +488,295 @@ export async function main(argv) {
487
488
  console.log(`${green('✓')} ${bold(res.name)} is now under ${bold(res.projectInfo?.full || res.projectInfo?.name)}`);
488
489
  });
489
490
 
491
+ program
492
+ .command('changes')
493
+ .description("An agent's changes since its branch started: files, commits and the diff")
494
+ .argument('<agent>')
495
+ .option('--stat', 'files and counts only, no diff')
496
+ .action(async (ref, opts) => {
497
+ await daemon();
498
+ const a = await resolveAgent(ref);
499
+ const c = await api('GET', `/agents/${a.id}/changes`);
500
+ console.log(`${bold(c.name)} ${c.branch}${c.base ? ` ← ${c.base.ref}` : ''} ${c.commits.length} commit(s)${c.uncommitted ? yellow(` ${c.uncommitted} uncommitted`) : ''}${c.unpushed ? yellow(` ${c.unpushed} not pushed`) : ''}`);
501
+ for (const f of c.files) console.log(` ${green(`+${f.added ?? '?'}`)} ${red(`-${f.removed ?? '?'}`)} ${f.path}${f.status !== 'modified' ? dim(` (${f.status})`) : ''}`);
502
+ if (!c.files.length) console.log(dim(' No changes yet.'));
503
+ if (!opts.stat && c.diff) console.log(`\n${c.diff}`);
504
+ });
505
+
506
+ program
507
+ .command('comment')
508
+ .description('Send review comments to an agent as one message: --at file:line=text (repeatable) and -m')
509
+ .argument('<agent>')
510
+ .option('--at <file:line=text>', 'a line comment, e.g. "src/a.js:42=Use const here"', (v, all) => [...all, v], [])
511
+ .option('-m, --message <text>', 'an overall comment')
512
+ .action(async (ref, opts) => {
513
+ await daemon();
514
+ const a = await resolveAgent(ref);
515
+ const comments = opts.at.map((x) => {
516
+ const m = /^(.+?):(\d+)=(.+)$/s.exec(x);
517
+ if (!m) throw new Error(`--at wants file:line=text, got "${x}"`);
518
+ return { file: m[1], line: Number(m[2]), side: 'new', text: m[3] };
519
+ });
520
+ const r = await api('POST', `/agents/${a.id}/changes/comments`, { comments, note: opts.message ?? '' });
521
+ console.log(`${green('✓')} sent ${r.sent} comment(s) to ${bold(a.name)}`);
522
+ });
523
+
524
+ program
525
+ .command('commit')
526
+ .description("Commit everything in an agent's worktree, new files included, as you")
527
+ .argument('<agent>')
528
+ .requiredOption('-m, --message <text>', 'commit message')
529
+ .action(async (ref, opts) => {
530
+ await daemon();
531
+ const a = await resolveAgent(ref);
532
+ const r = await api('POST', `/agents/${a.id}/changes/commit`, { message: opts.message });
533
+ console.log(`${green('✓')} ${r.sha} ${r.files} file(s)`);
534
+ });
535
+
536
+ program
537
+ .command('pr')
538
+ .description("Push an agent's branch and open a pull request with gh (or push to the one already open)")
539
+ .argument('<agent>')
540
+ .option('-t, --title <title>', 'title (default: from the commits)')
541
+ .option('-b, --body <text>', 'description')
542
+ .option('--draft', 'open it as a draft')
543
+ .action(async (ref, opts) => {
544
+ await daemon();
545
+ const a = await resolveAgent(ref);
546
+ const r = await api('POST', `/agents/${a.id}/changes/pr`, { title: opts.title ?? '', body: opts.body ?? '', draft: Boolean(opts.draft) });
547
+ console.log(`${green('✓')} ${r.existing ? 'pushed to' : 'opened'} ${r.url ?? '(no URL printed by gh)'}`);
548
+ });
549
+
550
+ program
551
+ .command('stats')
552
+ .description('Results across agents: merge rate, time to a first PR and to merge, cost per merged PR, by engine and model')
553
+ .option('-d, --days <n>', 'agents created in the last n days (0: all)', '30')
554
+ .option('--json', 'print JSON')
555
+ .action(async (opts) => {
556
+ await daemon();
557
+ const st = await api('GET', `/stats?days=${Number(opts.days) || 0}`);
558
+ if (opts.json) return console.log(JSON.stringify(st, null, 2));
559
+ const t = st.total;
560
+ const pct = (r) => (r == null ? '—' : `${Math.round(r * 100)}%`);
561
+ const hrs = (h) => (h == null ? '—' : `${h}h`);
562
+ const m = (n) => (n == null ? '—' : `$${n.toFixed(2)}`);
563
+ console.log(`${bold(`Last ${st.days || 'all'} days`)}: ${t.agents} agents, ${t.withPr} opened a PR, ${t.merged} merged, ${t.closed} closed, ${t.open} open`);
564
+ console.log(`merge rate ${bold(pct(t.mergeRate))} · median to first PR ${hrs(t.medianHoursToPr)} · to merge ${hrs(t.medianHoursToMerge)} · ${m(t.costPerMerged)} per merged PR (${m(t.cost)} in all)`);
565
+ for (const [title, rows] of [['By engine and model', st.byEngine], ['By source', st.bySource]]) {
566
+ console.log(`\n${bold(title)}`);
567
+ for (const r of rows) console.log(` ${r.key.padEnd(24)} ${String(r.agents).padStart(4)} agents ${String(r.merged).padStart(3)} merged ${pct(r.mergeRate).padStart(4)} ${m(r.costPerMerged)}/merged`);
568
+ }
569
+ });
570
+
571
+ program
572
+ .command('spend')
573
+ .description('What agents cost today and lately, and spending limits')
574
+ .option('--per-agent <usd>', 'limit for each agent ("" for none)')
575
+ .option('--per-day <usd>', 'limit for all agents a day ("" for none)')
576
+ .action(async (opts) => {
577
+ await daemon();
578
+ const patch = {};
579
+ if (opts.perAgent !== undefined) patch.perAgentUsd = opts.perAgent === '' ? null : Number(opts.perAgent);
580
+ if (opts.perDay !== undefined) patch.perDayUsd = opts.perDay === '' ? null : Number(opts.perDay);
581
+ const b = Object.keys(patch).length ? await api('PUT', '/budgets', patch) : await api('GET', '/budgets');
582
+ const m = (n) => `$${Number(n ?? 0).toFixed(2)}`;
583
+ console.log(`${bold('Today')}: ${m(b.today.total)}${b.perDayUsd ? ` of ${m(b.perDayUsd)}` : ''} ${dim(`each agent at most ${b.perAgentUsd ? m(b.perAgentUsd) : 'no limit'}`)}`);
584
+ for (const d of b.days.slice(1, 8)) console.log(dim(` ${d.day} ${m(d.total)}`));
585
+ for (const a of b.agents.slice(0, 15)) console.log(` ${m(a.cost).padStart(8)} ${bold(a.name)}${a.limit ? dim(` budget ${m(a.limit)}`) : ''}${a.hit ? red(' reached') : ''}`);
586
+ console.log(dim(b.note));
587
+ });
588
+
589
+ program
590
+ .command('learn')
591
+ .description("Add one line to the shared memory of an agent's repo (agents run this themselves)")
592
+ .argument('<text>', 'the learning, one short line')
593
+ .option('-a, --agent <agent>', 'which agent (default: the agent this runs in)')
594
+ .action(async (text, o) => {
595
+ await daemon();
596
+ const ref = o.agent ?? process.env.AGENTMGR_AGENT_ID;
597
+ if (!ref) throw new Error('Say which agent with --agent (outside an agent there is no default)');
598
+ const a = await resolveAgent(ref);
599
+ const r = await api('POST', `/agents/${a.id}/learn`, { text });
600
+ console.log(r.added ? `${green('✓')} remembered for ${r.key} (${r.entries} learnings)` : dim('Already in the memory.'));
601
+ });
602
+
603
+ program
604
+ .command('memory')
605
+ .description("Show the shared memory of an agent's repo (what agents learned there)")
606
+ .argument('<agent>')
607
+ .option('--clear', 'empty it')
608
+ .action(async (ref, o) => {
609
+ await daemon();
610
+ const a = await resolveAgent(ref);
611
+ const r = o.clear ? await api('PUT', `/agents/${a.id}/memory`, { text: '' }) : await api('GET', `/agents/${a.id}/memory`);
612
+ console.log(dim(`${r.file} · ${r.entries} learnings · ${r.bytes} of ${r.max} bytes`));
613
+ if (r.text) console.log(r.text.trimEnd());
614
+ });
615
+
616
+ const pb = program.command('playbook').description('Saved requests you run again, in several projects, by hand or on a schedule');
617
+ pb.command('ls', { isDefault: true })
618
+ .description('List playbooks')
619
+ .action(async () => {
620
+ await daemon();
621
+ const list = await api('GET', '/playbooks');
622
+ for (const p of list) {
623
+ const sched = p.schedule ? `${p.schedule.every} at ${p.schedule.at}${p.schedule.day ? ` (${p.schedule.day})` : ''}${p.enabled ? '' : ' (off)'}` : 'by hand';
624
+ console.log(`${dim(p.id)} ${bold(p.name)} ${p.projects.map((x) => `@${x}`).join(' ')} ${dim(sched)}${p.nextRunAt ? dim(` next ${p.nextRunAt.slice(0, 16).replace('T', ' ')}`) : ''}`);
625
+ }
626
+ if (!list.length) console.log(dim('No playbooks yet: agentmgr playbook add'));
627
+ });
628
+ pb.command('add')
629
+ .description('Save a playbook: a request ({{name}} for values filled in at run time) and the projects to run it in')
630
+ .argument('<name>')
631
+ .argument('<request>')
632
+ .requiredOption('--projects <list>', 'project shortcuts, comma separated (one agent each)')
633
+ .option('-m, --model <model>', 'model')
634
+ .option('-p, --permission-mode <mode>', 'permission mode')
635
+ .option('--every <when>', 'schedule: hour, day, weekday or week')
636
+ .option('--at <HH:MM>', 'schedule time (minute past the hour for "hour")', '09:00')
637
+ .option('--day <day>', 'for "week": mon..sun', 'mon')
638
+ .option('--default <name=value>', 'default value for a {{placeholder}} (repeatable)', (v, all) => [...all, v], [])
639
+ .action(async (name, intent, o) => {
640
+ await daemon();
641
+ const defaults = Object.fromEntries(o.default.map((x) => x.split(/=(.*)/s).slice(0, 2)));
642
+ const p = await api('POST', '/playbooks', { name, intent, projects: o.projects, model: o.model, permissionMode: o.permissionMode, defaults, schedule: o.every ? { every: o.every, at: o.at, day: o.day } : null });
643
+ console.log(`${green('✓')} ${bold(p.name)} ${dim(p.id)}${p.nextRunAt ? ` next run ${p.nextRunAt}` : ''}`);
644
+ });
645
+ pb.command('run')
646
+ .description('Run a playbook now')
647
+ .argument('<playbook>', 'id or name')
648
+ .option('--var <name=value>', 'a {{placeholder}} value (repeatable)', (v, all) => [...all, v], [])
649
+ .action(async (ref, o) => {
650
+ await daemon();
651
+ const vars = Object.fromEntries(o.var.map((x) => x.split(/=(.*)/s).slice(0, 2)));
652
+ const r = await api('POST', `/playbooks/${encodeURIComponent(ref)}/run`, { vars });
653
+ for (const a of r.agents) console.log(`${green('✓')} ${bold(a.name)} ${dim(a.id)} @${a.project}`);
654
+ for (const x of r.failed) console.log(`${red('!')} @${x.project}: ${x.reason}`);
655
+ });
656
+ pb.command('rm')
657
+ .description('Delete a playbook (its agents stay)')
658
+ .argument('<playbook>', 'id or name')
659
+ .action(async (ref) => {
660
+ await daemon();
661
+ await api('DELETE', `/playbooks/${encodeURIComponent(ref)}`);
662
+ console.log(`${green('✓')} deleted`);
663
+ });
664
+
665
+ const trig = program.command('triggers').description('Start agents from GitHub labels, /agent PR comments and a webhook (no tokens spent watching)');
666
+ trig
667
+ .command('status', { isDefault: true })
668
+ .description('What is on, and what triggers started lately')
669
+ .action(async () => {
670
+ await daemon();
671
+ const t = await api('GET', '/triggers');
672
+ console.log(`${bold('GitHub label')}: ${t.github.enabled ? green('on') : dim('off')} "${t.github.label}" in ${[...t.github.repos, ...t.github.owners.map((o) => `${o}/*`)].join(', ') || dim('(no repos)')} every ${t.github.everyMin} min`);
673
+ console.log(`${bold('PR comments')}: ${t.prCommands.enabled ? green('on') : dim('off')} "${t.prCommands.prefix} …" from owners, members, collaborators`);
674
+ console.log(`${bold('Webhook')}: ${t.webhook.enabled ? green('on') : dim('off')} POST /api/hooks/agents${t.webhook.hasToken ? '' : dim(' (no token: agentmgr triggers set --new-token)')}`);
675
+ console.log(dim(`${t.startedToday} started in the last day (at most ${t.maxPerDay}); last check ${t.lastPoll?.at ?? 'never'}${t.lastError ? `; error: ${t.lastError.message}` : ''}`));
676
+ for (const r of t.recent.slice(0, 10)) console.log(` ${r.at.slice(0, 16).replace('T', ' ')} ${bold(r.name)} ${r.source}${r.url ? ` ${dim(r.url)}` : ''}`);
677
+ });
678
+ trig
679
+ .command('poll')
680
+ .description('Check GitHub now')
681
+ .action(async () => {
682
+ await daemon();
683
+ const r = await api('POST', '/triggers/poll');
684
+ console.log(`${r.issues} agent(s) started, ${r.commands} comment(s) passed on`);
685
+ });
686
+ trig
687
+ .command('set')
688
+ .description('Change triggers')
689
+ .option('--github <on|off>', 'start agents for labeled issues')
690
+ .option('--label <label>', 'the label (default agentmgr)')
691
+ .option('--repos <list>', 'repos to watch, owner/name, comma separated')
692
+ .option('--owners <list>', 'or every repo of these owners')
693
+ .option('--every <minutes>', 'how often to check')
694
+ .option('--pr-comments <on|off>', 'pass /agent comments on agent PRs to the agent')
695
+ .option('--webhook <on|off>', 'accept POST /api/hooks/agents with the token')
696
+ .option('--new-token', 'make a new webhook token (printed once)')
697
+ .option('--max-per-day <n>', 'agents triggers may start a day')
698
+ .action(async (o) => {
699
+ await daemon();
700
+ const onOff = (v) => (v === undefined ? undefined : ['on', 'true', 'yes', '1'].includes(String(v).toLowerCase()));
701
+ const clean = (x) => Object.fromEntries(Object.entries(x).filter(([, v]) => v !== undefined));
702
+ const patch = clean({
703
+ github: clean({ enabled: onOff(o.github), label: o.label, repos: o.repos, owners: o.owners, everyMin: o.every === undefined ? undefined : Number(o.every) }),
704
+ prCommands: clean({ enabled: onOff(o.prComments) }),
705
+ webhook: clean({ enabled: o.newToken ? true : onOff(o.webhook), newToken: o.newToken ? true : undefined }),
706
+ maxPerDay: o.maxPerDay === undefined ? undefined : Number(o.maxPerDay),
707
+ });
708
+ const r = await api('PUT', '/triggers', patch);
709
+ if (r.token) console.log(`Webhook token (shown only now): ${r.token}\nPOST /api/hooks/agents with "Authorization: Bearer <token>" and {"intent": "…", "project": "<shortcut>"} or {"repo": "<git URL>"}`);
710
+ console.log(`${green('✓')} saved`);
711
+ });
712
+
713
+ const land = program.command('land').description("Agents' open pull requests: list them, update, ask to fix, merge");
714
+ land
715
+ .command('list', { isDefault: true })
716
+ .description('Open pull requests by what they need: ready, fix, behind, waiting, draft')
717
+ .action(async () => {
718
+ await daemon();
719
+ const { rows, settings } = await api('GET', '/landing');
720
+ const color = { ready: green, fix: red, behind: yellow, waiting: dim, draft: dim };
721
+ for (const r of rows) {
722
+ const c = r.pr.checks;
723
+ const checks = c ? { failed: red(`✗ ${c.failed.join(', ')}`), running: `◔ ${c.running}`, passed: green('✓'), none: '' }[c.state] : '';
724
+ console.log(`${(color[r.land] ?? dim)(r.land.padEnd(7))} P${r.agent.priority} ${bold(r.agent.name)} #${r.pr.number ?? ''} ${r.pr.title ?? r.pr.url} ${checks}${r.pr.review ? ` ${r.pr.review.toLowerCase().replace(/_/g, ' ')}` : ''}${r.fixes ? dim(` sent back ${r.fixes}x`) : ''}`);
725
+ }
726
+ if (!rows.length) console.log(dim('No open pull requests from agents.'));
727
+ console.log(dim(`Failing checks ${settings.fixChecks ? 'are' : 'are not'} sent to agents, requested changes ${settings.fixReviews ? 'are' : 'are not'}, at most ${settings.maxFixes} times per PR; merges use ${settings.mergeMethod}.`));
728
+ });
729
+ land
730
+ .command('update')
731
+ .description('Merge the base branch into the agent branch and push (asks the agent when busy, dirty or conflicting)')
732
+ .argument('<agent>')
733
+ .option('--agent', 'ask the agent to do it')
734
+ .action(async (ref, opts) => {
735
+ await daemon();
736
+ const a = await resolveAgent(ref);
737
+ const r = await api('POST', `/agents/${a.id}/land/update`, { via: opts.agent ? 'agent' : 'auto' });
738
+ console.log(r.upToDate ? `Up to date with ${r.target}` : r.merged ? `${green('✓')} merged ${r.merged} commit(s) from ${r.target}${r.pushed ? ' and pushed' : ''}` : `Asked ${bold(a.name)} to update (${r.why})`);
739
+ });
740
+ land
741
+ .command('fix')
742
+ .description("Send the PR's failing checks, requested changes or conflicts to the agent")
743
+ .argument('<agent>')
744
+ .action(async (ref) => {
745
+ await daemon();
746
+ const a = await resolveAgent(ref);
747
+ await api('POST', `/agents/${a.id}/land/fix`, {});
748
+ console.log(`${green('✓')} sent to ${bold(a.name)}`);
749
+ });
750
+ land
751
+ .command('merge')
752
+ .description("Merge the agent's open pull request with gh")
753
+ .argument('<agent>')
754
+ .option('--method <m>', 'squash, merge or rebase (default from settings)')
755
+ .option('--auto', 'turn on auto-merge instead: GitHub merges once checks and reviews allow')
756
+ .action(async (ref, opts) => {
757
+ await daemon();
758
+ const a = await resolveAgent(ref);
759
+ const r = await api('POST', `/agents/${a.id}/land/merge`, { method: opts.method, auto: Boolean(opts.auto) });
760
+ console.log(`${green('✓')} ${r.auto ? 'auto-merge on for' : 'merged'} ${r.url}`);
761
+ });
762
+ land
763
+ .command('set')
764
+ .description('Landing settings')
765
+ .option('--fix-checks <on|off>', 'send failing checks to the agent')
766
+ .option('--fix-reviews <on|off>', 'send requested changes to the agent')
767
+ .option('--max-fixes <n>', 'at most this many times per pull request')
768
+ .option('--method <m>', 'merge method: squash, merge or rebase')
769
+ .option('--delete-branch <on|off>', 'delete the branch on GitHub after merging')
770
+ .action(async (opts) => {
771
+ await daemon();
772
+ const onOff = (v) => (v === undefined ? undefined : ['on', 'true', 'yes', '1'].includes(String(v).toLowerCase()));
773
+ const patch = Object.fromEntries(
774
+ [['fixChecks', onOff(opts.fixChecks)], ['fixReviews', onOff(opts.fixReviews)], ['maxFixes', opts.maxFixes === undefined ? undefined : Number(opts.maxFixes)], ['mergeMethod', opts.method], ['deleteBranch', onOff(opts.deleteBranch)]].filter(([, v]) => v !== undefined),
775
+ );
776
+ const { settings } = await api('PUT', '/landing', patch);
777
+ console.log(JSON.stringify(settings, null, 2));
778
+ });
779
+
490
780
  program
491
781
  .command('plan')
492
782
  .description('While the workspace is still being prepared, plan the task with Claude and attach')
@@ -18,12 +18,14 @@ export default {
18
18
  extraArgs: (config) => [...(config.engines?.claude?.args ?? config.claudeArgs ?? [])],
19
19
 
20
20
  /** Options for every launch: permissions, model, a folder it may write to, extra instructions. */
21
- optionArgs({ permissionMode, model, writableDir, instructions, agents }) {
21
+ optionArgs({ permissionMode, model, writableDir, instructions, agents, allowedTools }) {
22
22
  const args = [];
23
23
  if (permissionMode && permissionMode !== 'default') args.push('--permission-mode', permissionMode);
24
24
  if (model) args.push('--model', model);
25
25
  const dirs = [writableDir ?? []].flat();
26
- if (dirs.length) args.push('--add-dir', ...dirs, '--allowedTools', ...dirs.flatMap((d) => [`Write(/${d}/**)`, `Edit(/${d}/**)`]));
26
+ if (dirs.length) args.push('--add-dir', ...dirs);
27
+ const allowed = [...dirs.flatMap((d) => [`Write(/${d}/**)`, `Edit(/${d}/**)`]), ...(allowedTools ?? [])];
28
+ if (allowed.length) args.push('--allowedTools', ...allowed);
27
29
  if (instructions) args.push('--append-system-prompt', instructions);
28
30
  // Sub-agents for this session only (a team lead's roles).
29
31
  if (agents) args.push('--agents', JSON.stringify(agents));
@@ -31,8 +33,8 @@ export default {
31
33
  },
32
34
 
33
35
  /** Full arguments for a launch: options, then the session. */
34
- launchArgs({ extra = [], permissionMode, model, writableDir, instructions, agents, sessionId, resume, name, prompt }) {
35
- return [...extra, ...this.optionArgs({ permissionMode, model, writableDir, instructions, agents }), ...this.sessionArgs({ sessionId, resume, name, prompt })];
36
+ launchArgs({ extra = [], permissionMode, model, writableDir, instructions, agents, allowedTools, sessionId, resume, name, prompt }) {
37
+ return [...extra, ...this.optionArgs({ permissionMode, model, writableDir, instructions, agents, allowedTools }), ...this.sessionArgs({ sessionId, resume, name, prompt })];
36
38
  },
37
39
 
38
40
  /** A new session under the ID we chose, or a resumed one; prompt is the first (or next) message. */
package/src/git.js CHANGED
@@ -3,13 +3,13 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
5
  // Never wait on a username/password prompt nobody can see; fail instead.
6
- const ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
6
+ const baseEnv = () => ({ ...process.env, GIT_TERMINAL_PROMPT: '0' });
7
7
 
8
8
  const cancelled = () => Object.assign(new Error('Cancelled'), { cancelled: true });
9
9
 
10
10
  export function run(cmd, args, { cwd, timeout = 60_000, signal, env } = {}) {
11
11
  return new Promise((resolve, reject) => {
12
- execFile(cmd, args, { cwd, timeout, signal, env: env ? { ...ENV, ...env } : ENV, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
12
+ execFile(cmd, args, { cwd, timeout, signal, env: { ...baseEnv(), ...env }, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
13
13
  if (err) {
14
14
  if (signal?.aborted) return reject(cancelled());
15
15
  err.message = `${cmd} ${args.join(' ')} failed: ${(stderr || err.message).trim()}`;
@@ -57,7 +57,7 @@ function gitProgress(args, { cwd, signal, onProgress } = {}) {
57
57
  return new Promise((resolve, reject) => {
58
58
  // Own process group, so cancelling also kills git's helpers (git-remote-http etc.).
59
59
  // Killing only git leaves them holding stderr open, and 'close' would never come.
60
- const child = spawn('git', args, { cwd, env: ENV, stdio: ['ignore', 'ignore', 'pipe'], detached: true });
60
+ const child = spawn('git', args, { cwd, env: baseEnv(), stdio: ['ignore', 'ignore', 'pipe'], detached: true });
61
61
  const kill = () => {
62
62
  try {
63
63
  process.kill(-child.pid, 'SIGKILL');
@@ -332,7 +332,7 @@ export async function fullDiff(dir, baseSha, max = 400_000) {
332
332
  if (out.length > max) break;
333
333
  // --no-index exits 1 when the files differ, which is always here.
334
334
  const patch = await new Promise((resolve) =>
335
- execFile('git', ['diff', '--no-color', '--no-index', '--', '/dev/null', f], { ...opts, env: ENV, maxBuffer: 10 * 1024 * 1024 }, (_e, stdout) => resolve(stdout ?? '')),
335
+ execFile('git', ['diff', '--no-color', '--no-index', '--', '/dev/null', f], { ...opts, env: baseEnv(), maxBuffer: 10 * 1024 * 1024 }, (_e, stdout) => resolve(stdout ?? '')),
336
336
  );
337
337
  out += `\n${patch}`;
338
338
  }
package/src/landing.js ADDED
@@ -0,0 +1,212 @@
1
+ // Landing: getting agents' pull requests merged. Failing checks and requested changes are sent
2
+ // back to the agent that owns the PR (a few times per PR at most), a branch that fell behind is
3
+ // brought up to date with the branch it targets, and ready PRs are merged with one click.
4
+ import os from 'node:os';
5
+ import * as git from './git.js';
6
+ import { NO_REPO } from './manager.js';
7
+ import { ghBin } from './tracker.js';
8
+ import { parseBaseRef } from './review.js';
9
+
10
+ export const MERGE_METHODS = ['squash', 'merge', 'rebase'];
11
+ export const LANDING_DEFAULTS = { fixChecks: true, fixReviews: true, maxFixes: 3, mergeMethod: 'squash', deleteBranch: true };
12
+ const LAND_ORDER = ['ready', 'fix', 'behind', 'waiting', 'draft'];
13
+
14
+ const err = (status, message) => Object.assign(new Error(message), { status });
15
+
16
+ /** Settings from config.json's "landing", with defaults and sane bounds. */
17
+ export function landingSettings(config = {}) {
18
+ const c = { ...LANDING_DEFAULTS, ...config.landing };
19
+ const n = Number(c.maxFixes);
20
+ return {
21
+ fixChecks: c.fixChecks !== false,
22
+ fixReviews: c.fixReviews !== false,
23
+ maxFixes: Number.isFinite(n) && n >= 0 ? Math.min(20, Math.floor(n)) : LANDING_DEFAULTS.maxFixes,
24
+ mergeMethod: MERGE_METHODS.includes(c.mergeMethod) ? c.mergeMethod : LANDING_DEFAULTS.mergeMethod,
25
+ deleteBranch: c.deleteBranch !== false,
26
+ };
27
+ }
28
+
29
+ export function checksMessage(pr) {
30
+ const names = pr.checks?.failed?.length ? pr.checks.failed.join(', ') : 'some checks';
31
+ return [
32
+ `CI is failing on your pull request ${pr.url} (${names}).`,
33
+ `Look at the failures (\`gh pr checks ${pr.number ?? pr.url}\`, and \`gh run view <run id> --log-failed\` for the logs), fix the cause, run the relevant tests locally, then commit and push.`,
34
+ 'If a failure is unrelated to your change (a flaky test or a broken main), say so instead of changing unrelated code.',
35
+ ].join('\n');
36
+ }
37
+
38
+ export function reviewMessage(pr) {
39
+ return [
40
+ `A reviewer requested changes on your pull request ${pr.url}.`,
41
+ `Read the review and its comments (\`gh pr view ${pr.number ?? pr.url} --comments\`, and \`gh api repos/{owner}/{repo}/pulls/${pr.number ?? '<number>'}/comments\` for line comments), address each one, then commit and push and reply on the PR with what you changed.`,
42
+ ].join('\n');
43
+ }
44
+
45
+ export function updateMessage(base) {
46
+ return `Your branch is behind ${base}. Merge ${base} into it (git fetch, then git merge ${base}), resolve any conflicts keeping both sides' intent, run the tests, then commit and push.`;
47
+ }
48
+
49
+ export class Landing {
50
+ constructor({ manager, store, config = {}, log = (m) => console.log(`[agentmgr] ${m}`) }) {
51
+ this.manager = manager;
52
+ this.store = store;
53
+ this.config = config;
54
+ this.log = log;
55
+ this.onPrs = (agent) => this.react(agent).catch((e) => this.log(`landing: ${e.message}`));
56
+ manager.on('prs', this.onPrs);
57
+ // Running agents' PRs are refreshed by the manager; closed agents with an open PR every few minutes.
58
+ this.timer = setInterval(() => this.refreshClosed().catch(() => {}), (config.landingRefreshMin ?? 6) * 60_000);
59
+ this.timer.unref?.();
60
+ }
61
+
62
+ close() {
63
+ clearInterval(this.timer);
64
+ this.manager.off('prs', this.onPrs);
65
+ }
66
+
67
+ get settings() {
68
+ return landingSettings(this.config);
69
+ }
70
+
71
+ update(patch = {}) {
72
+ if (patch.mergeMethod !== undefined && !MERGE_METHODS.includes(patch.mergeMethod)) throw err(400, `mergeMethod must be one of ${MERGE_METHODS.join(', ')}`);
73
+ if (patch.maxFixes !== undefined && !(Number(patch.maxFixes) >= 0)) throw err(400, 'maxFixes must be 0 or more');
74
+ const next = landingSettings({ landing: { ...this.config.landing, ...patch } });
75
+ this.config.landing = next;
76
+ return next;
77
+ }
78
+
79
+ /** Open PRs that the agent opened or that were found on its branch (not ones it only mentioned). */
80
+ openPrs(agent) {
81
+ return Object.values(agent.prs ?? {}).filter((p) => p.state === 'OPEN' && p.source !== 'mentioned');
82
+ }
83
+
84
+ /** After a PR refresh: send failing checks and requested changes back to the agent, once per commit. */
85
+ async react(agent) {
86
+ const s = this.settings;
87
+ if (!this.store.get(agent.id) || NO_REPO.has(agent.kind) || !s.maxFixes) return;
88
+ agent.landing ??= { sent: {} };
89
+ let sent = false;
90
+ for (const pr of this.openPrs(agent)) {
91
+ if (!pr.head) continue;
92
+ const count = Object.keys(agent.landing.sent).filter((k) => k.startsWith(`${pr.url}#`)).length;
93
+ if (count >= s.maxFixes) continue;
94
+ const send = (kind, text) => {
95
+ const key = `${pr.url}#${kind}@${pr.head}`;
96
+ if (agent.landing.sent[key]) return;
97
+ agent.landing.sent[key] = new Date().toISOString();
98
+ this.manager.tell(agent.id, text, { source: 'landing' });
99
+ this.log(`landing: told ${agent.name} about ${kind} on ${pr.url}`);
100
+ sent = true;
101
+ };
102
+ if (s.fixChecks && pr.checks?.state === 'failed') send('checks', checksMessage(pr));
103
+ else if (s.fixReviews && pr.review === 'CHANGES_REQUESTED') send('review', reviewMessage(pr));
104
+ }
105
+ if (sent) this.store.save();
106
+ }
107
+
108
+ async refreshClosed() {
109
+ for (const agent of this.store.list()) {
110
+ if (this.manager.runtimes.get(agent.id)?.pty || !agent.workdir || !this.openPrs(agent).length) continue;
111
+ await this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
112
+ }
113
+ }
114
+
115
+ /** Every agent's open pull request with where it stands, ready ones first. */
116
+ list() {
117
+ const rows = [];
118
+ for (const agent of this.store.list()) {
119
+ const view = this.manager.view(agent);
120
+ for (const pr of view.prs.filter((p) => p.state === 'OPEN' && p.source !== 'mentioned')) {
121
+ const fixes = Object.keys(agent.landing?.sent ?? {}).filter((k) => k.startsWith(`${pr.url}#`)).length;
122
+ rows.push({ agent: { id: agent.id, name: agent.name, priority: view.priority, busy: view.runtime.busy, running: view.runtime.running }, pr, land: pr.land ?? 'waiting', fixes });
123
+ }
124
+ }
125
+ const rank = (r) => (LAND_ORDER.includes(r.land) ? LAND_ORDER.indexOf(r.land) : LAND_ORDER.length);
126
+ return { settings: this.settings, rows: rows.sort((a, b) => rank(a) - rank(b) || a.agent.priority - b.agent.priority) };
127
+ }
128
+
129
+ agentPr(ref, url) {
130
+ const agent = this.manager.mustGet(ref);
131
+ const open = this.openPrs(agent);
132
+ const pr = url ? open.find((p) => p.url === url) : open[0];
133
+ if (!pr) throw err(404, url ? 'That pull request is not open on this agent' : 'This agent has no open pull request');
134
+ return { agent, pr };
135
+ }
136
+
137
+ /**
138
+ * Bring the branch up to date with the one it targets: merge it in here when the agent is idle
139
+ * and its worktree clean (and push), else ask the agent. A conflict is aborted and handed to the agent.
140
+ */
141
+ async updateBranch(ref, { via = 'auto' } = {}) {
142
+ const agent = this.manager.mustGet(ref);
143
+ if (!agent.workdir || NO_REPO.has(agent.kind)) throw err(400, 'This agent has no repository');
144
+ const dir = agent.workdir;
145
+ const opts = { cwd: dir, timeout: 120_000 };
146
+ const b = parseBaseRef(agent.baseRef);
147
+ const target = b?.ref && b.ref !== 'HEAD' ? b.ref : (await git.resolveBase(dir).catch(() => null))?.ref;
148
+ if (!target || target === 'HEAD') throw err(409, "Couldn't tell which branch this one should follow");
149
+ const ask = (why) => {
150
+ this.manager.tell(agent.id, updateMessage(target), { source: 'landing' });
151
+ return { asked: true, why, target };
152
+ };
153
+ if (via === 'agent') return ask('asked');
154
+ const rt = this.manager.runtimes.get(agent.id);
155
+ if (rt?.pty && rt.busy) return ask('the agent is working');
156
+ if ((await git.run('git', ['status', '--porcelain'], opts)).trim()) return ask('it has uncommitted changes');
157
+ if (target.startsWith('origin/')) await git.fetch(dir).catch(() => {});
158
+ const behind = Number(await git.run('git', ['rev-list', '--count', `HEAD..${target}`], opts).catch(() => '0')) || 0;
159
+ if (!behind) return { upToDate: true, target };
160
+ try {
161
+ await git.run('git', ['merge', '--no-edit', target], { ...opts, env: { GIT_MERGE_AUTOEDIT: 'no' } });
162
+ } catch (e) {
163
+ await git.run('git', ['merge', '--abort'], opts).catch(() => {});
164
+ return { ...ask('the merge has conflicts'), conflict: true };
165
+ }
166
+ let pushed = false;
167
+ const upstream = await git.run('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], opts).catch(() => '');
168
+ if (upstream) {
169
+ await git.run('git', ['push'], opts);
170
+ pushed = true;
171
+ }
172
+ this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
173
+ return { merged: behind, target, pushed };
174
+ }
175
+
176
+ /** Ask the agent to fix what stands in the way: failing checks, requested changes, conflicts. */
177
+ fix(ref, { url } = {}) {
178
+ const { agent, pr } = this.agentPr(ref, url);
179
+ const parts = [];
180
+ if (pr.checks?.state === 'failed') parts.push(checksMessage(pr));
181
+ if (pr.review === 'CHANGES_REQUESTED') parts.push(reviewMessage(pr));
182
+ if (pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY' || pr.mergeState === 'BEHIND') {
183
+ const b = parseBaseRef(agent.baseRef);
184
+ parts.push(updateMessage(b?.ref && b.ref !== 'HEAD' ? b.ref : 'the base branch'));
185
+ }
186
+ if (!parts.length) throw err(409, 'Nothing to fix on this pull request right now');
187
+ this.manager.tell(agent.id, parts.join('\n\n'), { source: 'landing' });
188
+ return { sent: parts.length };
189
+ }
190
+
191
+ /** Merge the PR with gh (or turn on auto-merge so GitHub merges it once it may). */
192
+ async merge(ref, { url, method, auto = false } = {}) {
193
+ const { agent, pr } = this.agentPr(ref, url);
194
+ const s = this.settings;
195
+ method = method ?? s.mergeMethod;
196
+ if (!MERGE_METHODS.includes(method)) throw err(400, `method must be one of ${MERGE_METHODS.join(', ')}`);
197
+ this.manager.plugins.check('agent.merge', { agent: this.manager.auditView(agent), url: pr.url, method, auto: Boolean(auto) });
198
+ const args = ['pr', 'merge', pr.url, `--${method}`];
199
+ if (auto) args.push('--auto');
200
+ if (s.deleteBranch && !auto) args.push('--delete-branch');
201
+ try {
202
+ // Outside the worktree, so gh deletes only the remote branch and leaves the worktree alone.
203
+ await git.run(ghBin(), args, { cwd: os.tmpdir(), timeout: 120_000 });
204
+ } catch (e) {
205
+ throw err(502, e.message.replace(/^\S+ pr merge .*? failed: /, ''));
206
+ }
207
+ this.manager.plugins.emit('agent.merge', { agent: this.manager.auditView(agent), url: pr.url, method, auto: Boolean(auto) });
208
+ this.log(`landing: ${auto ? 'auto-merge on for' : 'merged'} ${pr.url}`);
209
+ await this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
210
+ return { url: pr.url, merged: !auto, auto: Boolean(auto) };
211
+ }
212
+ }