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/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
- if (await isRunning()) {
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 --model');
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
- if (await isRunning()) {
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
- for (let i = 0; i < 50 && (await isRunning()); i++) await new Promise((r) => setTimeout(r, 200));
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
- if (await isRunning()) {
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
- for (let i = 0; i < 100 && (await isRunning()); i++) await new Promise((res) => setTimeout(res, 200));
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
- for (let i = 0; i < 100 && !(await isRunning()); i++) await new Promise((res) => setTimeout(res, 200));
1730
- if (!(await isRunning())) throw new Error('Daemon did not come back; check the log');
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
- if (!(await isRunning())) return console.log('Daemon is not running');
1740
- await api('POST', '/shutdown');
1741
- console.log('Daemon stopped');
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
- await api('GET', '/health');
39
+ process.kill(pid, 0);
25
40
  return true;
26
- } catch {
27
- return false;
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
- if (await isRunning()) return false;
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 < 50; 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`);
@@ -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
  }