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/backlog.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import crypto from 'node:crypto';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { parsePriority, projectOf } from './manager.js';
6
+ import { AUTOPILOT_LEVELS } from './autopilot.js';
6
7
  import { dispatchPrompt, hash } from './judge.js';
7
8
 
8
9
  const now = () => new Date().toISOString();
@@ -19,6 +20,7 @@ export class Backlog extends EventEmitter {
19
20
  this.config = config;
20
21
  store.data.backlog ??= { items: {}, limits: {}, autoRun: true };
21
22
  this.data = store.data.backlog;
23
+ this.data.autopilot ??= {};
22
24
  this.dispatching = false;
23
25
  this.plan = null; // the AI judge's dispatch plan: { hash, data, pending }
24
26
 
@@ -38,6 +40,12 @@ export class Backlog extends EventEmitter {
38
40
  return this.data.limits[projectKey] ?? this.defaultLimit();
39
41
  }
40
42
 
43
+ /** Autopilot for a project's items (see autopilot.js): "off", "pr" or "merge". */
44
+ autopilotFor(projectKey) {
45
+ const level = this.data.autopilot[projectKey] ?? this.config.autopilot?.level;
46
+ return AUTOPILOT_LEVELS.includes(level) ? level : 'off';
47
+ }
48
+
41
49
  items() {
42
50
  return Object.values(this.data.items).sort(
43
51
  (a, b) => a.priority - b.priority || a.order - b.order || a.createdAt.localeCompare(b.createdAt),
@@ -63,7 +71,7 @@ export class Backlog extends EventEmitter {
63
71
  const projects = {};
64
72
  for (const it of items) {
65
73
  const shortcut = Object.values(this.store.data.projects ?? {}).find((x) => x.key === it.projectKey)?.shortcut ?? null;
66
- const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), counts: {} });
74
+ const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), autopilot: this.autopilotFor(it.projectKey), counts: {} });
67
75
  p.counts[it.status] = (p.counts[it.status] ?? 0) + 1;
68
76
  }
