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/CHANGELOG.md +15 -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 +292 -2
- package/src/engines/claude.js +6 -4
- package/src/git.js +4 -4
- package/src/landing.js +212 -0
- package/src/manager.js +98 -67
- 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 +85 -3
- package/src/stats.js +107 -0
- package/src/tracker.js +61 -8
- package/src/triggers.js +258 -0
package/public/setup.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Getting started: shown where an agent's details go while none is selected, until every step
|
|
2
|
+
// is done or it is hidden. Steps open the dialog that does them.
|
|
3
|
+
|
|
4
|
+
const STEPS = [
|
|
5
|
+
['agent', 'Start your first agent', 'Give it a request and a repo or folder; it works in its own branch.', '#new-btn', 'New agents'],
|
|
6
|
+
['alerts', 'Get Slack alerts', 'Hear when an agent needs you, is stuck or finished.', '#alerts-btn', 'Alerts'],
|
|
7
|
+
['assistant', 'Let work come to you', 'The assistant, or triggers (a GitHub label, a webhook), start agents for new work.', '#assistant-btn', 'Assistant'],
|
|
8
|
+
['remote', 'Use it from your phone', 'Answer agents from anywhere on your network.', '#remote-btn', 'Remote access'],
|
|
9
|
+
['budget', 'Set a budget', 'An alert at 80% of an agent budget, and a daily limit.', '#spend-btn', 'Results'],
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function initSetup({ api, esc, $ }) {
|
|
13
|
+
const el = $('#placeholder');
|
|
14
|
+
const plain = el.innerHTML;
|
|
15
|
+
let data = null;
|
|
16
|
+
const hidden = () => {
|
|
17
|
+
try {
|
|
18
|
+
return localStorage.getItem('agentmgr.setupHidden') === '1';
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function render() {
|
|
25
|
+
if (!data || hidden()) {
|
|
26
|
+
el.innerHTML = plain;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const done = STEPS.filter(([k]) => data.steps[k]).length;
|
|
30
|
+
if (done === STEPS.length && !data.checks.length) {
|
|
31
|
+
el.innerHTML = plain;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
el.innerHTML = `<div class="setup">
|
|
35
|
+
<div class="setup-head"><h3>Getting started</h3><span class="hint">${done} of ${STEPS.length} done</span><span class="spacer"></span><button class="link" data-setup-hide>Hide</button></div>
|
|
36
|
+
${data.checks.length ? `<div class="setup-checks">${data.checks.map((c) => `<div class="setup-check ${c.status}"><b>${esc(c.name)}</b>: ${esc(c.detail)}${c.fix ? `<div class="hint">${esc(c.fix)}</div>` : ''}</div>`).join('')}</div>` : ''}
|
|
37
|
+
<ol class="setup-steps">${STEPS.map(([k, title, why, btn, label]) => `<li class="${data.steps[k] ? 'done' : ''}">
|
|
38
|
+
<span class="setup-mark">${data.steps[k] ? '✓' : ''}</span>
|
|
39
|
+
<div><b>${title}</b><div class="hint">${why}</div></div>
|
|
40
|
+
${data.steps[k] ? '' : `<button class="small" data-setup-open="${btn}">${label}</button>`}</li>`).join('')}</ol>
|
|
41
|
+
<p class="hint">${plain}</p>
|
|
42
|
+
</div>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
el.addEventListener('click', (e) => {
|
|
46
|
+
const open = e.target.closest('[data-setup-open]');
|
|
47
|
+
if (open) return document.querySelector(open.dataset.setupOpen)?.click();
|
|
48
|
+
if (e.target.closest('[data-setup-hide]')) {
|
|
49
|
+
try {
|
|
50
|
+
localStorage.setItem('agentmgr.setupHidden', '1');
|
|
51
|
+
} catch {}
|
|
52
|
+
render();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function load() {
|
|
57
|
+
try {
|
|
58
|
+
data = await api('GET', '/setup');
|
|
59
|
+
} catch {
|
|
60
|
+
data = null;
|
|
61
|
+
}
|
|
62
|
+
render();
|
|
63
|
+
}
|
|
64
|
+
load();
|
|
65
|
+
window.addEventListener('focus', () => !hidden() && load());
|
|
66
|
+
return { load };
|
|
67
|
+
}
|
package/public/spend.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Spend dialog: today's cost, the last two weeks, the costliest agents, and the limits.
|
|
2
|
+
|
|
3
|
+
export function initSpend({ api, esc, toast, $ }) {
|
|
4
|
+
const dlg = $('#spend-dialog');
|
|
5
|
+
const f = $('#spend-form');
|
|
6
|
+
const money = (n) => `$${Number(n ?? 0).toFixed(2)}`;
|
|
7
|
+
|
|
8
|
+
function fill(b) {
|
|
9
|
+
$('#spend-today').textContent = b.today.total ? money(b.today.total) : '';
|
|
10
|
+
if (!dlg.open) return;
|
|
11
|
+
f.perAgentUsd.value = b.perAgentUsd ?? '';
|
|
12
|
+
f.perDayUsd.value = b.perDayUsd ?? '';
|
|
13
|
+
const max = Math.max(...b.days.map((d) => d.total), 0.01);
|
|
14
|
+
$('#spend-summary').innerHTML = `
|
|
15
|
+
<p class="spend-big">${money(b.today.total)} <span class="hint">today${b.perDayUsd ? ` of ${money(b.perDayUsd)}` : ''}</span></p>
|
|
16
|
+
${b.days.length > 1 ? `<div class="spend-bars" title="The last two weeks">${[...b.days].reverse().map((d) => `<span style="height:${Math.max(2, Math.round((d.total / max) * 40))}px" title="${esc(d.day)}: ${money(d.total)}"></span>`).join('')}</div>` : ''}`;
|
|
17
|
+
$('#spend-agents').innerHTML = b.agents.length
|
|
18
|
+
? `<table class="spend-table"><tr><th>Agent</th><th>Spent</th><th>Today</th><th>Budget</th></tr>${b.agents.map((a) => `<tr class="${a.hit ? 'over' : ''}">
|
|
19
|
+
<td><a href="#${a.id}">${esc(a.name)}</a></td><td>${money(a.cost)}</td><td>${a.today ? money(a.today) : ''}</td>
|
|
20
|
+
<td>${a.limit ? `${money(a.limit)}${a.own ? '' : ' <span class="hint">default</span>'}${a.hit ? ' <span class="tag fail">reached</span>' : ''}` : '—'}</td></tr>`).join('')}</table>`
|
|
21
|
+
: '<p class="hint">No costs recorded yet.</p>';
|
|
22
|
+
$('#spend-note').textContent = b.note;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hours = (h) => (h == null ? '—' : h < 1 ? `${Math.round(h * 60)} min` : h < 48 ? `${h.toFixed(1)} h` : `${(h / 24).toFixed(1)} days`);
|
|
26
|
+
const pct = (r) => (r == null ? '—' : `${Math.round(r * 100)}%`);
|
|
27
|
+
|
|
28
|
+
function table(title, rows) {
|
|
29
|
+
if (!rows.length) return '';
|
|
30
|
+
return `<details class="stats-table" ${title === 'By engine and model' ? 'open' : ''}><summary>${title}</summary><table class="spend-table">
|
|
31
|
+
<tr><th></th><th>Agents</th><th>Merged</th><th>Merge rate</th><th>To PR</th><th>Cost / merged</th></tr>
|
|
32
|
+
${rows.map((r) => `<tr><td>${esc(r.key)}</td><td>${r.agents}</td><td>${r.merged}</td><td>${pct(r.mergeRate)}</td><td>${hours(r.medianHoursToPr)}</td><td>${r.costPerMerged == null ? '—' : money(r.costPerMerged)}</td></tr>`).join('')}
|
|
33
|
+
</table></details>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fillStats(st) {
|
|
37
|
+
const t = st.total;
|
|
38
|
+
const kpi = (label, value, hint = '') => `<div class="kpi"><div class="kpi-v">${value}</div><div class="kpi-l">${label}</div>${hint ? `<div class="hint">${hint}</div>` : ''}</div>`;
|
|
39
|
+
$('#stats-body').innerHTML = t.agents
|
|
40
|
+
? `<div class="kpis">
|
|
41
|
+
${kpi('agents', t.agents, `${t.withPr} opened a PR`)}
|
|
42
|
+
${kpi('PRs merged', t.merged, `${t.open} open, ${t.closed} closed`)}
|
|
43
|
+
${kpi('merge rate', pct(t.mergeRate), 'of PRs merged or closed')}
|
|
44
|
+
${kpi('to first PR', hours(t.medianHoursToPr), 'median')}
|
|
45
|
+
${kpi('to merge', hours(t.medianHoursToMerge), 'median')}
|
|
46
|
+
${kpi('per merged PR', t.costPerMerged == null ? '—' : money(t.costPerMerged), `${money(t.cost)} in all`)}
|
|
47
|
+
</div>
|
|
48
|
+
${table('By engine and model', st.byEngine)}${table('By where they came from', st.bySource)}${table('By project', st.byProject)}`
|
|
49
|
+
: '<p class="hint">No agents in this period.</p>';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function refresh() {
|
|
53
|
+
try {
|
|
54
|
+
fill(await api('GET', '/budgets'));
|
|
55
|
+
if (dlg.open) fillStats(await api('GET', `/stats?days=${$('#stats-days').value}`));
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
$('#stats-days').onchange = () => refresh();
|
|
59
|
+
|
|
60
|
+
$('#spend-btn').onclick = async () => {
|
|
61
|
+
$('#spend-error').hidden = true;
|
|
62
|
+
dlg.showModal();
|
|
63
|
+
await refresh();
|
|
64
|
+
};
|
|
65
|
+
$('#spend-close').onclick = () => dlg.close();
|
|
66
|
+
f.addEventListener('submit', async (e) => {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
try {
|
|
69
|
+
fill(await api('PUT', '/budgets', { perAgentUsd: f.perAgentUsd.value || null, perDayUsd: f.perDayUsd.value || null }));
|
|
70
|
+
toast('Limits saved');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
$('#spend-error').hidden = false;
|
|
73
|
+
$('#spend-error').textContent = err.message;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
dlg.addEventListener('click', (e) => {
|
|
77
|
+
if (e.target.closest('a[href^="#"]')) dlg.close();
|
|
78
|
+
});
|
|
79
|
+
refresh();
|
|
80
|
+
setInterval(refresh, 120_000);
|
|
81
|
+
return { refresh };
|
|
82
|
+
}
|
package/public/style.css
CHANGED
|
@@ -625,3 +625,107 @@ pre.diff .meta { color: var(--muted); font-weight: 600; }
|
|
|
625
625
|
.team-quest { border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; }
|
|
626
626
|
.team-quest summary { cursor: pointer; }
|
|
627
627
|
.team-quest .team-timeline { margin-top: 8px; }
|
|
628
|
+
|
|
629
|
+
/* Review changes */
|
|
630
|
+
.review-dialog { width: min(1400px, calc(100vw - 24px)); height: calc(100vh - 32px); max-height: none; }
|
|
631
|
+
.review-body { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; padding: 8px 18px 14px; }
|
|
632
|
+
.rv-summary { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; font-size: 13px; }
|
|
633
|
+
.rv-warn { margin: 0; color: var(--yellow); }
|
|
634
|
+
.rv-add { color: var(--green); font-family: var(--mono); font-size: 12px; }
|
|
635
|
+
.rv-del { color: var(--red); font-family: var(--mono); font-size: 12px; }
|
|
636
|
+
.rv-main { flex: 1; min-height: 0; display: grid; grid-template-columns: 240px 1fr; gap: 10px; }
|
|
637
|
+
.rv-files { overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; padding: 4px; font-size: 12px; }
|
|
638
|
+
.rv-files a { display: grid; grid-template-columns: 1fr auto; padding: 4px 6px; border-radius: 4px; color: var(--text); text-decoration: none; }
|
|
639
|
+
.rv-files a:hover { background: var(--panel-2); }
|
|
640
|
+
.rv-fname { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
641
|
+
.rv-fdir { grid-column: 1; color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
642
|
+
.rv-fstat { grid-column: 2; grid-row: 1 / span 2; align-self: center; white-space: nowrap; }
|
|
643
|
+
.rv-commits { margin: 8px 4px 2px; color: var(--muted); }
|
|
644
|
+
.rv-commits div { padding: 2px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
645
|
+
.rv-diffs { overflow: auto; display: flex; flex-direction: column; gap: 10px; }
|
|
646
|
+
.rv-file { border: 1px solid var(--border); border-radius: 6px; }
|
|
647
|
+
.rv-file-head { position: sticky; top: 0; z-index: 1; display: flex; gap: 6px; align-items: center; padding: 6px 10px; background: var(--panel-2); border-radius: 6px 6px 0 0; cursor: pointer; font-size: 13px; }
|
|
648
|
+
.rv-caret { color: var(--muted); width: 10px; }
|
|
649
|
+
.tag.rv-added { color: var(--green); }
|
|
650
|
+
.tag.rv-deleted { color: var(--red); }
|
|
651
|
+
.rv-pad { padding: 8px 10px; margin: 0; }
|
|
652
|
+
.rv-table { width: 100%; border-collapse: collapse; font: 12px/1.5 var(--mono); }
|
|
653
|
+
.rv-table td { padding: 0 6px; vertical-align: top; }
|
|
654
|
+
.rv-no { width: 1%; min-width: 38px; text-align: right; color: var(--muted); user-select: none; cursor: pointer; position: relative; white-space: nowrap; }
|
|
655
|
+
.rv-code { white-space: pre-wrap; word-break: break-word; }
|
|
656
|
+
.rv-sign { display: inline-block; width: 1.2em; color: var(--muted); user-select: none; }
|
|
657
|
+
tr.rv-add td.rv-code, tr.rv-add td.rv-no { background: color-mix(in srgb, var(--green) 12%, transparent); }
|
|
658
|
+
tr.rv-del td.rv-code, tr.rv-del td.rv-no { background: color-mix(in srgb, var(--red) 12%, transparent); }
|
|
659
|
+
tr.rv-hunk td { color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); padding: 2px 8px; }
|
|
660
|
+
.rv-plus { display: none; position: absolute; left: 2px; top: 1px; padding: 0 5px; font-size: 11px; line-height: 16px; border-radius: 3px; background: var(--accent); color: var(--accent-text); border: 0; cursor: pointer; }
|
|
661
|
+
tr[data-rv-line]:hover .rv-plus { display: inline-block; }
|
|
662
|
+
@media (hover: none) { .rv-plus { display: inline-block; opacity: .6; } }
|
|
663
|
+
tr.rv-note td { padding: 6px 10px; background: var(--panel); font-family: inherit; }
|
|
664
|
+
.rv-comment { display: flex; gap: 8px; align-items: flex-start; border-left: 3px solid var(--accent); padding: 4px 8px; background: var(--panel-2); border-radius: 4px; font: 13px/1.45 system-ui, sans-serif; }
|
|
665
|
+
.rv-comment-text { white-space: pre-wrap; }
|
|
666
|
+
.rv-form textarea { width: 100%; font: 13px/1.45 system-ui, sans-serif; }
|
|
667
|
+
.rv-form-actions, .rv-row { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
|
|
668
|
+
.rv-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; border-top: 1px solid var(--border); padding-top: 8px; }
|
|
669
|
+
.rv-send textarea { width: 100%; }
|
|
670
|
+
.rv-general { display: flex; flex-direction: column; gap: 4px; margin-bottom: 4px; }
|
|
671
|
+
.rv-land input:not([type]) { flex: 1; min-width: 0; }
|
|
672
|
+
.d-actions .count { font-size: 11px; padding: 0 5px; border-radius: 8px; background: var(--accent); color: var(--accent-text); }
|
|
673
|
+
@media (max-width: 760px) {
|
|
674
|
+
.rv-main { grid-template-columns: 1fr; }
|
|
675
|
+
.rv-files { max-height: 120px; }
|
|
676
|
+
.rv-actions { grid-template-columns: 1fr; }
|
|
677
|
+
}
|
|
678
|
+
.rv-land .check { display: inline-flex; gap: 4px; align-items: center; white-space: nowrap; margin: 0; }
|
|
679
|
+
|
|
680
|
+
/* Land tab */
|
|
681
|
+
.land-row { cursor: default; }
|
|
682
|
+
.land-row .name { white-space: normal; color: var(--text); text-decoration: none; font-weight: 600; }
|
|
683
|
+
.land-row .name:hover { text-decoration: underline; }
|
|
684
|
+
.land-chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0; }
|
|
685
|
+
.tag.fail { color: var(--red); }
|
|
686
|
+
.badge.ready { background: var(--green); color: #fff; border-color: var(--green); }
|
|
687
|
+
.land-empty { padding: 10px 14px; }
|
|
688
|
+
.land-settings { padding: 8px 14px 14px; font-size: 13px; }
|
|
689
|
+
.land-settings summary { cursor: pointer; color: var(--muted); margin-bottom: 6px; }
|
|
690
|
+
.land-settings label { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
|
|
691
|
+
.land-settings input[type="number"] { width: 56px; }
|
|
692
|
+
.land-settings select { width: auto; }
|
|
693
|
+
.links li:has(.pr-chips) { flex-wrap: wrap; }
|
|
694
|
+
.pr-chips { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-left: 2px; }
|
|
695
|
+
.land-settings input[type="checkbox"] { width: auto; flex: none; margin: 0; }
|
|
696
|
+
input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
|
|
697
|
+
#triggers-newtoken { width: auto; justify-self: start; align-self: flex-start; }
|
|
698
|
+
.trig-recent { margin: 4px 0 0; padding-left: 18px; }
|
|
699
|
+
.spend-today:empty { display: none; }
|
|
700
|
+
.spend-today { font-size: 11px; color: var(--muted); margin-left: 2px; }
|
|
701
|
+
.spend-big { font-size: 22px; font-weight: 700; margin: 0 0 6px; }
|
|
702
|
+
.spend-bars { display: flex; align-items: flex-end; gap: 3px; height: 42px; margin-bottom: 8px; }
|
|
703
|
+
.spend-bars span { width: 12px; background: var(--accent); border-radius: 2px 2px 0 0; opacity: .8; }
|
|
704
|
+
.spend-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
705
|
+
.spend-table th, .spend-table td { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--border); }
|
|
706
|
+
.spend-table tr.over td { color: var(--red); }
|
|
707
|
+
.inline-select { width: auto; font-size: 13px; font-weight: 400; margin-left: 6px; padding: 2px 4px; }
|
|
708
|
+
.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 8px; }
|
|
709
|
+
.kpi { border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; }
|
|
710
|
+
.kpi-v { font-size: 20px; font-weight: 700; }
|
|
711
|
+
.kpi-l { font-size: 12px; color: var(--muted); }
|
|
712
|
+
.stats-table { margin: 6px 0; }
|
|
713
|
+
.stats-table summary { cursor: pointer; font-size: 13px; color: var(--muted); }
|
|
714
|
+
.spend-h { margin: 14px 0 4px; font-size: 14px; }
|
|
715
|
+
#spend-dialog { width: min(720px, calc(100vw - 32px)); }
|
|
716
|
+
@media (max-width: 560px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
|
|
717
|
+
.pb-table td { vertical-align: top; }
|
|
718
|
+
.pb-intent { max-width: 360px; white-space: normal; }
|
|
719
|
+
.pb-actions { white-space: nowrap; text-align: right; }
|
|
720
|
+
.pb-form { border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-top: 10px; }
|
|
721
|
+
.memory-text { width: 100%; font: 12px/1.5 var(--mono); }
|
|
722
|
+
#memory-dialog { width: min(760px, calc(100vw - 32px)); }
|
|
723
|
+
.setup { max-width: 640px; margin: 24px auto; text-align: left; color: var(--text); }
|
|
724
|
+
.setup-head { display: flex; gap: 10px; align-items: baseline; }
|
|
725
|
+
.setup-head h3 { margin: 0; font-size: 16px; }
|
|
726
|
+
.setup-steps { list-style: none; padding: 0; margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
|
|
727
|
+
.setup-steps li { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
|
|
728
|
+
.setup-steps li.done { opacity: .6; }
|
|
729
|
+
.setup-mark { color: var(--green); font-weight: 700; }
|
|
730
|
+
.setup-check { padding: 6px 10px; border-radius: 6px; margin: 6px 0; border-left: 3px solid var(--yellow); background: var(--panel-2); font-size: 13px; }
|
|
731
|
+
.setup-check.fail { border-left-color: var(--red); }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Triggers dialog: GitHub label, /agent PR comments and the webhook (see src/triggers.js).
|
|
2
|
+
|
|
3
|
+
export function initTriggers({ api, esc, toast, $ }) {
|
|
4
|
+
const dlg = $('#triggers-dialog');
|
|
5
|
+
const f = $('#triggers-form');
|
|
6
|
+
let state = null;
|
|
7
|
+
|
|
8
|
+
const ago = (iso) => {
|
|
9
|
+
if (!iso) return 'never';
|
|
10
|
+
const m = Math.round((Date.now() - Date.parse(iso)) / 60_000);
|
|
11
|
+
return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : `${Math.round(m / 60)} h ago`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function fill(t, token) {
|
|
15
|
+
state = t;
|
|
16
|
+
f.gh_enabled.checked = t.github.enabled;
|
|
17
|
+
f.gh_label.value = t.github.label;
|
|
18
|
+
f.gh_everyMin.value = t.github.everyMin;
|
|
19
|
+
f.gh_comment.checked = t.github.comment;
|
|
20
|
+
f.gh_repos.value = t.github.repos.join(' ');
|
|
21
|
+
f.gh_owners.value = t.github.owners.join(' ');
|
|
22
|
+
f.pc_enabled.checked = t.prCommands.enabled;
|
|
23
|
+
f.pc_prefix.value = t.prCommands.prefix;
|
|
24
|
+
f.wh_enabled.checked = t.webhook.enabled;
|
|
25
|
+
f.wh_repos.value = t.webhook.repos.join(' ');
|
|
26
|
+
f.maxPerDay.value = t.maxPerDay;
|
|
27
|
+
const url = `${location.origin}/api/hooks/agents`;
|
|
28
|
+
$('#triggers-token').innerHTML = token
|
|
29
|
+
? `New token, shown only now. Copy it: <code>${esc(token)}</code><br>Example: <code>curl -X POST ${esc(url)} -H "Authorization: Bearer <token>" -H "content-type: application/json" -d '{"intent":"…","project":"<shortcut>"}'</code>`
|
|
30
|
+
: t.webhook.hasToken
|
|
31
|
+
? 'A token is set (it is shown only when made). Other machines reach this only through remote access.'
|
|
32
|
+
: 'No token yet: make one to turn the webhook on.';
|
|
33
|
+
$('#triggers-status').innerHTML = `Last check ${ago(t.lastPoll?.at)}${t.lastPoll ? ` (${t.lastPoll.issues} started, ${t.lastPoll.commands} comments passed on)` : ''} · ${t.startedToday} started in the last day${t.lastError ? `<br><span class="error">${esc(t.lastError.message)}</span>` : ''}
|
|
34
|
+
${t.recent.length ? `<ul class="trig-recent">${t.recent.slice(0, 6).map((r) => `<li>${ago(r.at)}: <a href="#${r.agent}">${esc(r.name)}</a> from ${esc(r.source)}${r.url ? ` <span class="hint">${esc(r.url)}</span>` : ''}</li>`).join('')}</ul>` : ''}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const patch = () => ({
|
|
38
|
+
github: { enabled: f.gh_enabled.checked, label: f.gh_label.value.trim(), everyMin: Number(f.gh_everyMin.value) || 5, comment: f.gh_comment.checked, repos: f.gh_repos.value, owners: f.gh_owners.value },
|
|
39
|
+
prCommands: { enabled: f.pc_enabled.checked, prefix: f.pc_prefix.value.trim() || '/agent' },
|
|
40
|
+
webhook: { enabled: f.wh_enabled.checked, repos: f.wh_repos.value },
|
|
41
|
+
maxPerDay: Number(f.maxPerDay.value),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const error = (msg) => {
|
|
45
|
+
$('#triggers-error').hidden = !msg;
|
|
46
|
+
$('#triggers-error').textContent = msg ?? '';
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
async function open() {
|
|
50
|
+
error(null);
|
|
51
|
+
fill(await api('GET', '/triggers'));
|
|
52
|
+
if (!dlg.open) dlg.showModal();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
f.addEventListener('submit', async (e) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
try {
|
|
58
|
+
fill(await api('PUT', '/triggers', patch()));
|
|
59
|
+
error(null);
|
|
60
|
+
toast('Triggers saved');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
error(err.message);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
$('#triggers-newtoken').onclick = async () => {
|
|
66
|
+
if (state?.webhook.hasToken && !confirm('Make a new token? The old one stops working.')) return;
|
|
67
|
+
try {
|
|
68
|
+
const r = await api('PUT', '/triggers', { ...patch(), webhook: { ...patch().webhook, enabled: true, newToken: true } });
|
|
69
|
+
fill(r, r.token);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
error(err.message);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
$('#triggers-poll').onclick = async () => {
|
|
75
|
+
try {
|
|
76
|
+
const r = await api('POST', '/triggers/poll');
|
|
77
|
+
fill(r);
|
|
78
|
+
toast(`${r.issues} started, ${r.commands} comments passed on`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
error(err.message);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
$('#triggers-close').onclick = () => dlg.close();
|
|
84
|
+
return { open };
|
|
85
|
+
}
|
package/src/assistant.js
CHANGED
|
@@ -16,10 +16,12 @@ export const SOURCES = {
|
|
|
16
16
|
ci: "the person's own open GitHub pull requests whose checks fail",
|
|
17
17
|
comments: 'new comments and mentions on Jira issues the person owns',
|
|
18
18
|
slack: 'Slack messages to the person (direct messages and mentions) that ask for work',
|
|
19
|
+
linear: 'Linear issues newly assigned to the person',
|
|
19
20
|
};
|
|
20
21
|
export const ASSISTANT_DEFAULTS = {
|
|
21
22
|
everyMin: 15,
|
|
22
|
-
|
|
23
|
+
// Linear only when chosen: most people use Jira or Linear, not both.
|
|
24
|
+
sources: Object.keys(SOURCES).filter((k) => k !== 'linear'),
|
|
23
25
|
maxPerDay: 10,
|
|
24
26
|
maxRunning: 5,
|
|
25
27
|
permissionMode: 'auto',
|
|
@@ -61,6 +63,14 @@ export function assistantPrompt(s, dir, bin = `node ${BIN}`) {
|
|
|
61
63
|
`Start the request with the issue link: the agent is then named after the issue key, and the daemon refuses a second agent for the same key.`,
|
|
62
64
|
);
|
|
63
65
|
}
|
|
66
|
+
if (s.sources.includes('linear')) {
|
|
67
|
+
how.push(
|
|
68
|
+
`Linear, new assignments: with your Linear tools, find open issues assigned to the person that were created or assigned since your last check (on the first check, the last 2 days). ` +
|
|
69
|
+
`For each one you haven't handled, start an agent: ${am} add @<project> "<the issue link> <a one-line summary of what to do>". ` +
|
|
70
|
+
`Start the request with the issue link (https://linear.app/<team>/issue/<KEY>/…): the agent is then named after the issue key, and the daemon refuses a second agent for the same key. ` +
|
|
71
|
+
`New comments on issues that already have an agent: pass them on with ${am} tell, as for Jira.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
64
74
|
if (s.sources.includes('comments')) {
|
|
65
75
|
how.push(
|
|
66
76
|
`Jira, comments and mentions: find new comments on issues the person owns or is mentioned in, since your last check. ` +
|
|
@@ -92,12 +102,12 @@ export function assistantPrompt(s, dir, bin = `node ${BIN}`) {
|
|
|
92
102
|
`Watch:\n${watch}`,
|
|
93
103
|
`Each time you get a message starting with "Check now", do one check of every source above, act, record it, and wait for the next message. Don't loop or sleep on your own.`,
|
|
94
104
|
`How:\n${how.map((h, i) => `${i + 1}. ${h}`).join('\n')}`,
|
|
95
|
-
`Projects: ${am} project ls shows the projects and their shortcuts. Pick the project whose repository matches the work (the Jira project, the GitHub repository). ` +
|
|
105
|
+
`Projects: ${am} project ls shows the projects and their shortcuts. Pick the project whose repository matches the work (the Jira or Linear project, the GitHub repository). ` +
|
|
96
106
|
`If no project fits, use --repo <git URL> when you know the repository; if you don't know where the work belongs, don't start an agent: list it under "Needs you" in the activity file.`,
|
|
97
107
|
`Limits: the daemon allows at most ${s.maxPerDay} new agents a day and ${s.maxRunning} of yours running at once, and refuses a second agent for the same issue. When ${am} add is refused, don't retry it in this check: record the reason.`,
|
|
98
108
|
`Memory: keep ${path.join(dir, 'handled.json')} as a JSON object from item keys (a Jira key, a PR URL, "<key>#comment-<id>", a Slack message link) to { "at": time, "action": "started <agent id>" | "told <agent id>" | "skipped: why" }. Read it at the start of each check and add what you handled, so you never act on the same item twice. Keep "lastCheck" in it too.`,
|
|
99
109
|
`Activity: after each check, append a short entry to ${path.join(dir, 'activity.md')}: a "## <date and time>" heading, then one line per action ("Started OPS-7 (agent 3fa2b1) in @cc: fix the login timeout", "Told 3fa2b1 about a new comment on OPS-7"), and a "Needs you" list for anything you couldn't decide. Write "Nothing new." when nothing came in. AI Agent Manager shows this file to the person.`,
|
|
100
|
-
`Never merge, approve, close, reassign or comment on anything in Jira, GitHub or Slack, and never send messages as the person. Reading, and starting or telling agents, is all you do.`,
|
|
110
|
+
`Never merge, approve, close, reassign or comment on anything in Jira, Linear, GitHub or Slack, and never send messages as the person. Reading, and starting or telling agents, is all you do.`,
|
|
101
111
|
s.notes ? `The person's notes for you:\n${s.notes}` : '',
|
|
102
112
|
]
|
|
103
113
|
.filter(Boolean)
|
package/src/budgets.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Spending limits, from the cost Claude Code records for each session (Codex and Antigravity
|
|
2
|
+
// record none). Per agent: at 80% you get an alert; at the limit the agent's session is closed
|
|
3
|
+
// and it won't start again until its budget is raised. Per day: once today's spend reaches the
|
|
4
|
+
// limit, no new agents start (or resume) until tomorrow or a higher limit; running ones go on.
|
|
5
|
+
// A built-in policy enforces both, so every way of starting an agent is covered.
|
|
6
|
+
|
|
7
|
+
export const BUDGET_DEFAULTS = { perAgentUsd: null, perDayUsd: null, warnAt: 0.8 };
|
|
8
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
9
|
+
const usd = (n) => `$${Number(n).toFixed(2)}`;
|
|
10
|
+
|
|
11
|
+
/** Settings from config.json's "budgets": dollar limits (null = none) and the alert fraction. */
|
|
12
|
+
export function budgetSettings(config = {}) {
|
|
13
|
+
const c = { ...BUDGET_DEFAULTS, ...config.budgets };
|
|
14
|
+
const money = (v) => (v === null || v === undefined || v === '' ? null : Number.isFinite(Number(v)) && Number(v) > 0 ? Math.round(Number(v) * 100) / 100 : null);
|
|
15
|
+
const w = Number(c.warnAt);
|
|
16
|
+
return { perAgentUsd: money(c.perAgentUsd), perDayUsd: money(c.perDayUsd), warnAt: Number.isFinite(w) && w > 0 && w < 1 ? w : BUDGET_DEFAULTS.warnAt };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What an agent's sessions cost so far, as Claude Code recorded it. */
|
|
20
|
+
export function costOf(agent) {
|
|
21
|
+
return (agent.sessions ?? []).reduce((sum, s) => sum + (Number(s.cost?.usd) || 0), 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const dayKey = (t = new Date()) => {
|
|
25
|
+
const d = new Date(t);
|
|
26
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class Budgets {
|
|
30
|
+
constructor({ manager, store, config = {}, notifier = null, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
31
|
+
this.manager = manager;
|
|
32
|
+
this.store = store;
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.notifier = notifier;
|
|
35
|
+
this.log = log;
|
|
36
|
+
store.data.spend ??= { days: {}, seen: {} };
|
|
37
|
+
this.startedAt = Date.now();
|
|
38
|
+
// Today's spend counts from now: what agents cost before limits existed isn't today's.
|
|
39
|
+
this.seed();
|
|
40
|
+
manager.plugins.api('budgets', config).policy((action, details) => this.policy(action, details));
|
|
41
|
+
this.timer = setInterval(() => this.tick(), (config.budgetCheckSec ?? 60) * 1000);
|
|
42
|
+
this.timer.unref?.();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
close() {
|
|
46
|
+
clearInterval(this.timer);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get settings() {
|
|
50
|
+
return budgetSettings(this.config);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get ledger() {
|
|
54
|
+
return this.store.data.spend;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
seed() {
|
|
58
|
+
for (const a of this.store.list()) if (this.ledger.seen[a.id] === undefined) this.ledger.seen[a.id] = costOf(a);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
limitOf(agent) {
|
|
62
|
+
return agent.budgetUsd ?? this.settings.perAgentUsd;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
today() {
|
|
66
|
+
return this.ledger.days[dayKey()] ?? { total: 0, byAgent: {} };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
policy(action, details) {
|
|
70
|
+
if (action !== 'agent.start' && action !== 'agent.create') return undefined;
|
|
71
|
+
const s = this.settings;
|
|
72
|
+
if (s.perDayUsd && this.today().total >= s.perDayUsd) {
|
|
73
|
+
return { allow: false, reason: `Today's spend (${usd(this.today().total)}) reached the daily limit of ${usd(s.perDayUsd)}; raise it in Budgets or wait until tomorrow` };
|
|
74
|
+
}
|
|
75
|
+
if (action === 'agent.start') {
|
|
76
|
+
const agent = this.store.get(details.agent?.id);
|
|
77
|
+
const limit = agent && this.limitOf(agent);
|
|
78
|
+
if (limit && costOf(agent) >= limit) {
|
|
79
|
+
return { allow: false, reason: `${agent.name} has spent ${usd(costOf(agent))} of its ${usd(limit)} budget; raise its budget (Edit) to continue` };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Count new spend into today, warn near a limit, close agents that reached theirs. */
|
|
86
|
+
tick() {
|
|
87
|
+
const s = this.settings;
|
|
88
|
+
const key = dayKey();
|
|
89
|
+
const day = (this.ledger.days[key] ??= { total: 0, byAgent: {} });
|
|
90
|
+
let changed = false;
|
|
91
|
+
for (const agent of this.store.list()) {
|
|
92
|
+
const cost = costOf(agent);
|
|
93
|
+
// An agent new since start counts from zero; one from before limits existed, from now.
|
|
94
|
+
const before = this.ledger.seen[agent.id] ?? (Date.parse(agent.createdAt) >= this.startedAt ? 0 : cost);
|
|
95
|
+
if (cost > before) {
|
|
96
|
+
const d = cost - before;
|
|
97
|
+
day.total = Math.round((day.total + d) * 10_000) / 10_000;
|
|
98
|
+
day.byAgent[agent.id] = Math.round(((day.byAgent[agent.id] ?? 0) + d) * 10_000) / 10_000;
|
|
99
|
+
changed = true;
|
|
100
|
+
}
|
|
101
|
+
if (this.ledger.seen[agent.id] !== cost) {
|
|
102
|
+
this.ledger.seen[agent.id] = cost;
|
|
103
|
+
changed = true;
|
|
104
|
+
}
|
|
105
|
+
const limit = this.limitOf(agent);
|
|
106
|
+
if (!limit) continue;
|
|
107
|
+
if (cost >= limit && !agent.budgetHit) {
|
|
108
|
+
this.manager.patch(agent.id, { budgetHit: new Date().toISOString() });
|
|
109
|
+
if (this.manager.runtimes.get(agent.id)?.pty) this.manager.stop(agent.id).catch(() => {});
|
|
110
|
+
this.notifier?.send?.('budget', agent, `reached its ${usd(limit)} budget (${usd(cost)} spent) and was closed. Raise its budget to continue.`);
|
|
111
|
+
this.log(`budgets: ${agent.name} reached its ${usd(limit)} budget; closed`);
|
|
112
|
+
} else if (cost >= limit * s.warnAt && cost < limit && agent.budgetWarned !== limit) {
|
|
113
|
+
this.manager.patch(agent.id, { budgetWarned: limit });
|
|
114
|
+
this.notifier?.send?.('budget', agent, `has spent ${usd(cost)} of its ${usd(limit)} budget.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (s.perDayUsd && day.total >= s.perDayUsd && !day.alerted) {
|
|
118
|
+
day.alerted = true;
|
|
119
|
+
changed = true;
|
|
120
|
+
this.log(`budgets: today's spend ${usd(day.total)} reached the daily limit ${usd(s.perDayUsd)}`);
|
|
121
|
+
this.notifier?.send?.('budget', { id: '', name: 'Daily limit' }, `reached: today's agent spend is ${usd(day.total)} of ${usd(s.perDayUsd)}. No new agents start until tomorrow or a higher limit.`);
|
|
122
|
+
}
|
|
123
|
+
// Keep 90 days of history.
|
|
124
|
+
for (const k of Object.keys(this.ledger.days).sort().slice(0, -90)) delete this.ledger.days[k];
|
|
125
|
+
if (changed) this.store.save();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
update(patch = {}) {
|
|
129
|
+
for (const k of ['perAgentUsd', 'perDayUsd']) {
|
|
130
|
+
const v = patch[k];
|
|
131
|
+
if (v !== undefined && v !== null && v !== '' && !(Number(v) > 0)) throw err(400, `${k} must be a positive number of dollars, or empty for no limit`);
|
|
132
|
+
}
|
|
133
|
+
const next = budgetSettings({ budgets: { ...this.config.budgets, ...patch } });
|
|
134
|
+
this.config.budgets = next;
|
|
135
|
+
// A higher limit lifts the stop on agents now under it.
|
|
136
|
+
for (const a of this.store.list()) {
|
|
137
|
+
const limit = this.limitOf(a);
|
|
138
|
+
if (a.budgetHit && (!limit || costOf(a) < limit)) this.manager.patch(a.id, { budgetHit: null });
|
|
139
|
+
}
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
status() {
|
|
144
|
+
this.tick();
|
|
145
|
+
const s = this.settings;
|
|
146
|
+
const today = this.today();
|
|
147
|
+
const names = Object.fromEntries(this.store.list().map((a) => [a.id, a.name]));
|
|
148
|
+
const days = Object.entries(this.ledger.days)
|
|
149
|
+
.sort(([a], [b]) => (a < b ? 1 : -1))
|
|
150
|
+
.slice(0, 14)
|
|
151
|
+
.map(([day, d]) => ({ day, total: d.total }));
|
|
152
|
+
const agents = this.store
|
|
153
|
+
.list()
|
|
154
|
+
.map((a) => ({ id: a.id, name: a.name, cost: Math.round(costOf(a) * 100) / 100, limit: this.limitOf(a), own: a.budgetUsd ?? null, hit: a.budgetHit ?? null, today: today.byAgent[a.id] ?? 0 }))
|
|
155
|
+
.filter((a) => a.cost > 0 || a.own)
|
|
156
|
+
.sort((x, y) => y.cost - x.cost)
|
|
157
|
+
.slice(0, 50);
|
|
158
|
+
return {
|
|
159
|
+
...s,
|
|
160
|
+
today: { total: Math.round(today.total * 100) / 100, top: Object.entries(today.byAgent).sort(([, a], [, b]) => b - a).slice(0, 5).map(([id, c]) => ({ id, name: names[id] ?? id, cost: Math.round(c * 100) / 100 })) },
|
|
161
|
+
days,
|
|
162
|
+
agents,
|
|
163
|
+
note: 'Costs are what Claude Code records for each session (the API price, even on a subscription plan). Codex and Antigravity record none.',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|