ai-agentmanager 1.13.0 → 1.15.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/src/rules.js ADDED
@@ -0,0 +1,161 @@
1
+ // Approval rules learned from your answers: when you keep saying yes to the same command in a repo
2
+ // ("npm test", three times), the inbox offers to always allow it there. Allowed rules go to every
3
+ // agent in that repo at launch (Claude Code's --allowedTools) and answer its live prompts, so the
4
+ // same question isn't asked again. Kept in ~/.agentmanager/rules.json, per repo (the repo-memory key).
5
+ import { EventEmitter } from 'node:events';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { paths } from './config.js';
9
+
10
+ export const SUGGEST_AFTER = 3;
11
+ // Commands whose first words name a subcommand: "npm test" is a rule, "npm" alone would allow anything npm does.
12
+ const SUBCOMMANDS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'npx', 'node', 'deno', 'git', 'gh', 'cargo', 'go', 'make', 'python', 'python3', 'uv', 'pip', 'poetry', 'pytest', 'mvn', 'gradle', './gradlew', 'dotnet', 'swift', 'xcodebuild', 'bundle', 'rake', 'rails', 'mix', 'composer', 'php', 'docker', 'kubectl', 'terraform', 'just', 'task']);
13
+ // Never offered as a standing rule, however often approved: they delete, escalate, publish or reach out.
14
+ const NEVER = /^(rm|sudo|su|chmod|chown|dd|mkfs|kill|killall|pkill|shutdown|reboot|curl|wget|ssh|scp|rsync|eval|exec|sh|bash|zsh|env|xargs|find|git (push|reset|clean|rebase|checkout|branch|tag)|gh (pr merge|release|repo|api|secret)|npm (publish|unpublish|login|adduser|token)|docker (rm|rmi|system|push|login)|kubectl (delete|apply)|terraform (apply|destroy))$/;
15
+ // Anything that chains, redirects or substitutes isn't one command, so no rule covers it.
16
+ const COMPOUND = /[;&|<>`$(){}\n\\]/;
17
+
18
+ /** The words of a plain command, or null when it chains, redirects, substitutes or sets variables. */
19
+ function words(command) {
20
+ const cmd = String(command ?? '').trim();
21
+ if (!cmd || COMPOUND.test(cmd)) return null;
22
+ const w = cmd.split(/\s+/);
23
+ return /^\w+=/.test(w[0]) ? null : w;
24
+ }
25
+
26
+ /**
27
+ * The rule that would allow a tool call, as Claude Code writes it, with a label for people:
28
+ * Bash → "Bash(npm test:*)", WebFetch → "WebFetch(domain:docs.x.com)", an MCP tool → its name.
29
+ * Null for calls that shouldn't become a standing rule (file edits: use Accept edits instead).
30
+ */
31
+ export function ruleFor(tool) {
32
+ const input = tool?.input ?? {};
33
+ if (tool?.name === 'Bash') {
34
+ const w = words(input.command);
35
+ if (!w) return null;
36
+ const prefix = (SUBCOMMANDS.has(w[0]) && w[1] && !w[1].startsWith('-') ? w.slice(0, 2) : w.slice(0, 1)).join(' ');
37
+ if (NEVER.test(prefix) || NEVER.test(w[0])) return null;
38
+ return { rule: `Bash(${prefix}:*)`, label: prefix };
39
+ }
40
+ if (tool?.name === 'WebFetch') {
41
+ try {
42
+ const host = new URL(input.url).hostname;
43
+ return host ? { rule: `WebFetch(domain:${host})`, label: `fetch from ${host}` } : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+ if (/^mcp__[\w-]+__[\w-]+$/.test(tool?.name ?? '')) return { rule: tool.name, label: tool.name.replace(/^mcp__/, '').replace('__', ': ') };
49
+ return null;
50
+ }
51
+
52
+ /** Whether an allowed rule covers this tool call. */
53
+ export function covers(rule, tool) {
54
+ const m = /^Bash\((.+):\*\)$/.exec(rule);
55
+ if (m) {
56
+ const w = words(tool?.input?.command);
57
+ return tool?.name === 'Bash' && Boolean(w) && (w.join(' ') === m[1] || w.join(' ').startsWith(`${m[1]} `));
58
+ }
59
+ return ruleFor(tool)?.rule === rule;
60
+ }
61
+
62
+ /** What a pending tool call is, in a few words for an inbox card. */
63
+ export function describeTool(tool) {
64
+ const input = tool?.input ?? {};
65
+ if (tool?.name === 'Bash') return { name: 'Bash', text: String(input.command ?? '').slice(0, 300) };
66
+ if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Read'].includes(tool?.name)) return { name: tool.name, text: String(input.file_path ?? input.notebook_path ?? '') };
67
+ if (tool?.name === 'WebFetch') return { name: 'WebFetch', text: String(input.url ?? '') };
68
+ return { name: String(tool?.name ?? ''), text: JSON.stringify(input).slice(0, 300) };
69
+ }
70
+
71
+ export class Rules extends EventEmitter {
72
+ constructor({ file = path.join(paths.home, 'rules.json'), suggestAfter = SUGGEST_AFTER } = {}) {
73
+ super();
74
+ this.file = file;
75
+ this.suggestAfter = suggestAfter;
76
+ try {
77
+ this.data = JSON.parse(fs.readFileSync(file, 'utf8'));
78
+ } catch {
79
+ this.data = {};
80
+ }
81
+ this.data.repos ??= {};
82
+ }
83
+
84
+ save() {
85
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
86
+ fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
87
+ this.emit('change', this.view());
88
+ }
89
+
90
+ repo(key) {
91
+ return (this.data.repos[key] ??= { allow: [], dismissed: [], seen: {} });
92
+ }
93
+
94
+ /** The rules allowed in a repo, as Claude Code allow rules. */
95
+ allowed(key) {
96
+ return (this.data.repos[key]?.allow ?? []).map((r) => r.rule);
97
+ }
98
+
99
+ /** The allowed rule that covers this call in this repo, if any. */
100
+ allows(key, tool) {
101
+ return this.allowed(key).find((r) => covers(r, tool)) ?? null;
102
+ }
103
+
104
+ /** An allowed rule answered a prompt by itself: count it, so the list shows what it saves. */
105
+ used(key, rule) {
106
+ const a = this.data.repos[key]?.allow.find((x) => x.rule === rule);
107
+ if (!a) return;
108
+ a.uses = (a.uses ?? 0) + 1;
109
+ a.lastUsedAt = new Date().toISOString();
110
+ this.save();
111
+ }
112
+
113
+ /** You said yes to this call in this repo; count it toward a suggestion. */
114
+ approved(key, tool) {
115
+ const r = ruleFor(tool);
116
+ if (!r) return null;
117
+ const repo = this.repo(key);
118
+ if (repo.allow.some((x) => x.rule === r.rule)) return null;
119
+ const seen = (repo.seen[r.rule] ??= { label: r.label, n: 0 });
120
+ seen.n++;
121
+ seen.at = new Date().toISOString();
122
+ this.save();
123
+ return { ...r, n: seen.n };
124
+ }
125
+
126
+ accept(key, rule) {
127
+ const repo = this.data.repos[key];
128
+ const seen = repo?.seen[rule];
129
+ if (!seen && !repo?.allow.some((x) => x.rule === rule)) throw Object.assign(new Error('No such suggestion'), { status: 404 });
130
+ if (!repo.allow.some((x) => x.rule === rule)) repo.allow.push({ rule, label: seen.label, at: new Date().toISOString() });
131
+ delete repo.seen[rule];
132
+ this.save();
133
+ }
134
+
135
+ /** "Not now": stop suggesting it (it can still be allowed later from the list). */
136
+ dismiss(key, rule) {
137
+ const repo = this.data.repos[key];
138
+ if (!repo?.seen[rule]) throw Object.assign(new Error('No such suggestion'), { status: 404 });
139
+ if (!repo.dismissed.includes(rule)) repo.dismissed.push(rule);
140
+ this.save();
141
+ }
142
+
143
+ remove(key, rule) {
144
+ const repo = this.data.repos[key];
145
+ if (!repo?.allow.some((x) => x.rule === rule)) throw Object.assign(new Error('No such rule'), { status: 404 });
146
+ repo.allow = repo.allow.filter((x) => x.rule !== rule);
147
+ repo.dismissed = repo.dismissed.filter((x) => x !== rule);
148
+ this.save();
149
+ }
150
+
151
+ /** Every repo's allowed rules and the suggestions waiting for an answer. */
152
+ view() {
153
+ const allowed = [];
154
+ const suggestions = [];
155
+ for (const [repo, r] of Object.entries(this.data.repos)) {
156
+ for (const a of r.allow) allowed.push({ repo, ...a });
157
+ for (const [rule, s] of Object.entries(r.seen)) if (s.n >= this.suggestAfter && !r.dismissed.includes(rule)) suggestions.push({ repo, rule, label: s.label, n: s.n, at: s.at });
158
+ }
159
+ return { allowed, suggestions, suggestAfter: this.suggestAfter };
160
+ }
161
+ }
package/src/server.js CHANGED
@@ -9,7 +9,7 @@ import { spawn } from 'node:child_process';
9
9
  import express from 'express';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.js';
12
- import { AgentManager, MACHINE, shellNumber } from './manager.js';
12
+ import { AgentManager, MACHINE, projectOf, shellNumber } from './manager.js';
13
13
  import { Backlog } from './backlog.js';
14
14
  import { Races } from './races.js';
15
15
  import { Review } from './review.js';
@@ -17,9 +17,11 @@ import { Teams } from './teams.js';
17
17
  import { Assistant } from './assistant.js';
18
18
  import { Cleanup } from './cleanup.js';
19
19
  import { Landing } from './landing.js';
20
+ import { Autopilot } from './autopilot.js';
20
21
  import { Budgets } from './budgets.js';
21
22
  import { stats } from './stats.js';
22
23
  import { Playbooks } from './playbooks.js';
24
+ import { Repeats } from './repeats.js';
23
25
  import { Updates } from './update.js';
24
26
  import { identity } from './identity.js';
25
27
  import { Studio } from './studio.js';
@@ -90,6 +92,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
90
92
  const teams = new Teams({ manager, store, config });
91
93
  const cleanup = new Cleanup({ manager, store, config, teams });
92
94
  const landing = new Landing({ manager, store, config });
95
+ // Backlog work the scorecard calls ready goes on to a pull request (and merged) by itself.
96
+ const autopilot = new Autopilot({ manager, store, backlog, review, landing, config });
97
+ manager.inboxSources.push(() => autopilot.inbox());
93
98
  const assistant = new Assistant({ manager, store, config });
94
99
  // Alerts link to the phone address when remote access is on, else the local UI.
95
100
  const notifier = new Notifier({
@@ -104,6 +109,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
104
109
  const triggers = new Triggers({ manager, store, config, notifier });
105
110
  const budgets = new Budgets({ manager, store, config, notifier });
106
111
  const playbooks = new Playbooks({ manager, store });
112
+ // Requests you keep typing, offered as a playbook.
113
+ const repeats = new Repeats({
114
+ suggestAfter: config.repeats?.suggestAfter,
115
+ enabled: config.repeats?.enabled !== false,
116
+ playbooks: () => playbooks.list(),
117
+ projects: () => Object.fromEntries(Object.values(store.data.projects ?? {}).map((p) => [p.key, p.shortcut || p.name])),
118
+ });
119
+ manager.inboxSources.push(() => repeats.inbox());
107
120
  const memory = new Memory({ manager, config });
108
121
  const updates = new Updates({ config });
109
122
  let remoteServer = null;
@@ -230,6 +243,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
230
243
  // Agents the assistant's own session starts: its limits apply, and they are marked as its.
231
244
  const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
232
245
  const created = manager.createMany(list, { ...opts, ...extra });
246
+ if (!extra.startedBy) repeats.record(created.map((a) => ({ text: a.intent, projectKey: projectOf(store.get(a.id) ?? a).key })));
233
247
  if (extra.startedBy) {
234
248
  for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
235
249
  }
@@ -443,11 +457,23 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
443
457
  '/backlog',
444
458
  wrap((req) => {
445
459
  const { items, item, ...opts } = req.body ?? {};
446
- return backlog.add(Array.isArray(items) ? items : [item], opts);
460
+ const added = backlog.add(Array.isArray(items) ? items : [item], opts);
461
+ repeats.record(added.map((it) => ({ text: it.title, projectKey: it.projectKey })));
462
+ return added;
447
463
  }),
448
464
  );
449
465
  // Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
450
466
  api.get('/inbox', wrap(() => manager.inbox()));
467
+ // Approval rules learned from your answers (src/rules.js): allowed per repo, and suggestions.
468
+ const ruleArgs = (req) => {
469
+ const { repo, rule } = req.body ?? {};
470
+ if (typeof repo !== 'string' || typeof rule !== 'string') throw Object.assign(new Error('repo and rule are required'), { status: 400 });
471
+ return [repo, rule];
472
+ };
473
+ api.get('/rules', wrap(() => manager.rules.view()));
474
+ api.post('/rules/accept', wrap((req) => (manager.rules.accept(...ruleArgs(req)), manager.applyRules(), manager.rules.view())));
475
+ api.post('/rules/dismiss', wrap((req) => (manager.rules.dismiss(...ruleArgs(req)), manager.rules.view())));
476
+ api.post('/rules/remove', wrap((req) => (manager.rules.remove(...ruleArgs(req)), manager.rules.view())));
451
477
  api.post('/agents/:id/tell', wrap((req) => manager.tell(req.params.id, req.body?.text)));
452
478
  api.post('/agents/:id/answer', wrap((req) => manager.answerQuestion(req.params.id, req.body?.question, req.body?.text)));
453
479
  api.post('/agents/:id/prompt-key', wrap((req) => manager.answerPrompt(req.params.id, String(req.body?.key ?? ''))));
@@ -648,9 +674,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
648
674
  };
649
675
  }));
650
676
  api.get('/playbooks', wrap(() => playbooks.list()));
651
- api.post('/playbooks', wrap((req) => playbooks.create(req.body ?? {})));
652
- api.put('/playbooks/:id', wrap((req) => playbooks.update(req.params.id, req.body ?? {})));
653
- api.delete('/playbooks/:id', wrap((req) => playbooks.remove(req.params.id)));
677
+ // Saving a playbook can answer a repeated-request suggestion.
678
+ const withRepeats = (fn) => (req) => {
679
+ const out = fn(req);
680
+ repeats.changed();
681
+ return out;
682
+ };
683
+ api.post('/playbooks', wrap(withRepeats((req) => playbooks.create(req.body ?? {}))));
684
+ api.put('/playbooks/:id', wrap(withRepeats((req) => playbooks.update(req.params.id, req.body ?? {}))));
685
+ api.delete('/playbooks/:id', wrap(withRepeats((req) => playbooks.remove(req.params.id))));
686
+ api.get('/repeats', wrap(() => repeats.view()));
687
+ api.post('/repeats/dismiss', wrap((req) => repeats.dismiss(String(req.body?.key ?? ''))));
654
688
  api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
655
689
  api.get('/stats', wrap((req) => {
656
690
  const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
@@ -703,6 +737,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
703
737
  api.post('/backlog/:id/move', wrap((req) => backlog.move(req.params.id, req.body?.where)));
704
738
  api.post('/backlog/:id/split', wrap((req) => backlog.split(req.params.id, req.body?.parts)));
705
739
  api.post('/backlog/:id/keep-whole', wrap((req) => backlog.keepWhole(req.params.id)));
740
+ api.post('/backlog/:id/ship', wrap((req) => autopilot.shipNow(req.params.id)));
706
741
  api.post(
707
742
  '/shutdown',
708
743
  wrap(() => {
@@ -912,6 +947,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
912
947
  };
913
948
  manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
914
949
  reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
950
+ manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
951
+ repeats.on('change', (view) => broadcast({ t: 'repeats', repeats: view }));
915
952
  manager.on('removed', (id) => {
916
953
  broadcast({ t: 'removed', id });
917
954
  live.closeAgent(id).catch(() => {});
@@ -948,7 +985,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
948
985
  const url = new URL(req.url, 'http://x');
949
986
  if (url.pathname === '/ws/events') {
950
987
  eventClients.add(ws);
951
- ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list() }));
988
+ ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view(), repeats: repeats.view() }));
952
989
  ws.on('close', () => eventClients.delete(ws));
953
990
  return;
954
991
  }
@@ -1029,6 +1066,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
1029
1066
  closing = true;
1030
1067
  cleanup.close();
1031
1068
  landing.close();
1069
+ autopilot.close();
1032
1070
  triggers.close();
1033
1071
  budgets.close();
1034
1072
  playbooks.close();
package/src/stuck.js CHANGED
@@ -20,12 +20,14 @@ const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((b)
20
20
 
21
21
  /**
22
22
  * Incrementally read new transcript lines into `state.events` (tool calls with their results).
23
- * state: { offset, events: [{ t, kind: 'cmd'|'edit'|'other', ... }], pending: Map(id -> event) }.
23
+ * state: { offset, events: [{ t, kind: 'cmd'|'edit'|'other', ... }], pending: Map(id -> event),
24
+ * calls: Map(id -> { name, input }) for the tool calls still waiting for a result (what a prompt asks about) }.
24
25
  */
25
26
  export function scanActivity(file, state) {
26
27
  state.offset ??= 0;
27
28
  state.events ??= [];
28
29
  state.pending ??= new Map();
30
+ state.calls ??= new Map();
29
31
  let fd;
30
32
  try {
31
33
  fd = fs.openSync(file, 'r');
@@ -34,7 +36,7 @@ export function scanActivity(file, state) {
34
36
  }
35
37
  try {
36
38
  const { size } = fs.fstatSync(fd);
37
- if (size < state.offset) Object.assign(state, { offset: 0, events: [], pending: new Map() });
39
+ if (size < state.offset) Object.assign(state, { offset: 0, events: [], pending: new Map(), calls: new Map() });
38
40
  if (size === state.offset) return state;
39
41
  const buf = Buffer.alloc(size - state.offset);
40
42
  fs.readSync(fd, buf, 0, buf.length, state.offset);
@@ -56,7 +58,12 @@ export function scanActivity(file, state) {
56
58
  const ev = eventFor(b, t);
57
59
  state.events.push(ev);
58
60
  state.pending.set(b.id, ev);
61
+ // Calls left without a result by an earlier response (interrupted) aren't what's asked now.
62
+ if (rec.message?.id && rec.message.id !== state.callsMsg) state.calls.clear();
63
+ state.callsMsg = rec.message?.id;
64
+ state.calls.set(b.id, { name: b.name, input: b.input ?? {} });
59
65
  } else if (rec.type === 'user' && b.type === 'tool_result') {
66
+ state.calls.delete(b.tool_use_id);
60
67
  const ev = state.pending.get(b.tool_use_id);
61
68
  if (!ev) continue;
62
69
  state.pending.delete(b.tool_use_id);