69
77
  return {
@@ -180,14 +188,18 @@ export class Backlog extends EventEmitter {
180
188
  return { id };
181
189
  }
182
190
 
183
- settings({ autoRun, limits, project, limit } = {}) {
191
+ settings({ autoRun, limits, project, limit, autopilot } = {}) {
184
192
  if (typeof autoRun === 'boolean') this.data.autoRun = autoRun;
185
193
  if (limits && typeof limits === 'object') Object.assign(this.data.limits, limits);
186
- if (project !== undefined && limit !== undefined) {
187
- const key =
188
- this.manager.resolveProject(project)?.key ??
189
- this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ??
190
- project;
194
+ const key =
195
+ project === undefined
196
+ ? null
197
+ : this.manager.resolveProject(project)?.key ?? this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ?? project;
198
+ if (key && autopilot !== undefined) {
199
+ if (!AUTOPILOT_LEVELS.includes(autopilot)) throw err(400, `autopilot must be one of ${AUTOPILOT_LEVELS.join(', ')}`);
200
+ this.data.autopilot[key] = autopilot;
201
+ }
202
+ if (key && limit !== undefined) {
191
203
  const n = Number(limit);
192
204
  if (!(n >= 0 && n <= 20)) throw err(400, 'limit must be 0-20');
193
205
  this.data.limits[key] = n;
package/src/builder.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // downloads it here. Built agents live in ~/.agentmanager/built/<name>/ as ordinary agentkit folders.
5
5
  import { spawn } from 'node:child_process';
6
6
  import fs from 'node:fs';
7
+ import os from 'node:os';
7
8
  import path from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import YAML from 'yaml';
@@ -127,6 +128,19 @@ export function filesWritten(dir, before, { root = dir, limit = 6, bytes = 4000
127
128
  return out;
128
129
  }
129
130
 
131
+ /**
132
+ * A throwaway copy of the agent's folder for one test run, so what a run writes (outputs, memory,
133
+ * changed fixtures) doesn't change the agent or the next test. It keeps the agent's memory, not its
134
+ * engine sessions, run logs, executables, dependencies or git data.
135
+ */
136
+ export function testCopy(dir) {
137
+ const copy = fs.mkdtempSync(path.join(os.tmpdir(), `agentkit-test-${path.basename(dir)}-`));
138
+ const state = path.join(dir, '.agentkit');
139
+ const keep = (src) => src === dir || (path.dirname(src) === state ? path.basename(src) === 'memory.md' : src === state || !SKIP.has(path.basename(src)));
140
+ fs.cpSync(dir, copy, { recursive: true, filter: keep });
141
+ return copy;
142
+ }
143
+
130
144
  export class Builder {
131
145
  /** `oneShot(prompt)` answers a judge prompt (the daemon's writer engine). */
132
146
  /** `sessions`: the daemon's agent sessions ({ create, get, tell, stop }); each built agent is developed in one. */
@@ -324,25 +338,32 @@ export class Builder {
324
338
  this.pending.set(name, 'testing');
325
339
  this.saveMeta(name, { status: 'testing' });
326
340
  try {
327
- // Tests don't wait for people: approval tools are simply not available in them.
328
- const agent = new Agent({ ...spec, triggers: [], report: null }, { env: this.env, engineTurn: this.runTurn, approvals: 'deny' });
341
+ // Each test runs in its own copy of the agent's folder (a workdir outside it is used in place),
342
+ // and the judge sees what the run wrote there, not only what it says it did. Tests don't wait
343
+ // for people: approval tools are simply not available in them.
329
344
  const results = [];
330
- // The judge sees what each run wrote in the agent's folders, not only what it says it did.
331
- const roots = [...new Set([dir, spec.workdir])].filter((d, i, all) => !all.some((o, j) => j !== i && d.startsWith(o + path.sep)));
332
345
  for (const t of tests) {
333
- const before = roots.map((d) => snapshot(d));
334
- const r = await agent.run(t.input, { source: 'test' });
335
- const files = roots.flatMap((d, i) => filesWritten(d, before[i], { root: dir })).slice(0, 6);
336
- let verdict = { pass: false, why: 'The run failed' };
337
- if (r.status !== 'failed') {
338
- try {
339
- const v = parseJsonReply(await this.oneShot(judgePrompt({ goal: spec.goal, input: t.input, expect: t.expect, result: r.result, files })));
340
- verdict = { pass: v.pass === true, why: String(v.why ?? '') };
341
- } catch (e) {
342
- verdict = { pass: false, why: `Could not judge: ${e.message}` };
346
+ const copy = testCopy(dir);
347
+ try {
348
+ const run = loadAgent(copy, { env: this.env });
349
+ const agent = new Agent({ ...run, triggers: [], report: null }, { env: this.env, engineTurn: this.runTurn, approvals: 'deny' });
350
+ const roots = [...new Set([copy, run.workdir])].filter((d, i, all) => !all.some((o, j) => j !== i && d.startsWith(o + path.sep)));
351
+ const before = roots.map((d) => snapshot(d));
352
+ const r = await agent.run(t.input, { source: 'test' });
353
+ const files = roots.flatMap((d, i) => filesWritten(d, before[i], { root: copy })).slice(0, 6);
354
+ let verdict = { pass: false, why: 'The run failed' };
355
+ if (r.status !== 'failed') {
356
+ try {
357
+ const v = parseJsonReply(await this.oneShot(judgePrompt({ goal: spec.goal, input: t.input, expect: t.expect, result: r.result, files })));
358
+ verdict = { pass: v.pass === true, why: String(v.why ?? '') };
359
+ } catch (e) {
360
+ verdict = { pass: false, why: `Could not judge: ${e.message}` };
361
+ }
343
362
  }
363
+ results.push({ input: t.input, expect: t.expect, result: r.result, status: r.status, costUsd: r.costUsd, files: files.map((f) => f.path), ...verdict });
364
+ } finally {
365
+ fs.rmSync(copy, { recursive: true, force: true });
344
366
  }
345
- results.push({ input: t.input, expect: t.expect, result: r.result, status: r.status, costUsd: r.costUsd, files: files.map((f) => f.path), ...verdict });
346
367
  }
347
368
  const passed = results.filter((r) => r.pass).length;
348
369
  this.saveMeta(name, { results: { at: now(), passed, total: results.length, list: results }, status: passed === results.length ? 'tested' : 'failing' });
package/src/cli.js CHANGED
@@ -879,6 +879,8 @@ export async function main(argv) {
879
879
  await attach(ref, { kind: `shell:${n}` });
880
880
  });
881
881
 
882
+ const repoLabel = (k) => String(k).replace(/^dir:/, '').split('/').filter(Boolean).slice(-2).join('/');
883
+
882
884
  program
883
885
  .command('inbox')
884
886
  .description('Everything waiting on you: prompts, questions, stuck agents, overlapping changes')
@@ -888,6 +890,21 @@ export async function main(argv) {
888
890
  if (!items.length) return console.log(dim('Nothing needs you right now.'));
889
891
  const label = { prompt: red('needs input'), waiting: red('waiting'), question: cyan('question'), review: cyan('AI review'), stuck: yellow('stuck?'), conflict: yellow('overlap') };
890
892
  for (const it of items) {
893
+ if (it.kind === 'repeat') {
894
+ console.log(`${cyan('make it a playbook?')} asked ${it.count} times: ${it.intent}`);
895
+ console.log(dim(` agentmgr playbook add "${it.name}" "${it.intent.replace(/"/g, '\\"')}" --projects ${it.projects.join(',') || '<project>'}`));
896
+ continue;
897
+ }
898
+ if (it.kind === 'autopilot') {
899
+ console.log(`${yellow('autopilot held')} ${bold(it.project ?? '')} ${it.title}`);
900
+ console.log(dim(` ${it.note ?? ''}\n agentmgr backlog ship ${it.itemId} · agentmgr changes ${it.agentId}`));
901
+ continue;
902
+ }
903
+ if (it.kind === 'rule') {
904
+ console.log(`${cyan('always allow?')} ${bold(repoLabel(it.repo))} ${it.title} ${dim(`(allowed ${it.n} times)`)}`);
905
+ console.log(dim(' agentmgr rules'));
906
+ continue;
907
+ }
891
908
  console.log(`${label[it.kind]} ${bold(it.agentName)} ${dim(it.agentId)} ${it.title}`);
892
909
  if (it.kind === 'prompt') console.log(dim(` ${(it.prompt?.choices ?? []).map((c) => `${c.key}. ${c.label}`).join(' ')} → agentmgr attach ${it.agentId}`));
893
910
  if (it.kind === 'question') {
@@ -901,6 +918,32 @@ export async function main(argv) {
901
918
  }
902
919
  });
903
920
 
921
+ program
922
+ .command('rules')
923
+ .description('Approval rules: commands always allowed per repo, and the ones the inbox suggests')
924
+ .argument('[action]', 'allow <n> | no <n> (suggestions), rm <n> (allowed)')
925
+ .argument('[n]')
926
+ .action(async (action, n) => {
927
+ await daemon();
928
+ let r = await api('GET', '/rules');
929
+ const pick = (list) => {
930
+ const x = list[Number(n) - 1];
931
+ if (!x) throw new Error(`No rule ${n ?? ''}; see agentmgr rules`);
932
+ return { repo: x.repo, rule: x.rule };
933
+ };
934
+ if (action === 'allow') r = await api('POST', '/rules/accept', pick(r.suggestions));
935
+ else if (action === 'no') r = await api('POST', '/rules/dismiss', pick(r.suggestions));
936
+ else if (action === 'rm') r = await api('POST', '/rules/remove', pick(r.allowed));
937
+ else if (action) throw new Error('Use: agentmgr rules [allow <n> | no <n> | rm <n>]');
938
+ if (r.suggestions.length) {
939
+ console.log(bold('Suggested') + dim(` (you allowed these ${r.suggestAfter}+ times; agentmgr rules allow <n> | no <n>)`));
940
+ r.suggestions.forEach((x, i) => console.log(` ${i + 1}. ${x.label} ${dim(`in ${repoLabel(x.repo)}, allowed ${x.n} times`)}`));
941
+ }
942
+ console.log(bold('Allowed') + dim(' (agents in the repo run these without asking; agentmgr rules rm <n>)'));
943
+ if (!r.allowed.length) console.log(dim(' none yet'));
944
+ r.allowed.forEach((x, i) => console.log(` ${i + 1}. ${x.label} ${dim(`in ${repoLabel(x.repo)}${x.uses ? `, answered ${x.uses} prompts` : ''}`)}`));
945
+ });
946
+
904
947
  program
905
948
  .command('answer')
906
949
  .description('Answer a question an agent asked in the inbox')
@@ -1211,15 +1254,18 @@ export async function main(argv) {
1211
1254
  const items = v.items.filter((i) => all || !['done', 'cancelled'].includes(i.status));
1212
1255
  console.log(`${v.autoRun ? green('running') : yellow('paused')} ${dim(`${v.defaultLimit} per project by default, ${v.maxActive} overall`)}`);
1213
1256
  if (!items.length) return console.log(dim('Backlog is empty. Add: agentmgr backlog add "ask" -r <repo>'));
1214
- const lim = Object.fromEntries(v.projects.map((p) => [p.key, p.limit]));
1257
+ const proj = Object.fromEntries(v.projects.map((p) => [p.key, p]));
1215
1258
  let last = null;
1216
1259
  for (const it of items) {
1217
1260
  if (it.projectKey !== last) {
1218
- console.log(`\n${bold(it.projectName)} ${dim(`(${lim[it.projectKey] ?? v.defaultLimit} at a time)`)}`);
1261
+ const p = proj[it.projectKey];
1262
+ const ap = p?.autopilot && p.autopilot !== 'off' ? `, autopilot: ${p.autopilot === 'merge' ? 'merge' : 'open PRs'}` : '';
1263
+ console.log(`\n${bold(it.projectName)} ${dim(`(${p?.limit ?? v.defaultLimit} at a time${ap})`)}`);
1219
1264
  last = it.projectKey;
1220
1265
  }
1221
1266
  const agent = it.agent ? dim(` → ${it.agent.name} (${it.agent.id})${it.agent.briefStatus ? ` brief: ${it.agent.briefStatus}` : ''}`) : '';
1222
1267
  console.log(` ${it.id} P${it.priority} ${(ST[it.status] ?? it.status).padEnd(18)} ${truncate(it.title, 60)}${agent}`);
1268
+ if (it.status === 'review' && it.autopilot?.state) console.log(dim(` autopilot: ${it.autopilot.state}${it.autopilot.pr ? ` ${it.autopilot.pr}` : ''}${it.autopilot.note ? `: ${it.autopilot.note}` : ''}`));
1223
1269
  }
1224
1270
  };
1225
1271
  bl.command('ls')
@@ -1307,6 +1353,15 @@ export async function main(argv) {
1307
1353
  await daemon();
1308
1354
  printBacklog(await api('POST', '/backlog/settings', { project, limit: Number(n) }));
1309
1355
  });
1356
+ bl.command('autopilot')
1357
+ .description("Take a project's finished items on by themselves when the review scorecard says ready: off, pr (open the pull request) or merge (and merge it once checks pass)")
1358
+ .argument('<project>', 'project name as shown in "backlog ls"')
1359
+ .argument('<level>', 'off, pr or merge')
1360
+ .action(async (project, level) => {
1361
+ await daemon();
1362
+ printBacklog(await api('POST', '/backlog/settings', { project, autopilot: level }));
1363
+ });
1364
+ itemCmd('ship', 'Go ahead with an item autopilot held: open its pull request (and merge, at the merge level)', 'ship');
1310
1365
  bl.command('pause')
1311
1366
  .description('Stop starting new items (active ones continue)')
1312
1367
  .action(async () => {
package/src/manager.js CHANGED
@@ -112,6 +112,7 @@ import { Plugins } from './plugins.js';
112
112
  import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
113
113
  import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
114
114
  import { learnCommand, memoryKey, memoryPrompt, memoryText } from './memory.js';
115
+ import { Rules, describeTool } from './rules.js';
115
116
  import { runningClaude, scanClaudeSessions } from './sessions.js';
116
117
  import { builderAllowedTools, builderPrompt } from './builder.js';
117
118
  import {
@@ -149,6 +150,8 @@ const ANSI = /\x1b(\[[0-9;?<>=]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[()][0-
149
150
  // When a link is found from several sources, the higher-ranked source is kept.
150
151
  const SOURCE_RANK = { manual: 5, intent: 4, session: 3, branch: 2, mentioned: 1 };
151
152
  const BUSY_IDLE_MS = 3000;
153
+ // The attention label engines give a tool permission menu (what rules can answer).
154
+ const PERMISSION = 'Permission prompt';
152
155
  /** Looser stuck thresholds: candidates the judge looks at before anyone is bothered. */
153
156
  const loosen = (o) => ({
154
157
  repeatFailures: Math.max(2, Math.ceil(o.repeatFailures / 2)),
@@ -282,6 +285,8 @@ export class AgentManager extends EventEmitter {
282
285
  this.stuck = new Map(); // agent id -> [finding]
283
286
  this.outbox = new Map(); // agent id -> [message] waiting to be typed into its session
284
287
  // The AI judge: rules decide when to look, a model decides what is going on (see judge.js).
288
+ this.rules = new Rules({ suggestAfter: config.rules?.suggestAfter });
289
+ this.inboxSources = [];
285
290
  this.judge = new Judge({ config, call: (agent, prompt, { timeoutMs }) => this.oneShot(agent, prompt, { ...this.judgeModel(agent), timeoutMs }) });
286
291
  this.stuckVerdicts = new Map(); // agent id -> Map(finding key -> { at, since, data })
287
292
  this.signIns = new Map(); // agent id -> { url, port, callback, terminal, at, finished } from a CLI's sign-in link
@@ -373,7 +378,7 @@ export class AgentManager extends EventEmitter {
373
378
  shell: this.shellNumbers(agent.id).length > 0,
374
379
  shells: this.shellNumbers(agent.id),
375
380
  lastOutputAt: rt?.lastOutputAt ? new Date(rt.lastOutputAt).toISOString() : null,
376
- prompt: rt?.pty && !rt.busy && rt.attention ? engineOf(agent).promptChoices(rt.tail) : null,
381
+ prompt: rt?.pty && !rt.busy && rt.attention ? this.promptView(agent, rt) : null,
377
382
  },
378
383
  };
379
384
  }
@@ -1066,6 +1071,9 @@ export class AgentManager extends EventEmitter {
1066
1071
  prompts.push(memoryPrompt(memoryText(memoryKey(agent))));
1067
1072
  allowedTools = [`Bash(${learnCommand()}:*)`];
1068
1073
  }
1074
+ // Commands you chose to always allow in this repo.
1075
+ const ruled = agent.workdir ? this.rules.allowed(memoryKey(agent)) : [];
1076
+ if (ruled.length) allowedTools = [...(allowedTools ?? []), ...ruled];
1069
1077
  if (agent.builds && agent.workdir) {
1070
1078
  prompts.push(builderPrompt({ dir: agent.workdir, name: agent.builds.name, engine: agent.builds.engine }));
1071
1079
  // Checking the agent loads and reading the reference need no permission prompt.
@@ -2866,16 +2874,68 @@ export class AgentManager extends EventEmitter {
2866
2874
  items.push({ ...base, kind: 'conflict', key: `conflict:${agent.id}:${g.agent.id}`, title: `Overlaps with ${g.agent.name}`, conflict: g });
2867
2875
  }
2868
2876
  }
2869
- const order = { prompt: 0, waiting: 1, question: 1, review: 2, stuck: 3, conflict: 4 };
2877
+ // Commands you keep allowing in a repo: offer to always allow them there.
2878
+ for (const r of this.rules.view().suggestions) items.push({ kind: 'rule', key: `rule:${r.repo}:${r.rule}`, title: `Always allow ${r.label}?`, priority: 2, ...r });
2879
+ // Others' items (autopilot's held backlog work).
2880
+ for (const source of this.inboxSources) items.push(...source());
2881
+ const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, autopilot: 2, stuck: 3, conflict: 4, repeat: 5 };
2870
2882
  return items.sort((a, b) => order[a.kind] - order[b.kind] || a.priority - b.priority);
2871
2883
  }
2872
2884
 
2885
+ /** A live prompt's question and choices, and the tool call it asks about when the transcript says. */
2886
+ promptView(agent, rt) {
2887
+ const p = engineOf(agent).promptChoices(rt.tail);
2888
+ const call = rt.attention === PERMISSION ? this.pendingCall(agent) : null;
2889
+ return call ? { ...p, tool: describeTool(call) } : p;
2890
+ }
2891
+
2892
+ /** The one tool call a live prompt is waiting on, from the transcript (Claude Code); null when unsure. */
2893
+ pendingCall(agent) {
2894
+ const engine = engineOf(agent);
2895
+ if (engine.id !== 'claude' || !agent.workdir || !agent.sessionId) return null;
2896
+ const key = `${agent.sessionId}:${agent.workdir}`;
2897
+ const state = this.activity.get(key) ?? {};
2898
+ this.activity.set(key, engine.activity(engine.sessionFile(agent.workdir, agent.sessionId), state));
2899
+ return state.calls?.size === 1 ? [...state.calls.values()][0] : null;
2900
+ }
2901
+
2902
+ /** A permission prompt for a call one of the repo's allowed rules covers is answered yes by itself. */
2903
+ autoAllow(agent, rt) {
2904
+ if (this.config.rules?.auto === false) return false;
2905
+ const call = this.pendingCall(agent);
2906
+ const repo = call ? memoryKey(agent) : null;
2907
+ const rule = call && this.rules.allows(repo, call);
2908
+ if (!rule) return false;
2909
+ const first = engineOf(agent).promptChoices(rt.tail)?.choices?.[0];
2910
+ if (first?.key !== '1' || !/^yes\b/i.test(first.label)) return false;
2911
+ this.write(agent.id, '1');
2912
+ rt.attention = null;
2913
+ this.rules.used(repo, rule);
2914
+ this.plugins.emit('agent.prompt.allowed', { agent: this.auditView(agent), rule, tool: describeTool(call) });
2915
+ return true;
2916
+ }
2917
+
2918
+ /** After a rule is allowed: prompts already waiting on a call it covers are answered too. */
2919
+ applyRules() {
2920
+ for (const [id, rt] of this.runtimes) {
2921
+ const agent = this.store.get(id);
2922
+ if (agent && rt.pty && !rt.busy && rt.attention === PERMISSION && this.autoAllow(agent, rt)) this.changed(agent);
2923
+ }
2924
+ }
2925
+
2873
2926
  /** Send keys to a live prompt (e.g. "1" to pick the first option). */
2874
2927
  answerPrompt(ref, key) {
2875
2928
  const agent = this.mustGet(ref);
2876
2929
  const keys = { esc: '\x1b', enter: '\r', up: '\x1b[A', down: '\x1b[B' };
2877
2930
  const data = keys[key] ?? (/^\d$/.test(key) ? key : null);
2878
2931
  if (!data) throw Object.assign(new Error('Unsupported key'), { status: 400 });
2932
+ // A yes to a permission prompt counts toward offering a rule for it in this repo.
2933
+ const rt = this.runtimes.get(agent.id);
2934
+ if (rt?.pty && !rt.busy && rt.attention === PERMISSION) {
2935
+ const choice = engineOf(agent).promptChoices(rt.tail)?.choices?.find((c) => c.key === key);
2936
+ const call = /^yes\b/i.test(choice?.label ?? '') ? this.pendingCall(agent) : null;
2937
+ if (call) this.rules.approved(memoryKey(agent), call);
2938
+ }
2879
2939
  // Menus that read a line (the ACP client's) take Enter after the choice.
2880
2940
  this.write(agent.id, engineOf(agent).promptKeyEnter && key !== 'enter' ? `${data}\r` : data);
2881
2941
  return { ok: true };
@@ -2985,6 +3045,7 @@ export class AgentManager extends EventEmitter {
2985
3045
  const screen = rt.tail.slice(-1500).replace(ANSI, '').replace(/\s+/g, '');
2986
3046
  const a0 = this.store.get(id);
2987
3047
  rt.attention = (a0 ? engineOf(a0).attention : []).find(([re]) => re.test(screen))?.[1] ?? null;
3048
+ if (a0 && rt.attention === PERMISSION) this.autoAllow(a0, rt);
2988
3049
  const a = this.store.get(id);
2989
3050
  if (a) this.changed(a);
2990
3051
  // A turn just ended: remember the code and conversation at this point.
package/src/repeats.js ADDED
@@ -0,0 +1,155 @@
1
+ // Requests you keep making: the asks you type (new agents and backlog items, not ones a playbook,
2
+ // trigger or the assistant started) are remembered, the last 500 in ~/.agentmanager/asks.json. When
3
+ // 3 or more read alike (the same words once numbers, quoted names, URLs, issue keys and filler are
4
+ // set aside), the inbox offers to save them as a playbook: the words they share become the request,
5
+ // the part that changes a {{what}} placeholder, and the projects they ran in its projects.
6
+ import crypto from 'node:crypto';
7
+ import { EventEmitter } from 'node:events';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { paths } from './config.js';
11
+
12
+ export const SUGGEST_AFTER = 3;
13
+ const KEEP = 500;
14
+ const ALIKE = 0.5; // share of distinct words two asks must have in common
15
+ const STOP = new Set('a an and are as at be by do does for from get have in into is it its make me my of on or our please should so that the their them then this to up use we with you your'.split(' '));
16
+ const err = (status, message) => Object.assign(new Error(message), { status });
17
+
18
+ /** The words that say what an ask is about, without the parts that change from one to the next. */
19
+ export function words(text) {
20
+ const w = String(text ?? '')
21
+ .toLowerCase()
22
+ .replace(/https?:\/\/\S+/g, ' ')
23
+ .replace(/(^|\s)["'`“‘][^"'`”’\n]{1,80}["'`”’](?=[\s.,;:!?)]|$)/g, ' ')
24
+ .replace(/\b[a-z][a-z0-9]+-\d+\b/g, ' ')
25
+ .replace(/#\d+|\b\d[\w.]*/g, ' ')
26
+ .split(/[^a-z0-9_]+/)
27
+ .filter((x) => x.length > 1 && !STOP.has(x));
28
+ return [...new Set(w)];
29
+ }
30
+
31
+ export function alike(a, b) {
32
+ if (!a.length || !b.length) return 0;
33
+ const sb = new Set(b);
34
+ const both = a.filter((x) => sb.has(x)).length;
35
+ return both / (a.length + b.length - both);
36
+ }
37
+
38
+ /** Groups of asks that read alike, newest first; each group's seed is its newest ask. */
39
+ export function groups(asks, { threshold = ALIKE } = {}) {
40
+ const out = [];
41
+ // Newest first; asks recorded together keep their order.
42
+ const order = asks.map((a, i) => [a, i]).sort(([x, i], [y, j]) => y.at.localeCompare(x.at) || j - i);
43
+ for (const [ask] of order) {
44
+ const w = words(ask.text);
45
+ if (w.length < 2) continue;
46
+ const g = out.find((x) => alike(x.words, w) >= threshold);
47
+ if (g) g.asks.push(ask);
48
+ else out.push({ words: w, asks: [ask] });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * A playbook request from asks that read alike: the words they all start and end with, around a
55
+ * {{what}} for the part that differs ("Upgrade react to the latest" / "Upgrade vite to the latest" →
56
+ * "Upgrade {{what}} to the latest"). When they share too little, the newest ask as it was written.
57
+ */
58
+ export function template(texts) {
59
+ const split = texts.map((t) => String(t).trim().split(/\s+/));
60
+ if (texts.every((t) => t.trim() === texts[0].trim())) return texts[0].trim();
61
+ const shortest = Math.min(...split.map((s) => s.length));
62
+ const same = (get) => split.every((s) => get(s) === get(split[0]));
63
+ let pre = 0;
64
+ while (pre < shortest && same((s) => s[pre].toLowerCase())) pre++;
65
+ let suf = 0;
66
+ while (suf < shortest - pre && same((s) => s[s.length - 1 - suf].toLowerCase())) suf++;
67
+ if (pre + suf < Math.max(2, Math.ceil(shortest / 2))) return texts[0].trim();
68
+ const head = split[0].slice(0, pre).join(' ');
69
+ const tail = split[0].slice(split[0].length - suf).join(' ');
70
+ return [head, '{{what}}', tail].filter(Boolean).join(' ');
71
+ }
72
+
73
+ /** A short name for the playbook, from its request. */
74
+ export function nameFor(intent) {
75
+ const plain = intent.replace(/\{\{\w+\}\}/g, ' ').replace(/\s+/g, ' ').trim();
76
+ const w = plain.split(' ').slice(0, 6);
77
+ while (w.length > 1 && STOP.has(w[w.length - 1].toLowerCase())) w.pop();
78
+ const name = w.join(' ');
79
+ return (name.charAt(0).toUpperCase() + name.slice(1)).replace(/[.,:;]+$/, '').slice(0, 60) || 'Repeated request';
80
+ }
81
+
82
+ export class Repeats extends EventEmitter {
83
+ constructor({ file = path.join(paths.home, 'asks.json'), suggestAfter = SUGGEST_AFTER, enabled = true, playbooks = () => [], projects = () => ({}) } = {}) {
84
+ super();
85
+ Object.assign(this, { file, suggestAfter, enabled, playbooks, projects });
86
+ try {
87
+ this.data = JSON.parse(fs.readFileSync(file, 'utf8'));
88
+ } catch {
89
+ this.data = {};
90
+ }
91
+ this.data.asks ??= [];
92
+ this.data.dismissed ??= [];
93
+ }
94
+
95
+ save() {
96
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
97
+ fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
98
+ this.changed();
99
+ }
100
+
101
+ changed() {
102
+ this.emit('change', this.view());
103
+ }
104
+
105
+ /** Asks the person typed: [{ text, projectKey }]. */
106
+ record(asks) {
107
+ const at = new Date().toISOString();
108
+ const list = asks.map((a) => ({ text: String(a.text ?? '').trim().slice(0, 2000), projectKey: a.projectKey ?? null, at })).filter((a) => a.text);
109
+ if (!list.length) return;
110
+ this.data.asks = [...this.data.asks, ...list].slice(-KEEP);
111
+ this.save();
112
+ }
113
+
114
+ /** Requests asked for at least suggestAfter times, not already a playbook and not dismissed. */
115
+ suggestions() {
116
+ if (!this.enabled) return [];
117
+ const books = this.playbooks().map((p) => words(p.intent));
118
+ const projects = this.projects();
119
+ const out = [];
120
+ for (const g of groups(this.data.asks)) {
121
+ if (g.asks.length < this.suggestAfter) continue;
122
+ if (books.some((b) => alike(b, g.words) >= ALIKE)) continue;
123
+ if (this.data.dismissed.some((d) => alike(d, g.words) >= ALIKE)) continue;
124
+ const intent = template(g.asks.map((a) => a.text));
125
+ const names = [...new Set(g.asks.map((a) => projects[a.projectKey]).filter(Boolean))];
126
+ out.push({
127
+ key: crypto.createHash('sha1').update([...g.words].sort().join(' ')).digest('hex').slice(0, 12),
128
+ name: nameFor(intent),
129
+ intent,
130
+ projects: names,
131
+ count: g.asks.length,
132
+ examples: [...new Set(g.asks.map((a) => a.text))].slice(0, 3),
133
+ lastAt: g.asks[0].at,
134
+ });
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** "Not now": stop offering this one (asks like it keep counting, but it isn't offered again). */
140
+ dismiss(key) {
141
+ const g = groups(this.data.asks).find((x) => crypto.createHash('sha1').update([...x.words].sort().join(' ')).digest('hex').slice(0, 12) === key);
142
+ if (!g) throw err(404, 'No such suggestion');
143
+ this.data.dismissed = [...this.data.dismissed, g.words].slice(-100);
144
+ this.save();
145
+ return this.view();
146
+ }
147
+
148
+ view() {
149
+ return { suggestions: this.suggestions(), suggestAfter: this.suggestAfter };
150
+ }
151
+
152
+ inbox() {
153
+ return this.suggestions().map((s) => ({ ...s, kind: 'repeat', key: `repeat:${s.key}`, title: `Save "${s.name}" as a playbook?`, priority: 3 }));
154
+ }
155
+ }
package/src/review.js CHANGED
@@ -9,6 +9,18 @@ import { ghBin } from './tracker.js';
9
9
  const err = (status, message) => Object.assign(new Error(message), { status });
10
10
  const MAX_COMMENTS = 100;
11
11
 
12
+ /** A git command that writes the index, retried while another git process (the app's own status refresh) holds its lock. */
13
+ async function locked(args, opts, tries = 5) {
14
+ for (let i = 1; ; i++) {
15
+ try {
16
+ return await git.run('git', args, opts);
17
+ } catch (e) {
18
+ if (i >= tries || !/index\.lock|another git process/i.test(e.message)) throw e;
19
+ await new Promise((r) => setTimeout(r, 300 * i));
20
+ }
21
+ }
22
+ }
23
+
12
24
  /** "origin/main @ 1a2b3c4d5" -> { ref: 'origin/main', sha: '1a2b3c4d5' } */
13
25
  export function parseBaseRef(baseRef) {
14
26
  const m = /^(.*) @ ([0-9a-f]{4,40})$/.exec(baseRef ?? '');
@@ -190,8 +202,8 @@ export class Review {
190
202
  const opts = { cwd: agent.workdir, timeout: 60_000 };
191
203
  const status = await git.run('git', ['status', '--porcelain'], opts);
192
204
  if (!status.trim()) throw err(409, 'Nothing to commit');
193
- await git.run('git', ['add', '-A'], opts);
194
- await git.run('git', ['commit', '-m', message], opts);
205
+ await locked(['add', '-A'], opts);
206
+ await locked(['commit', '-m', message], opts);
195
207
  const sha = await git.run('git', ['rev-parse', '--short', 'HEAD'], opts);
196
208
  this.manager.plugins.emit('agent.commit', { agent: this.manager.auditView(agent), message, sha });
197
209
  this.manager.refresh(agent.id).catch(() => {});