ai-agentmanager 1.2.1 → 1.3.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/CHANGELOG.md +20 -0
- package/README.md +94 -3
- package/docs/plugins.md +4 -1
- package/package.json +1 -1
- package/public/app.js +65 -3
- package/public/index.html +133 -1
- package/public/land.js +149 -0
- package/public/playbooks.js +132 -0
- package/public/review.js +368 -0
- package/public/setup.js +67 -0
- package/public/spend.js +82 -0
- package/public/style.css +104 -0
- package/public/triggers.js +85 -0
- package/src/assistant.js +13 -3
- package/src/budgets.js +166 -0
- package/src/cli.js +323 -14
- package/src/client.js +46 -6
- package/src/engines/claude.js +6 -4
- package/src/git.js +4 -4
- package/src/landing.js +212 -0
- package/src/manager.js +100 -68
- package/src/memory.js +106 -0
- package/src/notify.js +3 -2
- package/src/playbooks.js +178 -0
- package/src/review.js +209 -0
- package/src/server.js +87 -4
- package/src/stats.js +107 -0
- package/src/tracker.js +61 -8
- package/src/triggers.js +258 -0
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { Command, Option } from 'commander';
|
|
4
4
|
import WebSocket from 'ws';
|
|
5
5
|
import { HOST, PORT } from './config.js';
|
|
6
|
-
import { api, BASE, ensureDaemon, isRunning } from './client.js';
|
|
6
|
+
import { api, BASE, ensureDaemon, health, isRunning, waitForExit } from './client.js';
|
|
7
7
|
import { about, latestRelease } from './about.js';
|
|
8
8
|
|
|
9
9
|
const tty = process.stdout.isTTY;
|
|
@@ -271,10 +271,16 @@ export async function main(argv) {
|
|
|
271
271
|
.command('serve')
|
|
272
272
|
.description('Run the agent daemon (API + web UI) in the foreground')
|
|
273
273
|
.action(async () => {
|
|
274
|
-
|
|
274
|
+
const h = await health();
|
|
275
|
+
if (h && !h.closing) {
|
|
275
276
|
// Clean exit, so a login service doesn't keep retrying while another daemon owns the port.
|
|
276
277
|
return console.log(`[agentmgr] a daemon is already running at ${BASE}; not starting another`);
|
|
277
278
|
}
|
|
279
|
+
if (h?.closing) {
|
|
280
|
+
// One that is shutting down closes its agents first; start once it has exited.
|
|
281
|
+
console.log(`[agentmgr] waiting for the previous daemon (pid ${h.pid}) to finish shutting down`);
|
|
282
|
+
if (!(await waitForExit(h.pid))) return console.log(`[agentmgr] the previous daemon (pid ${h.pid}) is still shutting down; not starting`);
|
|
283
|
+
}
|
|
278
284
|
const { startServer } = await import('./server.js');
|
|
279
285
|
const { url } = await startServer();
|
|
280
286
|
console.log(`[agentmgr] listening on ${url}`);
|
|
@@ -462,13 +468,14 @@ export async function main(argv) {
|
|
|
462
468
|
.option('--priority <P0-P3>', 'priority')
|
|
463
469
|
.option('-p, --permission-mode <mode>', 'permission mode, from the next launch ("" for the default)')
|
|
464
470
|
.option('-m, --model <model>', 'model, from the next launch ("" for the default)')
|
|
471
|
+
.option('--budget <usd>', 'its spending limit in US dollars ("" for the default)')
|
|
465
472
|
.action(async (ref, opts) => {
|
|
466
473
|
await daemon();
|
|
467
474
|
const a = await resolveAgent(ref);
|
|
468
475
|
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),
|
|
476
|
+
[['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
477
|
);
|
|
471
|
-
if (!Object.keys(patch).length) throw new Error('Nothing to change: give --name, --intent, --project, --priority, --permission-mode or --
|
|
478
|
+
if (!Object.keys(patch).length) throw new Error('Nothing to change: give --name, --intent, --project, --priority, --permission-mode, --model or --budget');
|
|
472
479
|
const res = await api('PATCH', `/agents/${a.id}`, patch);
|
|
473
480
|
if (opts.tell && opts.intent) await api('POST', `/agents/${a.id}/tell`, { text: `The request changed. It is now:\n${opts.intent}` });
|
|
474
481
|
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 +494,295 @@ export async function main(argv) {
|
|
|
487
494
|
console.log(`${green('✓')} ${bold(res.name)} is now under ${bold(res.projectInfo?.full || res.projectInfo?.name)}`);
|
|
488
495
|
});
|
|
489
496
|
|
|
497
|
+
program
|
|
498
|
+
.command('changes')
|
|
499
|
+
.description("An agent's changes since its branch started: files, commits and the diff")
|
|
500
|
+
.argument('<agent>')
|
|
501
|
+
.option('--stat', 'files and counts only, no diff')
|
|
502
|
+
.action(async (ref, opts) => {
|
|
503
|
+
await daemon();
|
|
504
|
+
const a = await resolveAgent(ref);
|
|
505
|
+
const c = await api('GET', `/agents/${a.id}/changes`);
|
|
506
|
+
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`) : ''}`);
|
|
507
|
+
for (const f of c.files) console.log(` ${green(`+${f.added ?? '?'}`)} ${red(`-${f.removed ?? '?'}`)} ${f.path}${f.status !== 'modified' ? dim(` (${f.status})`) : ''}`);
|
|
508
|
+
if (!c.files.length) console.log(dim(' No changes yet.'));
|
|
509
|
+
if (!opts.stat && c.diff) console.log(`\n${c.diff}`);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
program
|
|
513
|
+
.command('comment')
|
|
514
|
+
.description('Send review comments to an agent as one message: --at file:line=text (repeatable) and -m')
|
|
515
|
+
.argument('<agent>')
|
|
516
|
+
.option('--at <file:line=text>', 'a line comment, e.g. "src/a.js:42=Use const here"', (v, all) => [...all, v], [])
|
|
517
|
+
.option('-m, --message <text>', 'an overall comment')
|
|
518
|
+
.action(async (ref, opts) => {
|
|
519
|
+
await daemon();
|
|
520
|
+
const a = await resolveAgent(ref);
|
|
521
|
+
const comments = opts.at.map((x) => {
|
|
522
|
+
const m = /^(.+?):(\d+)=(.+)$/s.exec(x);
|
|
523
|
+
if (!m) throw new Error(`--at wants file:line=text, got "${x}"`);
|
|
524
|
+
return { file: m[1], line: Number(m[2]), side: 'new', text: m[3] };
|
|
525
|
+
});
|
|
526
|
+
const r = await api('POST', `/agents/${a.id}/changes/comments`, { comments, note: opts.message ?? '' });
|
|
527
|
+
console.log(`${green('✓')} sent ${r.sent} comment(s) to ${bold(a.name)}`);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
program
|
|
531
|
+
.command('commit')
|
|
532
|
+
.description("Commit everything in an agent's worktree, new files included, as you")
|
|
533
|
+
.argument('<agent>')
|
|
534
|
+
.requiredOption('-m, --message <text>', 'commit message')
|
|
535
|
+
.action(async (ref, opts) => {
|
|
536
|
+
await daemon();
|
|
537
|
+
const a = await resolveAgent(ref);
|
|
538
|
+
const r = await api('POST', `/agents/${a.id}/changes/commit`, { message: opts.message });
|
|
539
|
+
console.log(`${green('✓')} ${r.sha} ${r.files} file(s)`);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
program
|
|
543
|
+
.command('pr')
|
|
544
|
+
.description("Push an agent's branch and open a pull request with gh (or push to the one already open)")
|
|
545
|
+
.argument('<agent>')
|
|
546
|
+
.option('-t, --title <title>', 'title (default: from the commits)')
|
|
547
|
+
.option('-b, --body <text>', 'description')
|
|
548
|
+
.option('--draft', 'open it as a draft')
|
|
549
|
+
.action(async (ref, opts) => {
|
|
550
|
+
await daemon();
|
|
551
|
+
const a = await resolveAgent(ref);
|
|
552
|
+
const r = await api('POST', `/agents/${a.id}/changes/pr`, { title: opts.title ?? '', body: opts.body ?? '', draft: Boolean(opts.draft) });
|
|
553
|
+
console.log(`${green('✓')} ${r.existing ? 'pushed to' : 'opened'} ${r.url ?? '(no URL printed by gh)'}`);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
program
|
|
557
|
+
.command('stats')
|
|
558
|
+
.description('Results across agents: merge rate, time to a first PR and to merge, cost per merged PR, by engine and model')
|
|
559
|
+
.option('-d, --days <n>', 'agents created in the last n days (0: all)', '30')
|
|
560
|
+
.option('--json', 'print JSON')
|
|
561
|
+
.action(async (opts) => {
|
|
562
|
+
await daemon();
|
|
563
|
+
const st = await api('GET', `/stats?days=${Number(opts.days) || 0}`);
|
|
564
|
+
if (opts.json) return console.log(JSON.stringify(st, null, 2));
|
|
565
|
+
const t = st.total;
|
|
566
|
+
const pct = (r) => (r == null ? '—' : `${Math.round(r * 100)}%`);
|
|
567
|
+
const hrs = (h) => (h == null ? '—' : `${h}h`);
|
|
568
|
+
const m = (n) => (n == null ? '—' : `$${n.toFixed(2)}`);
|
|
569
|
+
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`);
|
|
570
|
+
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)`);
|
|
571
|
+
for (const [title, rows] of [['By engine and model', st.byEngine], ['By source', st.bySource]]) {
|
|
572
|
+
console.log(`\n${bold(title)}`);
|
|
573
|
+
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`);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
program
|
|
578
|
+
.command('spend')
|
|
579
|
+
.description('What agents cost today and lately, and spending limits')
|
|
580
|
+
.option('--per-agent <usd>', 'limit for each agent ("" for none)')
|
|
581
|
+
.option('--per-day <usd>', 'limit for all agents a day ("" for none)')
|
|
582
|
+
.action(async (opts) => {
|
|
583
|
+
await daemon();
|
|
584
|
+
const patch = {};
|
|
585
|
+
if (opts.perAgent !== undefined) patch.perAgentUsd = opts.perAgent === '' ? null : Number(opts.perAgent);
|
|
586
|
+
if (opts.perDay !== undefined) patch.perDayUsd = opts.perDay === '' ? null : Number(opts.perDay);
|
|
587
|
+
const b = Object.keys(patch).length ? await api('PUT', '/budgets', patch) : await api('GET', '/budgets');
|
|
588
|
+
const m = (n) => `$${Number(n ?? 0).toFixed(2)}`;
|
|
589
|
+
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'}`)}`);
|
|
590
|
+
for (const d of b.days.slice(1, 8)) console.log(dim(` ${d.day} ${m(d.total)}`));
|
|
591
|
+
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') : ''}`);
|
|
592
|
+
console.log(dim(b.note));
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
program
|
|
596
|
+
.command('learn')
|
|
597
|
+
.description("Add one line to the shared memory of an agent's repo (agents run this themselves)")
|
|
598
|
+
.argument('<text>', 'the learning, one short line')
|
|
599
|
+
.option('-a, --agent <agent>', 'which agent (default: the agent this runs in)')
|
|
600
|
+
.action(async (text, o) => {
|
|
601
|
+
await daemon();
|
|
602
|
+
const ref = o.agent ?? process.env.AGENTMGR_AGENT_ID;
|
|
603
|
+
if (!ref) throw new Error('Say which agent with --agent (outside an agent there is no default)');
|
|
604
|
+
const a = await resolveAgent(ref);
|
|
605
|
+
const r = await api('POST', `/agents/${a.id}/learn`, { text });
|
|
606
|
+
console.log(r.added ? `${green('✓')} remembered for ${r.key} (${r.entries} learnings)` : dim('Already in the memory.'));
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
program
|
|
610
|
+
.command('memory')
|
|
611
|
+
.description("Show the shared memory of an agent's repo (what agents learned there)")
|
|
612
|
+
.argument('<agent>')
|
|
613
|
+
.option('--clear', 'empty it')
|
|
614
|
+
.action(async (ref, o) => {
|
|
615
|
+
await daemon();
|
|
616
|
+
const a = await resolveAgent(ref);
|
|
617
|
+
const r = o.clear ? await api('PUT', `/agents/${a.id}/memory`, { text: '' }) : await api('GET', `/agents/${a.id}/memory`);
|
|
618
|
+
console.log(dim(`${r.file} · ${r.entries} learnings · ${r.bytes} of ${r.max} bytes`));
|
|
619
|
+
if (r.text) console.log(r.text.trimEnd());
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const pb = program.command('playbook').description('Saved requests you run again, in several projects, by hand or on a schedule');
|
|
623
|
+
pb.command('ls', { isDefault: true })
|
|
624
|
+
.description('List playbooks')
|
|
625
|
+
.action(async () => {
|
|
626
|
+
await daemon();
|
|
627
|
+
const list = await api('GET', '/playbooks');
|
|
628
|
+
for (const p of list) {
|
|
629
|
+
const sched = p.schedule ? `${p.schedule.every} at ${p.schedule.at}${p.schedule.day ? ` (${p.schedule.day})` : ''}${p.enabled ? '' : ' (off)'}` : 'by hand';
|
|
630
|
+
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', ' ')}`) : ''}`);
|
|
631
|
+
}
|
|
632
|
+
if (!list.length) console.log(dim('No playbooks yet: agentmgr playbook add'));
|
|
633
|
+
});
|
|
634
|
+
pb.command('add')
|
|
635
|
+
.description('Save a playbook: a request ({{name}} for values filled in at run time) and the projects to run it in')
|
|
636
|
+
.argument('<name>')
|
|
637
|
+
.argument('<request>')
|
|
638
|
+
.requiredOption('--projects <list>', 'project shortcuts, comma separated (one agent each)')
|
|
639
|
+
.option('-m, --model <model>', 'model')
|
|
640
|
+
.option('-p, --permission-mode <mode>', 'permission mode')
|
|
641
|
+
.option('--every <when>', 'schedule: hour, day, weekday or week')
|
|
642
|
+
.option('--at <HH:MM>', 'schedule time (minute past the hour for "hour")', '09:00')
|
|
643
|
+
.option('--day <day>', 'for "week": mon..sun', 'mon')
|
|
644
|
+
.option('--default <name=value>', 'default value for a {{placeholder}} (repeatable)', (v, all) => [...all, v], [])
|
|
645
|
+
.action(async (name, intent, o) => {
|
|
646
|
+
await daemon();
|
|
647
|
+
const defaults = Object.fromEntries(o.default.map((x) => x.split(/=(.*)/s).slice(0, 2)));
|
|
648
|
+
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 });
|
|
649
|
+
console.log(`${green('✓')} ${bold(p.name)} ${dim(p.id)}${p.nextRunAt ? ` next run ${p.nextRunAt}` : ''}`);
|
|
650
|
+
});
|
|
651
|
+
pb.command('run')
|
|
652
|
+
.description('Run a playbook now')
|
|
653
|
+
.argument('<playbook>', 'id or name')
|
|
654
|
+
.option('--var <name=value>', 'a {{placeholder}} value (repeatable)', (v, all) => [...all, v], [])
|
|
655
|
+
.action(async (ref, o) => {
|
|
656
|
+
await daemon();
|
|
657
|
+
const vars = Object.fromEntries(o.var.map((x) => x.split(/=(.*)/s).slice(0, 2)));
|
|
658
|
+
const r = await api('POST', `/playbooks/${encodeURIComponent(ref)}/run`, { vars });
|
|
659
|
+
for (const a of r.agents) console.log(`${green('✓')} ${bold(a.name)} ${dim(a.id)} @${a.project}`);
|
|
660
|
+
for (const x of r.failed) console.log(`${red('!')} @${x.project}: ${x.reason}`);
|
|
661
|
+
});
|
|
662
|
+
pb.command('rm')
|
|
663
|
+
.description('Delete a playbook (its agents stay)')
|
|
664
|
+
.argument('<playbook>', 'id or name')
|
|
665
|
+
.action(async (ref) => {
|
|
666
|
+
await daemon();
|
|
667
|
+
await api('DELETE', `/playbooks/${encodeURIComponent(ref)}`);
|
|
668
|
+
console.log(`${green('✓')} deleted`);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const trig = program.command('triggers').description('Start agents from GitHub labels, /agent PR comments and a webhook (no tokens spent watching)');
|
|
672
|
+
trig
|
|
673
|
+
.command('status', { isDefault: true })
|
|
674
|
+
.description('What is on, and what triggers started lately')
|
|
675
|
+
.action(async () => {
|
|
676
|
+
await daemon();
|
|
677
|
+
const t = await api('GET', '/triggers');
|
|
678
|
+
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`);
|
|
679
|
+
console.log(`${bold('PR comments')}: ${t.prCommands.enabled ? green('on') : dim('off')} "${t.prCommands.prefix} …" from owners, members, collaborators`);
|
|
680
|
+
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)')}`);
|
|
681
|
+
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}` : ''}`));
|
|
682
|
+
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)}` : ''}`);
|
|
683
|
+
});
|
|
684
|
+
trig
|
|
685
|
+
.command('poll')
|
|
686
|
+
.description('Check GitHub now')
|
|
687
|
+
.action(async () => {
|
|
688
|
+
await daemon();
|
|
689
|
+
const r = await api('POST', '/triggers/poll');
|
|
690
|
+
console.log(`${r.issues} agent(s) started, ${r.commands} comment(s) passed on`);
|
|
691
|
+
});
|
|
692
|
+
trig
|
|
693
|
+
.command('set')
|
|
694
|
+
.description('Change triggers')
|
|
695
|
+
.option('--github <on|off>', 'start agents for labeled issues')
|
|
696
|
+
.option('--label <label>', 'the label (default agentmgr)')
|
|
697
|
+
.option('--repos <list>', 'repos to watch, owner/name, comma separated')
|
|
698
|
+
.option('--owners <list>', 'or every repo of these owners')
|
|
699
|
+
.option('--every <minutes>', 'how often to check')
|
|
700
|
+
.option('--pr-comments <on|off>', 'pass /agent comments on agent PRs to the agent')
|
|
701
|
+
.option('--webhook <on|off>', 'accept POST /api/hooks/agents with the token')
|
|
702
|
+
.option('--new-token', 'make a new webhook token (printed once)')
|
|
703
|
+
.option('--max-per-day <n>', 'agents triggers may start a day')
|
|
704
|
+
.action(async (o) => {
|
|
705
|
+
await daemon();
|
|
706
|
+
const onOff = (v) => (v === undefined ? undefined : ['on', 'true', 'yes', '1'].includes(String(v).toLowerCase()));
|
|
707
|
+
const clean = (x) => Object.fromEntries(Object.entries(x).filter(([, v]) => v !== undefined));
|
|
708
|
+
const patch = clean({
|
|
709
|
+
github: clean({ enabled: onOff(o.github), label: o.label, repos: o.repos, owners: o.owners, everyMin: o.every === undefined ? undefined : Number(o.every) }),
|
|
710
|
+
prCommands: clean({ enabled: onOff(o.prComments) }),
|
|
711
|
+
webhook: clean({ enabled: o.newToken ? true : onOff(o.webhook), newToken: o.newToken ? true : undefined }),
|
|
712
|
+
maxPerDay: o.maxPerDay === undefined ? undefined : Number(o.maxPerDay),
|
|
713
|
+
});
|
|
714
|
+
const r = await api('PUT', '/triggers', patch);
|
|
715
|
+
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>"}`);
|
|
716
|
+
console.log(`${green('✓')} saved`);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const land = program.command('land').description("Agents' open pull requests: list them, update, ask to fix, merge");
|
|
720
|
+
land
|
|
721
|
+
.command('list', { isDefault: true })
|
|
722
|
+
.description('Open pull requests by what they need: ready, fix, behind, waiting, draft')
|
|
723
|
+
.action(async () => {
|
|
724
|
+
await daemon();
|
|
725
|
+
const { rows, settings } = await api('GET', '/landing');
|
|
726
|
+
const color = { ready: green, fix: red, behind: yellow, waiting: dim, draft: dim };
|
|
727
|
+
for (const r of rows) {
|
|
728
|
+
const c = r.pr.checks;
|
|
729
|
+
const checks = c ? { failed: red(`✗ ${c.failed.join(', ')}`), running: `◔ ${c.running}`, passed: green('✓'), none: '' }[c.state] : '';
|
|
730
|
+
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`) : ''}`);
|
|
731
|
+
}
|
|
732
|
+
if (!rows.length) console.log(dim('No open pull requests from agents.'));
|
|
733
|
+
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}.`));
|
|
734
|
+
});
|
|
735
|
+
land
|
|
736
|
+
.command('update')
|
|
737
|
+
.description('Merge the base branch into the agent branch and push (asks the agent when busy, dirty or conflicting)')
|
|
738
|
+
.argument('<agent>')
|
|
739
|
+
.option('--agent', 'ask the agent to do it')
|
|
740
|
+
.action(async (ref, opts) => {
|
|
741
|
+
await daemon();
|
|
742
|
+
const a = await resolveAgent(ref);
|
|
743
|
+
const r = await api('POST', `/agents/${a.id}/land/update`, { via: opts.agent ? 'agent' : 'auto' });
|
|
744
|
+
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})`);
|
|
745
|
+
});
|
|
746
|
+
land
|
|
747
|
+
.command('fix')
|
|
748
|
+
.description("Send the PR's failing checks, requested changes or conflicts to the agent")
|
|
749
|
+
.argument('<agent>')
|
|
750
|
+
.action(async (ref) => {
|
|
751
|
+
await daemon();
|
|
752
|
+
const a = await resolveAgent(ref);
|
|
753
|
+
await api('POST', `/agents/${a.id}/land/fix`, {});
|
|
754
|
+
console.log(`${green('✓')} sent to ${bold(a.name)}`);
|
|
755
|
+
});
|
|
756
|
+
land
|
|
757
|
+
.command('merge')
|
|
758
|
+
.description("Merge the agent's open pull request with gh")
|
|
759
|
+
.argument('<agent>')
|
|
760
|
+
.option('--method <m>', 'squash, merge or rebase (default from settings)')
|
|
761
|
+
.option('--auto', 'turn on auto-merge instead: GitHub merges once checks and reviews allow')
|
|
762
|
+
.action(async (ref, opts) => {
|
|
763
|
+
await daemon();
|
|
764
|
+
const a = await resolveAgent(ref);
|
|
765
|
+
const r = await api('POST', `/agents/${a.id}/land/merge`, { method: opts.method, auto: Boolean(opts.auto) });
|
|
766
|
+
console.log(`${green('✓')} ${r.auto ? 'auto-merge on for' : 'merged'} ${r.url}`);
|
|
767
|
+
});
|
|
768
|
+
land
|
|
769
|
+
.command('set')
|
|
770
|
+
.description('Landing settings')
|
|
771
|
+
.option('--fix-checks <on|off>', 'send failing checks to the agent')
|
|
772
|
+
.option('--fix-reviews <on|off>', 'send requested changes to the agent')
|
|
773
|
+
.option('--max-fixes <n>', 'at most this many times per pull request')
|
|
774
|
+
.option('--method <m>', 'merge method: squash, merge or rebase')
|
|
775
|
+
.option('--delete-branch <on|off>', 'delete the branch on GitHub after merging')
|
|
776
|
+
.action(async (opts) => {
|
|
777
|
+
await daemon();
|
|
778
|
+
const onOff = (v) => (v === undefined ? undefined : ['on', 'true', 'yes', '1'].includes(String(v).toLowerCase()));
|
|
779
|
+
const patch = Object.fromEntries(
|
|
780
|
+
[['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),
|
|
781
|
+
);
|
|
782
|
+
const { settings } = await api('PUT', '/landing', patch);
|
|
783
|
+
console.log(JSON.stringify(settings, null, 2));
|
|
784
|
+
});
|
|
785
|
+
|
|
490
786
|
program
|
|
491
787
|
.command('plan')
|
|
492
788
|
.description('While the workspace is still being prepared, plan the task with Claude and attach')
|
|
@@ -1499,10 +1795,11 @@ export async function main(argv) {
|
|
|
1499
1795
|
.description('Register the daemon with launchd (macOS) or systemd --user (Linux) and start it')
|
|
1500
1796
|
.action(async () => {
|
|
1501
1797
|
const svc = await import('./service.js');
|
|
1502
|
-
|
|
1798
|
+
const h = await health();
|
|
1799
|
+
if (h) {
|
|
1503
1800
|
// Hand over to the service-managed daemon; running agents are resumed by it.
|
|
1504
|
-
await api('POST', '/shutdown');
|
|
1505
|
-
|
|
1801
|
+
if (!h.closing) await api('POST', '/shutdown');
|
|
1802
|
+
await waitForExit(h.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
|
|
1506
1803
|
}
|
|
1507
1804
|
const res = await svc.install();
|
|
1508
1805
|
for (let i = 0; i < 100 && !(await isRunning()); i++) await new Promise((r) => setTimeout(r, 200));
|
|
@@ -1711,7 +2008,8 @@ export async function main(argv) {
|
|
|
1711
2008
|
.description('Flush, stop the daemon, and start it again; running agents resume their sessions')
|
|
1712
2009
|
.action(async () => {
|
|
1713
2010
|
const svc = await import('./service.js');
|
|
1714
|
-
|
|
2011
|
+
const before = await health();
|
|
2012
|
+
if (before && !before.closing) {
|
|
1715
2013
|
try {
|
|
1716
2014
|
const r = await api('POST', '/flush');
|
|
1717
2015
|
console.log(dim(`saved ${r.agents} agents, scrollback for ${r.scrollback}`));
|
|
@@ -1721,13 +2019,21 @@ export async function main(argv) {
|
|
|
1721
2019
|
console.log(dim('running daemon is an older version (no flush); restarting it to upgrade'));
|
|
1722
2020
|
}
|
|
1723
2021
|
await api('POST', '/shutdown');
|
|
1724
|
-
|
|
2022
|
+
}
|
|
2023
|
+
// Wait for the old process itself to exit: it answers until every agent is closed.
|
|
2024
|
+
if (before?.pid) {
|
|
2025
|
+
const gone = await waitForExit(before.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
|
|
2026
|
+
if (!gone) throw new Error(`The daemon (pid ${before.pid}) is still shutting down after 5 minutes; check the log`);
|
|
1725
2027
|
}
|
|
1726
2028
|
const st = await svc.status();
|
|
1727
2029
|
if (st.loaded) await svc.kickstart();
|
|
1728
2030
|
else await ensureDaemon({ quiet: true });
|
|
1729
|
-
|
|
1730
|
-
|
|
2031
|
+
const back = async () => {
|
|
2032
|
+
const h = await health();
|
|
2033
|
+
return Boolean(h && !h.closing && h.pid !== before?.pid);
|
|
2034
|
+
};
|
|
2035
|
+
for (let i = 0; i < 150 && !(await back()); i++) await new Promise((res) => setTimeout(res, 200));
|
|
2036
|
+
if (!(await back())) throw new Error('Daemon did not come back; check the log');
|
|
1731
2037
|
const agents = await api('GET', '/agents');
|
|
1732
2038
|
console.log(`${green('✓')} restarted${st.loaded ? ' (login service)' : ''}; ${agents.filter((a) => a.desired === 'running').length} agents set to resume`);
|
|
1733
2039
|
});
|
|
@@ -1736,9 +2042,12 @@ export async function main(argv) {
|
|
|
1736
2042
|
.command('shutdown')
|
|
1737
2043
|
.description('Stop the daemon (running agents are closed; sessions are kept)')
|
|
1738
2044
|
.action(async () => {
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
2045
|
+
const h = await health();
|
|
2046
|
+
if (!h) return console.log('Daemon is not running');
|
|
2047
|
+
if (!h.closing) await api('POST', '/shutdown');
|
|
2048
|
+
// It closes every agent before it exits; say stopped only once it has.
|
|
2049
|
+
const gone = await waitForExit(h.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
|
|
2050
|
+
console.log(gone ? 'Daemon stopped' : yellow(`The daemon (pid ${h.pid}) is still shutting down; check the log`));
|
|
1742
2051
|
});
|
|
1743
2052
|
|
|
1744
2053
|
try {
|
package/src/client.js
CHANGED
|
@@ -19,18 +19,58 @@ export async function api(method, route, body) {
|
|
|
19
19
|
return data;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** The daemon's /health answer ({ ok, pid, closing, … }), or null when nothing answers. */
|
|
23
|
+
export async function health() {
|
|
24
|
+
try {
|
|
25
|
+
return await api('GET', '/health');
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A daemon that answers and isn't shutting down. */
|
|
22
32
|
export async function isRunning() {
|
|
33
|
+
const h = await health();
|
|
34
|
+
return Boolean(h && !h.closing);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const alive = (pid) => {
|
|
23
38
|
try {
|
|
24
|
-
|
|
39
|
+
process.kill(pid, 0);
|
|
25
40
|
return true;
|
|
26
|
-
} catch {
|
|
27
|
-
return
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return e.code === 'EPERM';
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wait for a daemon process to exit. Shutting down closes every agent first (to save their
|
|
48
|
+
* sessions), which takes a while with many agents, and it answers /health until then.
|
|
49
|
+
* Returns true once it is gone, false after `timeoutMs`.
|
|
50
|
+
*/
|
|
51
|
+
export async function waitForExit(pid, { timeoutMs = 300_000, onWait = null } = {}) {
|
|
52
|
+
if (!pid) return true;
|
|
53
|
+
const start = Date.now();
|
|
54
|
+
let told = 0;
|
|
55
|
+
while (alive(pid)) {
|
|
56
|
+
if (Date.now() - start > timeoutMs) return false;
|
|
57
|
+
if (onWait && Date.now() - told > 10_000) {
|
|
58
|
+
told = Date.now();
|
|
59
|
+
onWait(Math.round((Date.now() - start) / 1000));
|
|
60
|
+
}
|
|
61
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
28
62
|
}
|
|
63
|
+
return true;
|
|
29
64
|
}
|
|
30
65
|
|
|
31
|
-
/** Start the daemon in the background if it is not already listening. */
|
|
66
|
+
/** Start the daemon in the background if it is not already listening (waiting out one that is shutting down). */
|
|
32
67
|
export async function ensureDaemon({ quiet = false } = {}) {
|
|
33
|
-
|
|
68
|
+
const h = await health();
|
|
69
|
+
if (h && !h.closing) return false;
|
|
70
|
+
if (h?.closing) {
|
|
71
|
+
if (!quiet) process.stderr.write(`agentmgr: waiting for the daemon (pid ${h.pid}) to finish shutting down…\n`);
|
|
72
|
+
if (!(await waitForExit(h.pid))) throw new Error(`The daemon (pid ${h.pid}) is still shutting down; try again in a minute`);
|
|
73
|
+
}
|
|
34
74
|
ensureHome();
|
|
35
75
|
const log = fs.openSync(paths.log, 'a');
|
|
36
76
|
const child = spawn(process.execPath, [BIN, 'serve'], {
|
|
@@ -39,7 +79,7 @@ export async function ensureDaemon({ quiet = false } = {}) {
|
|
|
39
79
|
env: process.env,
|
|
40
80
|
});
|
|
41
81
|
child.unref();
|
|
42
|
-
for (let i = 0; i <
|
|
82
|
+
for (let i = 0; i < 150; i++) {
|
|
43
83
|
await new Promise((r) => setTimeout(r, 100));
|
|
44
84
|
if (await isRunning()) {
|
|
45
85
|
if (!quiet) process.stderr.write(`agentmgr: started daemon at ${BASE} (log: ${paths.log})\n`);
|
package/src/engines/claude.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|