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/tracker.js CHANGED
@@ -72,8 +72,9 @@ export function recentSessionFiles(cwd, sinceMs) {
72
72
  const URL_TOKEN = /https?:\/\/[^\s"'<>`\\)\]]+/g;
73
73
  const PR_PATH = /^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/;
74
74
  const GH_ISSUE_PATH = /^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)(?:[/?#]|$)/;
75
- // Jira keys in links: /browse/KEY-1, ?selectedIssue=KEY-1 (boards, Polaris ideas), /issues/KEY-1.
76
- const JIRA_KEY = /(?:\/browse\/|[?&]selectedIssue=|\/issues\/)([A-Z][A-Z0-9_]+-\d+)\b/;
75
+ // Issue keys in links: Jira /browse/KEY-1 and ?selectedIssue=KEY-1 (boards, Polaris ideas),
76
+ // /issues/KEY-1, and Linear /issue/KEY-1.
77
+ const JIRA_KEY = /(?:\/browse\/|[?&]selectedIssue=|\/issues?\/)([A-Z][A-Z0-9_]+-\d+)\b/;
77
78
 
78
79
  const trimUrl = (url) => url.replace(/[.,;:!?'"]+$/, '');
79
80
 
@@ -95,7 +96,7 @@ export function bugFromRef(ref, source) {
95
96
  return { key: ref, url: null, source };
96
97
  }
97
98
 
98
- /** The Jira key when the text starts with a Jira link ("https://x.atlassian.net/browse/ABC-1 fix it" → "ABC-1"). */
99
+ /** The issue key when the text starts with a Jira or Linear link ("https://x.atlassian.net/browse/ABC-1 fix it" → "ABC-1"). */
99
100
  export function leadingJiraKey(text) {
100
101
  const first = String(text ?? '').trimStart().match(/^<?(https?:\/\/\S+)/);
101
102
  return first?.[1].match(JIRA_KEY)?.[1] ?? null;
@@ -218,17 +219,69 @@ export function scanTranscript(file, cursor, bugPatterns = []) {
218
219
  return result;
219
220
  }
220
221
 
222
+ /** The gh CLI to run (AGENTMGR_GH_BIN points the tests at a fake one). */
223
+ export const ghBin = () => process.env.AGENTMGR_GH_BIN || 'gh';
224
+
225
+ const PR_FIELDS = 'number,title,url,state,isDraft,headRefOid,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,createdAt,mergedAt';
226
+ const FAILED = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
227
+ const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
228
+
229
+ /** gh's statusCheckRollup -> { state: passed|failed|running|none, failed: [names], running, total } */
230
+ export function checkSummary(rollup) {
231
+ const list = Array.isArray(rollup) ? rollup : [];
232
+ const failed = [];
233
+ let running = 0;
234
+ for (const c of list) {
235
+ // Check runs have status/conclusion; commit statuses have state.
236
+ const result = c.conclusion || (c.status && c.status !== 'COMPLETED' ? 'PENDING' : null) || c.state || 'PENDING';
237
+ if (FAILED.has(result)) failed.push(c.name || c.context || 'check');
238
+ else if (!PASSED.has(result)) running += 1;
239
+ }
240
+ const state = !list.length ? 'none' : failed.length ? 'failed' : running ? 'running' : 'passed';
241
+ return { state, failed: failed.slice(0, 10), running, total: list.length };
242
+ }
243
+
244
+ /** Where a pull request stands on its way in: merged, closed, draft, fix, behind, ready or waiting. */
245
+ export function landState(pr) {
246
+ if (!pr?.state) return null;
247
+ if (pr.state === 'MERGED') return 'merged';
248
+ if (pr.state === 'CLOSED') return 'closed';
249
+ if (pr.isDraft) return 'draft';
250
+ const checks = pr.checks?.state;
251
+ if (checks === 'failed' || pr.review === 'CHANGES_REQUESTED' || pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY') return 'fix';
252
+ if (pr.mergeState === 'BEHIND') return 'behind';
253
+ if (checks === 'running') return 'waiting';
254
+ if (pr.mergeState === 'CLEAN' || pr.mergeState === 'HAS_HOOKS' || pr.mergeState === 'UNSTABLE') return 'ready';
255
+ if (pr.review === 'APPROVED' && pr.mergeable === 'MERGEABLE') return 'ready';
256
+ return 'waiting';
257
+ }
258
+
259
+ /** Keep what we use from gh's PR JSON, small enough to store per agent. */
260
+ export function compactPr(p) {
261
+ if (!p) return p;
262
+ const { statusCheckRollup, reviewDecision, mergeStateStatus, headRefOid, mergeable, createdAt, mergedAt, ...rest } = p;
263
+ const out = { ...rest };
264
+ if (createdAt) out.openedAt = createdAt;
265
+ if (mergedAt) out.mergedAt = mergedAt;
266
+ if (statusCheckRollup !== undefined) out.checks = checkSummary(statusCheckRollup);
267
+ if (reviewDecision !== undefined) out.review = reviewDecision || null;
268
+ if (mergeable !== undefined) out.mergeable = mergeable || null;
269
+ if (mergeStateStatus !== undefined) out.mergeState = mergeStateStatus || null;
270
+ if (headRefOid) out.head = headRefOid;
271
+ return out;
272
+ }
273
+
221
274
  /** PRs whose head is `branch`, via the gh CLI. Returns [] when gh is missing or unauthenticated. */
222
275
  export async function prsForBranch(cwd, branch) {
223
276
  if (process.env.AGENTMGR_NO_GH) return [];
224
277
  if (!branch || branch === 'HEAD' || ['main', 'master', 'develop', 'trunk'].includes(branch)) return [];
225
278
  try {
226
279
  const out = await run(
227
- 'gh',
228
- ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json', 'number,title,url,state,isDraft'],
280
+ ghBin(),
281
+ ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json', PR_FIELDS],
229
282
  { cwd, timeout: 30_000 },
230
283
  );
231
- return JSON.parse(out || '[]').map((p) => ({ ...prFromUrl(p.url, 'branch'), ...p }));
284
+ return JSON.parse(out || '[]').map((p) => ({ ...prFromUrl(p.url, 'branch'), ...compactPr(p) }));
232
285
  } catch {
233
286
  return [];
234
287
  }
@@ -238,8 +291,8 @@ export async function prsForBranch(cwd, branch) {
238
291
  export async function prDetails(url) {
239
292
  if (process.env.AGENTMGR_NO_GH) return null;
240
293
  try {
241
- const out = await run('gh', ['pr', 'view', url, '--json', 'number,title,url,state,isDraft'], { timeout: 30_000 });
242
- return JSON.parse(out);
294
+ const out = await run(ghBin(), ['pr', 'view', url, '--json', PR_FIELDS], { timeout: 30_000 });
295
+ return compactPr(JSON.parse(out));
243
296
  } catch {
244
297
  return null;
245
298
  }
@@ -0,0 +1,258 @@
1
+ // Triggers: start agents and pass on instructions from where the work lives, with no model
2
+ // watching (no tokens spent). Three sources:
3
+ // - GitHub label: open issues with a label (default "agentmgr") in the repos or owners you list
4
+ // each get an agent in that repo. Labeling needs triage access, so it is the go-ahead.
5
+ // - "/agent" comments on an agent's own pull request, by the repo's owners, members or
6
+ // collaborators, are typed into that agent.
7
+ // - Webhook: POST /api/hooks/agents with the secret token creates an agent (Jira automation,
8
+ // CI, scripts). Off until a token is set; reachable from other machines only with remote access.
9
+ import crypto from 'node:crypto';
10
+ import { run } from './git.js';
11
+ import { ghBin } from './tracker.js';
12
+
13
+ export const TRIGGER_DEFAULTS = {
14
+ github: { enabled: false, label: 'agentmgr', repos: [], owners: [], everyMin: 5, comment: false },
15
+ prCommands: { enabled: true, prefix: '/agent' },
16
+ webhook: { enabled: false, token: null, repos: [] },
17
+ maxPerDay: 20,
18
+ };
19
+ const TRUSTED = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
20
+ const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
21
+
22
+ const err = (status, message) => Object.assign(new Error(message), { status });
23
+ const list = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);
24
+
25
+ /** Settings from config.json's "triggers", with defaults; the webhook token is never returned by `view`. */
26
+ export function triggerSettings(config = {}) {
27
+ const t = config.triggers ?? {};
28
+ const gh = { ...TRIGGER_DEFAULTS.github, ...t.github };
29
+ const pc = { ...TRIGGER_DEFAULTS.prCommands, ...t.prCommands };
30
+ const wh = { ...TRIGGER_DEFAULTS.webhook, ...t.webhook };
31
+ const every = Number(gh.everyMin);
32
+ const max = Number(t.maxPerDay ?? TRIGGER_DEFAULTS.maxPerDay);
33
+ return {
34
+ github: {
35
+ enabled: Boolean(gh.enabled),
36
+ label: String(gh.label || 'agentmgr').trim(),
37
+ repos: list(gh.repos).filter((r) => REPO_RE.test(r)),
38
+ owners: list(gh.owners).filter((o) => /^[\w.-]+$/.test(o)),
39
+ everyMin: Number.isFinite(every) && every >= 1 ? Math.min(1440, Math.floor(every)) : 5,
40
+ comment: Boolean(gh.comment),
41
+ },
42
+ prCommands: { enabled: pc.enabled !== false, prefix: String(pc.prefix || '/agent').trim() },
43
+ webhook: { enabled: Boolean(wh.enabled && wh.token), token: wh.token || null, repos: list(wh.repos) },
44
+ maxPerDay: Number.isFinite(max) && max >= 0 ? Math.min(500, Math.floor(max)) : TRIGGER_DEFAULTS.maxPerDay,
45
+ };
46
+ }
47
+
48
+ /** The request for an agent started from a GitHub issue. */
49
+ export function issueIntent(issue) {
50
+ const body = String(issue.body ?? '').trim();
51
+ return `${issue.url}\n\n${issue.title}${body ? `\n\n${body.slice(0, 6000)}` : ''}\n\nWhen you are done, open a pull request that says "Fixes #${issue.number}".`;
52
+ }
53
+
54
+ /** A PR comment for the agent, or null when it isn't a command from someone trusted. */
55
+ export function prCommand(comment, prefix = '/agent') {
56
+ const body = String(comment?.body ?? '').trim();
57
+ if (!body.toLowerCase().startsWith(prefix.toLowerCase())) return null;
58
+ if (!TRUSTED.has(comment.authorAssociation)) return null;
59
+ const text = body.slice(prefix.length).trim();
60
+ return text || null;
61
+ }
62
+
63
+ /** Same length and same bytes, without leaking where they differ. */
64
+ export function tokenMatches(given, expected) {
65
+ if (!given || !expected) return false;
66
+ const a = Buffer.from(String(given));
67
+ const b = Buffer.from(String(expected));
68
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
69
+ }
70
+
71
+ export class Triggers {
72
+ constructor({ manager, store, config = {}, notifier = null, log = (m) => console.log(`[agentmgr] ${m}`) }) {
73
+ this.manager = manager;
74
+ this.store = store;
75
+ this.config = config;
76
+ this.notifier = notifier;
77
+ this.log = log;
78
+ store.data.triggers ??= { issues: {}, comments: {}, started: [], lastPoll: null, lastError: null };
79
+ this.schedule();
80
+ }
81
+
82
+ get state() {
83
+ return this.store.data.triggers;
84
+ }
85
+
86
+ get settings() {
87
+ return triggerSettings(this.config);
88
+ }
89
+
90
+ schedule() {
91
+ clearInterval(this.timer);
92
+ this.timer = setInterval(() => this.poll().catch((e) => this.fail(e)), this.settings.github.everyMin * 60_000);
93
+ this.timer.unref?.();
94
+ }
95
+
96
+ close() {
97
+ clearInterval(this.timer);
98
+ }
99
+
100
+ fail(e) {
101
+ this.state.lastError = { at: new Date().toISOString(), message: e.message.split('\n')[0].slice(0, 300) };
102
+ this.log(`triggers: ${this.state.lastError.message}`);
103
+ }
104
+
105
+ /** Settings for the UI: the token is replaced by whether one is set. */
106
+ view() {
107
+ const s = this.settings;
108
+ const today = this.startedToday();
109
+ return {
110
+ ...s,
111
+ webhook: { enabled: s.webhook.enabled, hasToken: Boolean(s.webhook.token), repos: s.webhook.repos },
112
+ startedToday: today.length,
113
+ recent: this.state.started.slice(-20).reverse(),
114
+ lastPoll: this.state.lastPoll,
115
+ lastError: this.state.lastError,
116
+ };
117
+ }
118
+
119
+ update(patch = {}) {
120
+ const cur = this.config.triggers ?? {};
121
+ const next = {
122
+ github: { ...cur.github, ...patch.github },
123
+ prCommands: { ...cur.prCommands, ...patch.prCommands },
124
+ webhook: { ...cur.webhook, ...patch.webhook },
125
+ maxPerDay: patch.maxPerDay ?? cur.maxPerDay,
126
+ };
127
+ if (patch.webhook?.newToken) {
128
+ next.webhook.token = crypto.randomBytes(24).toString('base64url');
129
+ delete next.webhook.newToken;
130
+ }
131
+ if (next.github.enabled && !list(next.github.repos).length && !list(next.github.owners).length) {
132
+ throw err(400, 'List the repos (owner/name) or owners to watch, so issues elsewhere on GitHub never start agents');
133
+ }
134
+ const s = triggerSettings({ triggers: next });
135
+ this.config.triggers = { github: s.github, prCommands: s.prCommands, webhook: { enabled: s.webhook.enabled, token: s.webhook.token, repos: s.webhook.repos }, maxPerDay: s.maxPerDay };
136
+ this.schedule();
137
+ return { saved: this.config.triggers, token: patch.webhook?.newToken ? s.webhook.token : undefined };
138
+ }
139
+
140
+ startedToday() {
141
+ const since = Date.now() - 86_400_000;
142
+ return this.state.started.filter((x) => Date.parse(x.at) > since);
143
+ }
144
+
145
+ /** Create one agent for a trigger, within the daily cap. */
146
+ start({ source, url, intent, repo, name, project, priority }) {
147
+ if (this.startedToday().length >= this.settings.maxPerDay) throw err(429, `Triggers started ${this.settings.maxPerDay} agents in the last day (triggers.maxPerDay)`);
148
+ const [agent] = this.manager.createMany([intent], {
149
+ repo,
150
+ name,
151
+ project,
152
+ priority,
153
+ trigger: { source, url: url ?? null, at: new Date().toISOString() },
154
+ });
155
+ this.state.started = [...this.state.started, { at: new Date().toISOString(), source, url: url ?? null, agent: agent.id, name: agent.name }].slice(-200);
156
+ this.store.save();
157
+ this.notifier?.send?.('assistant', agent, `was started from ${source === 'github' ? 'a GitHub label' : 'a webhook'}${url ? `: ${url}` : ''}`);
158
+ this.log(`triggers: started ${agent.name} from ${source}${url ? ` (${url})` : ''}`);
159
+ return agent;
160
+ }
161
+
162
+ async gh(args) {
163
+ return run(ghBin(), args, { timeout: 60_000 });
164
+ }
165
+
166
+ /** One pass: labeled issues, then commands on agents' pull requests. */
167
+ async poll() {
168
+ if (process.env.AGENTMGR_NO_GH) return { issues: 0, commands: 0 };
169
+ const s = this.settings;
170
+ let issues = 0;
171
+ let commands = 0;
172
+ if (s.github.enabled && (s.github.repos.length || s.github.owners.length)) issues = await this.pollIssues(s.github);
173
+ if (s.prCommands.enabled) commands = await this.pollPrCommands(s.prCommands.prefix);
174
+ this.state.lastPoll = { at: new Date().toISOString(), issues, commands };
175
+ this.state.lastError = null;
176
+ this.store.save();
177
+ return { issues, commands };
178
+ }
179
+
180
+ async pollIssues(g) {
181
+ const scopes = [...g.repos.map((r) => ['--repo', r]), ...g.owners.map((o) => ['--owner', o])];
182
+ let started = 0;
183
+ for (const scope of scopes) {
184
+ const out = await this.gh(['search', 'issues', '--label', g.label, '--state', 'open', ...scope, '--limit', '30', '--json', 'number,title,body,url,repository']);
185
+ for (const issue of JSON.parse(out || '[]')) {
186
+ if (this.state.issues[issue.url]) continue;
187
+ const nwo = issue.repository?.nameWithOwner;
188
+ if (!nwo || !REPO_RE.test(nwo)) continue;
189
+ // Mark first: a failure must not start the same issue again on every pass.
190
+ this.state.issues[issue.url] = { at: new Date().toISOString() };
191
+ try {
192
+ // A project already on this repo reuses its clone and settings.
193
+ const project = this.projectFor(nwo);
194
+ const agent = this.start({ source: 'github', url: issue.url, intent: issueIntent(issue), ...(project ? { project: project.shortcut } : { repo: `https://github.com/${nwo}` }), name: `${issue.repository.name}#${issue.number}` });
195
+ this.state.issues[issue.url].agent = agent.id;
196
+ started += 1;
197
+ if (g.comment) {
198
+ await this.gh(['issue', 'comment', issue.url, '--body', `AI Agent Manager started an agent on this (\`${agent.name}\`). It will open a pull request.`]).catch(() => {});
199
+ }
200
+ } catch (e) {
201
+ this.state.issues[issue.url].error = e.message;
202
+ if (e.status === 429) break;
203
+ }
204
+ }
205
+ }
206
+ return started;
207
+ }
208
+
209
+ projectFor(nwo) {
210
+ const re = new RegExp(`github\\.com[:/]${nwo.replace(/[.]/g, '\\.')}(\\.git)?/?$`, 'i');
211
+ return Object.values(this.store.data.projects ?? {}).find((p) => p.repo && re.test(p.repo) && p.shortcut) ?? null;
212
+ }
213
+
214
+ async pollPrCommands(prefix) {
215
+ let sent = 0;
216
+ for (const agent of this.store.list()) {
217
+ for (const pr of Object.values(agent.prs ?? {})) {
218
+ if (pr.state !== 'OPEN' || pr.source === 'mentioned') continue;
219
+ const out = await this.gh(['pr', 'view', pr.url, '--json', 'comments']).catch(() => null);
220
+ if (!out) continue;
221
+ const comments = JSON.parse(out).comments ?? [];
222
+ const seen = (this.state.comments[pr.url] ??= { ids: [], since: new Date().toISOString() });
223
+ for (const c of comments) {
224
+ const id = c.id ?? c.url ?? `${c.author?.login}:${c.createdAt}`;
225
+ if (seen.ids.includes(id)) continue;
226
+ seen.ids = [...seen.ids, id].slice(-200);
227
+ // Comments from before we started watching this PR are history, not commands.
228
+ if (Date.parse(c.createdAt) < Date.parse(seen.since)) continue;
229
+ const text = prCommand(c, prefix);
230
+ if (!text) continue;
231
+ try {
232
+ this.manager.tell(agent.id, `${c.author?.login ?? 'Someone'} asked on your pull request ${pr.url}:\n${text}\n\nDo it, push, and reply on the pull request when done.`, { source: 'github' });
233
+ sent += 1;
234
+ } catch (e) {
235
+ this.log(`triggers: couldn't pass a comment to ${agent.name}: ${e.message}`);
236
+ }
237
+ }
238
+ }
239
+ }
240
+ return sent;
241
+ }
242
+
243
+ /** The webhook: { intent, repo?, project?, name?, priority? } with the token. */
244
+ hook(body = {}) {
245
+ const s = this.settings;
246
+ if (!s.webhook.enabled) throw err(404, 'Not found');
247
+ const intent = String(body.intent ?? '').trim();
248
+ if (!intent) throw err(400, 'intent is required');
249
+ const repo = body.repo ? String(body.repo).trim() : undefined;
250
+ const project = body.project ? String(body.project).trim() : undefined;
251
+ if (!repo && !project) throw err(400, 'repo or project is required');
252
+ if (s.webhook.repos.length && repo && !s.webhook.repos.some((r) => repo === r || repo.endsWith(`/${r}`) || repo.endsWith(`/${r}.git`))) {
253
+ throw err(403, 'That repo is not in triggers.webhook.repos');
254
+ }
255
+ const agent = this.start({ source: 'webhook', url: body.url ? String(body.url) : null, intent, repo, project, name: body.name ? String(body.name) : undefined, priority: body.priority });
256
+ return { id: agent.id, name: agent.name };
257
+ }
258
+ }