ai-agentmanager 1.1.0 → 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.
@@ -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 &lt;token&gt;" -H "content-type: application/json" -d '{"intent":"…","project":"&lt;shortcut&gt;"}'</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
+ }
@@ -0,0 +1,279 @@
1
+ // The assistant: one always-on session that works on the person's behalf. Every few minutes the
2
+ // daemon asks it to check for new work (newly assigned Jira issues, GitHub review requests,
3
+ // failing checks on the person's PRs, new Jira comments, Slack messages) with its own tools, and
4
+ // it starts agents for that work through agentmgr, or passes news to the agent already on it.
5
+ // The daemon keeps the limits: a daily cap, a cap on its agents running at once, and no second
6
+ // agent for the same issue.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { paths } from './config.js';
11
+ import { leadingJiraKey } from './tracker.js';
12
+
13
+ export const SOURCES = {
14
+ jira: 'Jira issues newly assigned to the person',
15
+ reviews: "GitHub pull requests where the person's review is requested",
16
+ ci: "the person's own open GitHub pull requests whose checks fail",
17
+ comments: 'new comments and mentions on Jira issues the person owns',
18
+ slack: 'Slack messages to the person (direct messages and mentions) that ask for work',
19
+ linear: 'Linear issues newly assigned to the person',
20
+ };
21
+ export const ASSISTANT_DEFAULTS = {
22
+ everyMin: 15,
23
+ // Linear only when chosen: most people use Jira or Linear, not both.
24
+ sources: Object.keys(SOURCES).filter((k) => k !== 'linear'),
25
+ maxPerDay: 10,
26
+ maxRunning: 5,
27
+ permissionMode: 'auto',
28
+ model: null,
29
+ notes: '',
30
+ };
31
+ const DAY = 86_400_000;
32
+ const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'agentmgr.js');
33
+
34
+ const err = (status, message) => Object.assign(new Error(message), { status });
35
+
36
+ export const assistantDir = () => path.join(paths.home, 'assistant');
37
+
38
+ /** Settings from config.json's "assistant", with defaults and sane bounds. */
39
+ export function assistantSettings(config = {}) {
40
+ const c = { ...ASSISTANT_DEFAULTS, ...config.assistant };
41
+ const n = (v, d, lo, hi) => (Number.isFinite(Number(v)) && Number(v) >= lo ? Math.min(hi, Math.floor(Number(v))) : d);
42
+ const sources = Array.isArray(c.sources) ? c.sources.filter((s) => SOURCES[s]) : ASSISTANT_DEFAULTS.sources;
43
+ return {
44
+ everyMin: n(c.everyMin, ASSISTANT_DEFAULTS.everyMin, 1, 1440),
45
+ sources,
46
+ maxPerDay: n(c.maxPerDay, ASSISTANT_DEFAULTS.maxPerDay, 0, 500),
47
+ maxRunning: n(c.maxRunning, ASSISTANT_DEFAULTS.maxRunning, 0, 100),
48
+ permissionMode: typeof c.permissionMode === 'string' && c.permissionMode ? c.permissionMode : null,
49
+ model: typeof c.model === 'string' && c.model ? c.model : null,
50
+ notes: typeof c.notes === 'string' ? c.notes.slice(0, 4000) : '',
51
+ };
52
+ }
53
+
54
+ /** The assistant's standing instructions (appended to its system prompt on every launch). */
55
+ export function assistantPrompt(s, dir, bin = `node ${BIN}`) {
56
+ const am = bin;
57
+ const watch = s.sources.map((k) => `- ${SOURCES[k]}`).join('\n');
58
+ const how = [];
59
+ if (s.sources.includes('jira')) {
60
+ how.push(
61
+ `Jira, new assignments: with your Jira / Atlassian tools, find unresolved issues assigned to the person that were created or assigned since your last check (on the first check, the last 2 days). ` +
62
+ `For each one you haven't handled, start an agent: ${am} add @<project> "<the issue link> <a one-line summary of what to do>". ` +
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.`,
64
+ );
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
+ }
74
+ if (s.sources.includes('comments')) {
75
+ how.push(
76
+ `Jira, comments and mentions: find new comments on issues the person owns or is mentioned in, since your last check. ` +
77
+ `If an agent is already working on that issue (its name is the issue key; see ${am} ls --json), pass the comment on: ${am} tell <agent id> "New comment on <key> from <author>: <the comment>". ` +
78
+ `A comment that asks for new work on an issue with no agent: start one as above. Otherwise just record it.`,
79
+ );
80
+ }
81
+ if (s.sources.includes('reviews')) {
82
+ how.push(
83
+ `GitHub reviews: gh search prs --review-requested=@me --state=open --json url,title,repository,updatedAt. ` +
84
+ `For each pull request you haven't handled, start a review agent in its project: ${am} add @<project> "Review <the PR link>: read the change, check out the branch if needed, and write the review as comments for the person to post. Don't approve, request changes or post anything yourself."`,
85
+ );
86
+ }
87
+ if (s.sources.includes('ci')) {
88
+ how.push(
89
+ `Failing checks: gh search prs --author=@me --state=open --json url,title,repository, then gh pr checks <url> for each. ` +
90
+ `For a pull request with failing checks: if an agent already works on it (${am} ls --json lists each agent's PRs), tell that agent: ${am} tell <agent id> "Checks fail on <PR link>: <which checks>. Find out why and fix it." ` +
91
+ `Otherwise start one: ${am} add @<project> "Checks fail on <PR link>: find out why and fix it on that branch."`,
92
+ );
93
+ }
94
+ if (s.sources.includes('slack')) {
95
+ how.push(
96
+ `Slack: with your Slack tools, read direct messages to the person and messages that mention them, since your last check. ` +
97
+ `When one clearly asks for work in a known project, start an agent for it and include the message link in the request. Don't reply in Slack; record what you did.`,
98
+ );
99
+ }
100
+ return [
101
+ `You are the person's assistant inside AI Agent Manager (agentmgr). You run all the time in the background and work on their behalf: you find new work and start agents for it, so the work is under way before they look. You don't do the work yourself.`,
102
+ `Watch:\n${watch}`,
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.`,
104
+ `How:\n${how.map((h, i) => `${i + 1}. ${h}`).join('\n')}`,
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). ` +
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.`,
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.`,
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.`,
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.`,
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.`,
111
+ s.notes ? `The person's notes for you:\n${s.notes}` : '',
112
+ ]
113
+ .filter(Boolean)
114
+ .join('\n\n');
115
+ }
116
+
117
+ export class Assistant {
118
+ constructor({ manager, store, config = {}, log = (m) => console.log(`[agentmgr] ${m}`) }) {
119
+ this.manager = manager;
120
+ this.store = store;
121
+ this.config = config;
122
+ this.log = log;
123
+ store.data.assistant ??= {};
124
+ this.timer = setInterval(() => this.tick(), 30_000);
125
+ this.timer.unref?.();
126
+ }
127
+
128
+ close() {
129
+ clearInterval(this.timer);
130
+ }
131
+
132
+ get settings() {
133
+ return assistantSettings(this.config);
134
+ }
135
+
136
+ get state() {
137
+ return this.store.data.assistant;
138
+ }
139
+
140
+ /** The assistant agent, if there is one. */
141
+ agent() {
142
+ const id = this.state.agentId;
143
+ const a = id && this.store.get(id);
144
+ return a?.kind === 'assistant' ? a : null;
145
+ }
146
+
147
+ /** Change settings; kept in config.json by the caller. Running sessions get them on the next launch. */
148
+ update(patch = {}) {
149
+ if (patch.sources !== undefined && (!Array.isArray(patch.sources) || patch.sources.some((s) => !SOURCES[s]))) {
150
+ throw err(400, `sources must be a list of: ${Object.keys(SOURCES).join(', ')}`);
151
+ }
152
+ for (const k of ['everyMin', 'maxPerDay', 'maxRunning']) {
153
+ if (patch[k] !== undefined && !(Number(patch[k]) >= (k === 'everyMin' ? 1 : 0))) throw err(400, `${k} must be ${k === 'everyMin' ? 1 : 0} or more`);
154
+ }
155
+ const next = assistantSettings({ assistant: { ...this.config.assistant, ...patch } });
156
+ this.config.assistant = next;
157
+ return next;
158
+ }
159
+
160
+ /** Create the assistant if needed and start it; the first check follows once it is ready. */
161
+ start() {
162
+ let a = this.agent();
163
+ if (!a) {
164
+ const s = this.settings;
165
+ fs.mkdirSync(assistantDir(), { recursive: true });
166
+ a = this.manager.create({ kind: 'assistant', name: 'Assistant', permissionMode: s.permissionMode || undefined, model: s.model || undefined, start: true });
167
+ this.state.agentId = a.id;
168
+ this.state.nextAt = new Date(Date.now() + 20_000).toISOString();
169
+ this.store.save();
170
+ this.log('assistant started');
171
+ return this.status();
172
+ }
173
+ this.state.paused = false;
174
+ this.state.nextAt ??= new Date(Date.now() + 20_000).toISOString();
175
+ this.store.save();
176
+ if (!this.manager.runtimes.get(a.id)?.pty) this.manager.start(a.id);
177
+ return this.status();
178
+ }
179
+
180
+ /** Stop checking and close the session (it keeps its memory; start resumes it). */
181
+ async pause() {
182
+ const a = this.agent();
183
+ this.state.paused = true;
184
+ this.store.save();
185
+ if (a && this.manager.runtimes.get(a.id)?.pty) await this.manager.stop(a.id);
186
+ return this.status();
187
+ }
188
+
189
+ /** Ask for a check now (queued until the session is idle). */
190
+ check({ by = 'person' } = {}) {
191
+ const a = this.agent();
192
+ if (!a) throw err(409, 'The assistant is not set up yet: start it first');
193
+ const last = this.state.lastCheckAt;
194
+ const n = (this.state.checks ?? 0) + 1;
195
+ const text = `Check now (check ${n}, ${new Date().toISOString()}${last ? `; last check ${last}` : '; first check'}${by === 'person' ? ', asked by the person' : ''}).`;
196
+ this.manager.tell(a.id, text, { source: 'assistant' });
197
+ this.state.checks = n;
198
+ this.state.lastCheckAt = new Date().toISOString();
199
+ this.state.nextAt = new Date(Date.now() + this.settings.everyMin * 60_000).toISOString();
200
+ this.store.save();
201
+ return this.status();
202
+ }
203
+
204
+ /** The scheduler: a check every everyMin minutes while the assistant is on and free. */
205
+ tick(t = Date.now()) {
206
+ const a = this.agent();
207
+ // Paused, or closed by hand: no checks until it is started again.
208
+ if (!a || this.state.paused || a.desired === 'stopped') return;
209
+ if (Date.parse(this.state.nextAt ?? 0) > t) return;
210
+ const rt = this.manager.runtimes.get(a.id);
211
+ // Busy with the last check, or a message still waiting to be typed in: try again shortly.
212
+ if (rt?.busy || this.manager.outbox.get(a.id)?.length) return;
213
+ if (!rt?.pty && a.status === 'preparing') return;
214
+ try {
215
+ this.check({ by: 'schedule' });
216
+ } catch (e) {
217
+ this.log(`assistant: ${e.message}`);
218
+ }
219
+ }
220
+
221
+ /** Agents the assistant started. */
222
+ started() {
223
+ const a = this.agent();
224
+ return a ? this.store.list().filter((x) => x.startedBy === a.id) : [];
225
+ }
226
+
227
+ /**
228
+ * Called before the assistant's own session creates agents: enforces the daily cap, the cap on
229
+ * its running agents, and one agent per issue. Returns the creation options to add.
230
+ */
231
+ admit(callerId, intents, opts = {}) {
232
+ const a = this.agent();
233
+ if (!a || callerId !== a.id) return {};
234
+ const s = this.settings;
235
+ const mine = this.started();
236
+ const today = mine.filter((x) => Date.now() - Date.parse(x.createdAt) < DAY).length;
237
+ if (today + intents.length > s.maxPerDay) throw err(429, `The assistant's limit is ${s.maxPerDay} new agents a day (${today} so far); not started`);
238
+ const running = mine.filter((x) => this.manager.runtimes.get(x.id)?.pty || x.status === 'preparing').length;
239
+ if (running + intents.length > s.maxRunning) throw err(429, `The assistant's limit is ${s.maxRunning} of its agents running at once (${running} now); not started`);
240
+ for (const intent of intents) {
241
+ const key = opts.name?.trim() || leadingJiraKey(intent);
242
+ const dup = key && this.store.list().find((x) => x.kind === 'task' && x.name.toLowerCase() === key.toLowerCase());
243
+ if (dup) throw err(409, `An agent for ${key} already exists: ${dup.name} (${dup.id}); tell it instead`);
244
+ }
245
+ return { startedBy: a.id };
246
+ }
247
+
248
+ activity({ chars = 20_000 } = {}) {
249
+ try {
250
+ const text = fs.readFileSync(path.join(assistantDir(), 'activity.md'), 'utf8');
251
+ return text.length > chars ? text.slice(text.indexOf('\n## ', text.length - chars) + 1 || text.length - chars) : text;
252
+ } catch {
253
+ return '';
254
+ }
255
+ }
256
+
257
+ status() {
258
+ const a = this.agent();
259
+ const rt = a && this.manager.runtimes.get(a.id);
260
+ const mine = this.started();
261
+ return {
262
+ ...this.settings,
263
+ agentId: a?.id ?? null,
264
+ on: Boolean(a && !this.state.paused),
265
+ running: Boolean(rt?.pty),
266
+ busy: Boolean(rt?.busy),
267
+ checks: this.state.checks ?? 0,
268
+ lastCheckAt: this.state.lastCheckAt ?? null,
269
+ nextAt: a && !this.state.paused ? (this.state.nextAt ?? null) : null,
270
+ startedToday: mine.filter((x) => Date.now() - Date.parse(x.createdAt) < DAY).length,
271
+ startedTotal: mine.length,
272
+ started: mine
273
+ .sort((x, y) => y.createdAt.localeCompare(x.createdAt))
274
+ .slice(0, 20)
275
+ .map((x) => ({ id: x.id, name: x.name, intent: x.intent, createdAt: x.createdAt })),
276
+ activity: this.activity(),
277
+ };
278
+ }
279
+ }
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
+ }
package/src/cleanup.js CHANGED
@@ -81,6 +81,7 @@ export class Cleanup {
81
81
  /** Why an agent is left alone whatever its age, or null. */
82
82
  protectedBy(agent) {
83
83
  if (agent.keep) return 'marked Keep';
84
+ if (agent.kind === 'assistant') return 'the assistant';
84
85
  if (agent.status === 'preparing') return 'still setting up';
85
86
  const rt = this.manager.runtimes.get(agent.id);
86
87
  if (rt?.pty && rt.busy) return 'working';