ai-agentmanager 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.0 (2026-11-02)
4
+
5
+ - **Fewer questions: approval rules.** When you allow the same command 3 times in a repo (`npm test`, an MCP tool, fetching from a site), the inbox offers **Always allow**. Allowed rules go to every Claude Code agent in that repo at launch and answer its open prompts, including the ones waiting when you allow it. Never offered for compound commands or ones that delete, escalate, publish or reach the network. Settings → Approval rules and `agentmgr rules` list them, with how many prompts each answered, and remove them.
6
+ - **Prompt cards say what they ask about:** "Wants to run `npm test`" (from the session's transcript) instead of only "Do you want to proceed?".
7
+ - **Yes to all:** several agents waiting on the same command get one card that answers them together.
8
+ - **Built agents' tests run in a throwaway copy** of the agent's folder: what a run writes (summaries, changed fixtures, memory) no longer stays in the agent or changes the next test's starting point. The copy keeps the agent's memory, not its engine sessions or run logs. A `workdir` outside the agent's folder is still used in place.
9
+
3
10
  ## 1.13.0 (2026-11-01)
4
11
 
5
12
  - **Agents are built in an agent session.** **+ Build an agent** now starts an agent session that works in the new agent's folder (`~/.agentmanager/built/<name>/`) and goes straight to it. The session writes the analysis (`analysis.json`), `agent.yaml`, `instructions.md` and `tests.yaml`, and checks that the agent loads. You keep shaping the agent in that conversation, like any other session (terminal, resume, inbox).
package/README.md CHANGED
@@ -541,13 +541,16 @@ The **Inbox** tab (and `agentmgr inbox`) lists what needs you, across all agents
541
541
 
542
542
  | Kind | What it is | How you answer |
543
543
  | --- | --- | --- |
544
- | Needs input | A permission prompt or question menu is open in the agent's terminal | Click a choice (1, 2, 3, Esc); no need to open the terminal |
544
+ | Needs input | A permission prompt or question menu is open in the agent's terminal; for Claude Code, the card shows the command or file it asks about | Click a choice (1, 2, 3, Esc); no need to open the terminal. Several agents waiting on the same command: **Yes to all** |
545
+ | Always allow? | You allowed the same command 3 times in a repo | **Always allow** (agents there stop asking) or **Not now** |
545
546
  | Waiting on you | The AI judge read an idle screen and saw a question (or an error) no rule recognized | Click a choice or type an answer; **Not waiting** if it misread |
546
547
  | Question | The agent asked something without stopping | Click an option or type an answer |
547
548
  | AI review | The agent said it was done and the judge checked the work against the intent and brief | **Send back to finish** (types the missing items in) or **Looks good** |
548
549
  | Stuck? | The agent seems to be going in circles | Send the suggested nudge (edit it first if you like) or dismiss |
549
550
  | Overlap | It changes the same files as another agent | Tell one or both agents; see the suggested merge order |
550
551
 
552
+ **Fewer questions: approval rules.** Each yes you give a permission prompt counts toward the command, per repo (`npm test`, `pytest`, `git status`; an MCP tool; fetching from a site). After 3, the inbox offers to always allow it there. An allowed rule goes to every agent in that repo when it starts (Claude Code's `--allowedTools`), and answers its prompts that are already open, so you aren't asked again. Rules are never offered for commands that chain, redirect or substitute (`&&`, `|`, `>`, `$(…)`), or that delete, escalate, publish or reach the network (`rm`, `sudo`, `git push`, `npm publish`, `curl`…). Settings → Approval rules (or `agentmgr rules`) lists them with how many prompts each answered; **Remove** to be asked again. Stored in `~/.agentmanager/rules.json`; In config.json, `"rules": { "auto": false }` stops rules answering open prompts and `"rules": { "suggestAfter": 5 }` changes the count. Claude Code agents only for now.
553
+
551
554
  **Questions without stopping.** Task agents are told to write questions that don't block all their work to `QUESTIONS.md` next to their brief, with options and the default they'll assume meanwhile, and then keep going:
552
555
 
553
556
  ```markdown
package/docs/agentkit.md CHANGED
@@ -67,7 +67,7 @@ interfaces:
67
67
 
68
68
  It asks you what only you know: accounts, labels, teams. You keep developing the agent in that conversation.
69
69
 
70
- The session's page shows a panel for its agent: status and test results, with **Run tests** (each test runs through the real runtime and is judged against `expect`), **Start**/**Stop** (runs it continuously on this computer, with its chat page and API) and **Details**. Failing tests can go straight back to the session. The Built tab lists every built agent with a link to its session. Its details dialog sends change requests to the session and has the spec, executables and downloads. Results and executables from before the latest change are marked out of date.
70
+ The session's page shows a panel for its agent: status and test results, with **Run tests** (each test runs through the real runtime in a throwaway copy of the agent's folder, so what one run writes doesn't change the agent or the next test, and is judged against `expect` and the files it wrote), **Start**/**Stop** (runs it continuously on this computer, with its chat page and API) and **Details**. Failing tests can go straight back to the session. The Built tab lists every built agent with a link to its session. Its details dialog sends change requests to the session and has the spec, executables and downloads. Results and executables from before the latest change are marked out of date.
71
71
 
72
72
  ## A run
73
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.js CHANGED
@@ -127,6 +127,7 @@ $('#new-playbook').onclick = () => {
127
127
  };
128
128
  $('#triggers-open').onclick = () => triggersUi.open();
129
129
  let builtReports = []; // agents built with agentkit that report here (runs, approvals)
130
+ let rulesState = { allowed: [], suggestions: [] }; // approval rules learned from your answers (src/rules.js)
130
131
  const builtUi = initBuilt({
131
132
  api, esc, toast, $,
132
133
  isOpen: () => sideTab === 'built',
@@ -1013,7 +1014,7 @@ function flags(a) {
1013
1014
  }
1014
1015
 
1015
1016
  const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
1016
- const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, review: 2, stuck: 3, conflict: 4 };
1017
+ const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, rule: 2, review: 2, stuck: 3, conflict: 4 };
1017
1018
  /** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
1018
1019
  function needsFor(a, { both = false } = {}) {
1019
1020
  const items = [];
@@ -1037,20 +1038,49 @@ function teamNeeds() {
1037
1038
  function approvalNeeds() {
1038
1039
  return builtReports.flatMap((r) => r.pending.map((p) => ({ a: { id: r.key, name: r.title, priority: 1 }, kind: 'approval', key: `approval:${r.key}:${p.id}`, r, p })));
1039
1040
  }
1041
+ /** The repo a rule belongs to, short: its last two path parts. */
1042
+ const repoLabel = (k) => String(k).replace(/^dir:/, '').split('/').filter(Boolean).slice(-2).join('/');
1043
+ /** Commands you keep allowing in a repo, offered as a standing rule. */
1044
+ function ruleNeeds() {
1045
+ return rulesState.suggestions.map((r) => ({ a: { id: 'rules', name: repoLabel(r.repo), priority: 2 }, kind: 'rule', key: `rule:${r.repo}:${r.rule}`, r }));
1046
+ }
1040
1047
  function inboxItems() {
1041
- return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds()]
1048
+ return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds(), ...ruleNeeds()]
1042
1049
  .sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
1043
1050
  }
1044
1051
 
1045
- const NEED_LABEL = { approval: 'Approval', team: 'Team', prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
1052
+ const NEED_LABEL = { rule: 'Always allow?', approval: 'Approval', team: 'Team', prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
1046
1053
  /** Why the AI judge thinks so, as a small tag. */
1047
1054
  const aiTag = (confidence, what = 'AI') => `<span class="tag ai" title="Judged by a model from the agent's activity; your reactions tune later calls">🤖 ${what}${confidence != null ? ` ${Math.round(confidence * 100)}%` : ''}</span>`;
1055
+ /** What a permission prompt asks about: the command, file or tool. */
1056
+ const toolLine = (t) => (t.name === 'Bash' ? `Wants to run <code>${esc(t.text)}</code>` : `Wants to use ${esc(t.name)}${t.text ? ` <code>${esc(t.text)}</code>` : ''}`);
1057
+ /** Agents waiting on the same command, answered together. */
1058
+ function promptGroups(items) {
1059
+ const groups = new Map();
1060
+ for (const it of items) {
1061
+ const p = it.kind === 'prompt' && it.a.runtime.prompt;
1062
+ if (!p?.tool || p.choices?.[0]?.key !== '1' || !/^yes\b/i.test(p.choices[0].label)) continue;
1063
+ const k = `${p.tool.name}\0${p.tool.text}`;
1064
+ (groups.get(k) ?? groups.set(k, []).get(k)).push(it);
1065
+ }
1066
+ return [...groups.values()].filter((g) => g.length > 1);
1067
+ }
1068
+ function groupCard(g) {
1069
+ const t = g[0].a.runtime.prompt.tool;
1070
+ const names = g.map((it) => `<a href="#${it.a.id}" data-goto="${it.a.id}">${esc(it.a.name)}</a>`).join(', ');
1071
+ return `<div class="need prompt group" data-need="group:${esc(g.map((it) => it.a.id).join(','))}" data-agents="${esc(g.map((it) => it.a.id).join(','))}">
1072
+ <div class="need-head"><span class="need-kind prompt">${g.length} agents</span></div>
1073
+ <p class="need-title">${toolLine(t)}</p><p class="hint">${names}</p>
1074
+ <div class="need-actions"><button class="small primary" data-ib="yes-all">Yes to all ${g.length}</button></div></div>`;
1075
+ }
1048
1076
  function needCard(it, { inDetail = false } = {}) {
1049
1077
  const { a } = it;
1050
1078
  const who = inDetail
1051
1079
  ? ''
1052
1080
  : it.kind === 'approval'
1053
1081
  ? `<a href="#" class="need-agent" data-ib="built-open">⚙ ${esc(a.name)}</a> <span class="hint">${esc(it.r.host)}</span>`
1082
+ : it.kind === 'rule'
1083
+ ? `<span class="need-agent" title="${esc(it.r.repo)}">${esc(a.name)}</span>`
1054
1084
  : `<a href="#${a.id}" class="need-agent" data-goto="${a.id}">${prioChip(a.priority ?? 2)}${esc(a.name)}</a>${a.projectInfo?.name ? ` <span class="hint">${esc(a.projectInfo.name)}</span>` : ''}`;
1055
1085
  const head = `<div class="need-head"><span class="need-kind ${it.kind}">${NEED_LABEL[it.kind]}</span>${who}</div>`;
1056
1086
  const draft = (k, fallback = '') => esc(needDrafts.get(`${it.key}:${k}`) ?? fallback);
@@ -1060,9 +1090,13 @@ function needCard(it, { inDetail = false } = {}) {
1060
1090
  body = `<p class="need-title">Wants to run <code>${esc(p.tool)}</code> <span class="hint">asked ${ago(p.at)}</span></p>
1061
1091
  <pre class="need-input">${esc(JSON.stringify(p.input, null, 2).slice(0, 1500))}</pre>
1062
1092
  <div class="need-actions"><button class="small primary" data-ib="approve" data-allow="1">Approve</button><button class="small" data-ib="approve">Deny</button>${it.r.chat ? `<a class="button small ghost" href="${esc(it.r.chat)}" target="_blank" rel="noopener">Its chat ↗</a>` : ''}</div>`;
1093
+ } else if (it.kind === 'rule') {
1094
+ body = `<p class="need-title">You've allowed <code>${esc(it.r.label)}</code> ${it.r.n} times in this repo. Always allow it here?</p>
1095
+ <p class="hint">Agents in ${esc(a.name)} won't ask about it again. Remove it any time in Settings → Approval rules.</p>
1096
+ <div class="need-actions"><button class="small primary" data-ib="rule-accept">Always allow</button><button class="small" data-ib="rule-dismiss">Not now</button></div>`;
1063
1097
  } else if (it.kind === 'prompt') {
1064
1098
  const p = a.runtime.prompt;
1065
- body = `<p class="need-title">${esc(p?.question || a.runtime.attention)}</p>
1099
+ body = `${p?.tool ? `<p class="need-title">${toolLine(p.tool)}</p><p class="hint">${esc(p.question || a.runtime.attention)}</p>` : `<p class="need-title">${esc(p?.question || a.runtime.attention)}</p>`}
1066
1100
  <div class="need-actions">
1067
1101
  ${(p?.choices ?? []).map((c) => `<button class="small ${c.key === '1' ? 'primary' : ''}" data-ib="key" data-key="${c.key}">${c.key}. ${esc(c.label)}</button>`).join('')}
1068
1102
  <button class="small" data-ib="key" data-key="esc" title="Escape">Esc</button>
@@ -1171,7 +1205,7 @@ function renderInbox() {
1171
1205
  const view = $('#inbox-view');
1172
1206
  if (view.contains(document.activeElement) && document.activeElement.dataset.draft) return; // don't yank the field being typed in
1173
1207
  view.innerHTML = items.length
1174
- ? `<div class="counts"><span>${items.length} waiting on you</span></div><div class="needs inbox">${items.map((it) => needCard(it)).join('')}</div>`
1208
+ ? `<div class="counts"><span>${items.length} waiting on you</span></div><div class="needs inbox">${promptGroups(items).map(groupCard).join('')}${items.map((it) => needCard(it)).join('')}</div>`
1175
1209
  : `<div class="empty"><p>Nothing needs you right now.</p><p>Prompts waiting for an answer, questions agents ask, agents that seem stuck and agents editing the same files show up here.</p></div>`;
1176
1210
  view.insertAdjacentHTML('beforeend', judgeLine());
1177
1211
  }
@@ -1208,6 +1242,19 @@ async function onNeed(e) {
1208
1242
  }
1209
1243
  case 'key':
1210
1244
  return act(() => api('POST', `/agents/${id}/prompt-key`, { key: el.dataset.key }), 'Sent');
1245
+ case 'yes-all': {
1246
+ const ids = card.dataset.agents.split(',');
1247
+ return act(() => Promise.all(ids.map((x) => api('POST', `/agents/${x}/prompt-key`, { key: '1' }))), `Allowed for ${ids.length} agents`);
1248
+ }
1249
+ case 'rule-accept':
1250
+ case 'rule-dismiss': {
1251
+ const [, repo, rule] = key.match(/^rule:(.+?):((?:Bash|WebFetch|mcp__).*)$/) ?? [];
1252
+ const accept = el.dataset.ib === 'rule-accept';
1253
+ return act(async () => {
1254
+ rulesState = await api('POST', `/rules/${accept ? 'accept' : 'dismiss'}`, { repo, rule });
1255
+ renderInbox();
1256
+ }, accept ? 'Allowed in this repo; agents there won\'t ask again' : 'Won\'t suggest it again');
1257
+ }
1211
1258
  case 'terminal':
1212
1259
  showTab('agents');
1213
1260
  select(id);
@@ -3637,6 +3684,7 @@ function handleEvent(msg) {
3637
3684
  teams.clear();
3638
3685
  for (const t of msg.teams ?? []) teams.set(t.id, t);
3639
3686
  builtReports = msg.built ?? [];
3687
+ if (msg.rules) rulesState = msg.rules;
3640
3688
  builtUi.render();
3641
3689
  msg.agents.forEach(watchDocs);
3642
3690
  if (selectedId && !agents.has(selectedId)) selectedId = null;
@@ -3661,6 +3709,8 @@ function handleEvent(msg) {
3661
3709
  teams.set(msg.team.id, msg.team);
3662
3710
  } else if (msg.t === 'team-removed') {
3663
3711
  teams.delete(msg.id);
3712
+ } else if (msg.t === 'rules') {
3713
+ rulesState = msg.rules;
3664
3714
  } else if (msg.t === 'built') {
3665
3715
  builtReports = msg.reports;
3666
3716
  builtUi.render();
package/public/index.html CHANGED
@@ -745,6 +745,11 @@
745
745
  <label class="check">Keep at most <input type="number" name="maxRunning" min="1" max="500" placeholder="no limit" class="num" /> agents open at once</label>
746
746
  <p class="hint">Above it, the agents idle longest are closed (they resume when you open them), so the machine stays responsive. Never closed: agents working, waiting for your answer, active in the last 5 minutes, marked Keep, or in a team that is still going. <span id="settings-running"></span></p>
747
747
  </fieldset>
748
+ <fieldset id="settings-rules">
749
+ <legend>Approval rules</legend>
750
+ <p class="hint">Commands you chose to always allow in a repo. Agents there run them without asking. After you allow the same command 3 times, the inbox offers a rule.</p>
751
+ <ul id="settings-rules-list" class="rules-list"></ul>
752
+ </fieldset>
748
753
  <fieldset class="local-only" id="settings-service">
749
754
  <legend>Start at login</legend>
750
755
  <label class="check"><input type="checkbox" name="startAtLogin" /> Start AI Agent Manager when I log in</label>
package/public/menu.js CHANGED
@@ -92,6 +92,7 @@ export function initMenu({ api, esc, toast, $ }) {
92
92
  form.maxRunning.value = c.maxRunning ?? '';
93
93
  $('#settings-running').textContent = `Open now: ${c.running}.`;
94
94
  service = null;
95
+ showRules(await api('GET', '/rules').catch(() => null));
95
96
  const u = await api('GET', '/update').catch(() => null);
96
97
  form.updateCheck.checked = u?.check !== false;
97
98
  if (!remote()) {
@@ -115,6 +116,23 @@ export function initMenu({ api, esc, toast, $ }) {
115
116
  toast(`Error: ${err.message}`, 5000);
116
117
  }
117
118
  };
119
+ /** Allowed rules, newest first, each with what it has saved and a Remove button. */
120
+ const showRules = (r) => {
121
+ const list = [...(r?.allowed ?? [])].sort((a, b) => String(b.at).localeCompare(String(a.at)));
122
+ $('#settings-rules-list').innerHTML = list.length
123
+ ? list.map((x) => `<li><code>${esc(x.label)}</code> <span class="hint">in ${esc(String(x.repo).replace(/^dir:/, '').split('/').filter(Boolean).slice(-2).join('/'))}${x.uses ? ` · answered ${x.uses} prompt${x.uses === 1 ? '' : 's'}` : ''}</span> <button type="button" class="small ghost" data-rule-remove="${esc(x.rule)}" data-repo="${esc(x.repo)}">Remove</button></li>`).join('')
124
+ : '<li class="hint">None yet.</li>';
125
+ };
126
+ $('#settings-rules-list').addEventListener('click', async (e) => {
127
+ const b = e.target.closest('[data-rule-remove]');
128
+ if (!b) return;
129
+ try {
130
+ showRules(await api('POST', '/rules/remove', { repo: b.dataset.repo, rule: b.dataset.ruleRemove }));
131
+ toast('Rule removed; agents there will ask again');
132
+ } catch (err) {
133
+ toast(`Error: ${err.message}`, 5000);
134
+ }
135
+ });
118
136
  $('#settings-cancel').onclick = () => dialog.close();
119
137
  dialog.addEventListener('click', (e) => {
120
138
  const go = e.target.closest('[data-open]');
package/public/style.css CHANGED
@@ -979,3 +979,9 @@ body.remote .settings-links .local-only { display: none; }
979
979
  .built-tests pre, .built-runs div { white-space: pre-wrap; font-size: 12.5px; }
980
980
  .code-area { width: 100%; font: 12.5px/1.45 var(--mono); margin-top: 6px; }
981
981
  .built-runs { list-style: none; padding: 0; } .built-runs li { padding: 6px 0; border-bottom: 1px solid var(--border); }
982
+
983
+ /* Approval rules (Settings) and agents waiting on the same command (inbox) */
984
+ .rules-list { list-style: none; padding: 0; margin: 6px 0 0; display: grid; gap: 6px; }
985
+ .rules-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
986
+ .rules-list li button { margin-left: auto; }
987
+ .need.group { border-style: dashed; }
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,11 @@ 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 === 'rule') {
894
+ console.log(`${cyan('always allow?')} ${bold(repoLabel(it.repo))} ${it.title} ${dim(`(allowed ${it.n} times)`)}`);
895
+ console.log(dim(' agentmgr rules'));
896
+ continue;
897
+ }
891
898
  console.log(`${label[it.kind]} ${bold(it.agentName)} ${dim(it.agentId)} ${it.title}`);
892
899
  if (it.kind === 'prompt') console.log(dim(` ${(it.prompt?.choices ?? []).map((c) => `${c.key}. ${c.label}`).join(' ')} → agentmgr attach ${it.agentId}`));
893
900
  if (it.kind === 'question') {
@@ -901,6 +908,32 @@ export async function main(argv) {
901
908
  }
902
909
  });
903
910
 
911
+ program
912
+ .command('rules')
913
+ .description('Approval rules: commands always allowed per repo, and the ones the inbox suggests')
914
+ .argument('[action]', 'allow <n> | no <n> (suggestions), rm <n> (allowed)')
915
+ .argument('[n]')
916
+ .action(async (action, n) => {
917
+ await daemon();
918
+ let r = await api('GET', '/rules');
919
+ const pick = (list) => {
920
+ const x = list[Number(n) - 1];
921
+ if (!x) throw new Error(`No rule ${n ?? ''}; see agentmgr rules`);
922
+ return { repo: x.repo, rule: x.rule };
923
+ };
924
+ if (action === 'allow') r = await api('POST', '/rules/accept', pick(r.suggestions));
925
+ else if (action === 'no') r = await api('POST', '/rules/dismiss', pick(r.suggestions));
926
+ else if (action === 'rm') r = await api('POST', '/rules/remove', pick(r.allowed));
927
+ else if (action) throw new Error('Use: agentmgr rules [allow <n> | no <n> | rm <n>]');
928
+ if (r.suggestions.length) {
929
+ console.log(bold('Suggested') + dim(` (you allowed these ${r.suggestAfter}+ times; agentmgr rules allow <n> | no <n>)`));
930
+ r.suggestions.forEach((x, i) => console.log(` ${i + 1}. ${x.label} ${dim(`in ${repoLabel(x.repo)}, allowed ${x.n} times`)}`));
931
+ }
932
+ console.log(bold('Allowed') + dim(' (agents in the repo run these without asking; agentmgr rules rm <n>)'));
933
+ if (!r.allowed.length) console.log(dim(' none yet'));
934
+ r.allowed.forEach((x, i) => console.log(` ${i + 1}. ${x.label} ${dim(`in ${repoLabel(x.repo)}${x.uses ? `, answered ${x.uses} prompts` : ''}`)}`));
935
+ });
936
+
904
937
  program
905
938
  .command('answer')
906
939
  .description('Answer a question an agent asked in the inbox')
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,7 @@ 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 });
285
289
  this.judge = new Judge({ config, call: (agent, prompt, { timeoutMs }) => this.oneShot(agent, prompt, { ...this.judgeModel(agent), timeoutMs }) });
286
290
  this.stuckVerdicts = new Map(); // agent id -> Map(finding key -> { at, since, data })
287
291
  this.signIns = new Map(); // agent id -> { url, port, callback, terminal, at, finished } from a CLI's sign-in link
@@ -373,7 +377,7 @@ export class AgentManager extends EventEmitter {
373
377
  shell: this.shellNumbers(agent.id).length > 0,
374
378
  shells: this.shellNumbers(agent.id),
375
379
  lastOutputAt: rt?.lastOutputAt ? new Date(rt.lastOutputAt).toISOString() : null,
376
- prompt: rt?.pty && !rt.busy && rt.attention ? engineOf(agent).promptChoices(rt.tail) : null,
380
+ prompt: rt?.pty && !rt.busy && rt.attention ? this.promptView(agent, rt) : null,
377
381
  },
378
382
  };
379
383
  }
@@ -1066,6 +1070,9 @@ export class AgentManager extends EventEmitter {
1066
1070
  prompts.push(memoryPrompt(memoryText(memoryKey(agent))));
1067
1071
  allowedTools = [`Bash(${learnCommand()}:*)`];
1068
1072
  }
1073
+ // Commands you chose to always allow in this repo.
1074
+ const ruled = agent.workdir ? this.rules.allowed(memoryKey(agent)) : [];
1075
+ if (ruled.length) allowedTools = [...(allowedTools ?? []), ...ruled];
1069
1076
  if (agent.builds && agent.workdir) {
1070
1077
  prompts.push(builderPrompt({ dir: agent.workdir, name: agent.builds.name, engine: agent.builds.engine }));
1071
1078
  // Checking the agent loads and reading the reference need no permission prompt.
@@ -2866,16 +2873,66 @@ export class AgentManager extends EventEmitter {
2866
2873
  items.push({ ...base, kind: 'conflict', key: `conflict:${agent.id}:${g.agent.id}`, title: `Overlaps with ${g.agent.name}`, conflict: g });
2867
2874
  }
2868
2875
  }
2869
- const order = { prompt: 0, waiting: 1, question: 1, review: 2, stuck: 3, conflict: 4 };
2876
+ // Commands you keep allowing in a repo: offer to always allow them there.
2877
+ 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 });
2878
+ const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, stuck: 3, conflict: 4 };
2870
2879
  return items.sort((a, b) => order[a.kind] - order[b.kind] || a.priority - b.priority);
2871
2880
  }
2872
2881
 
2882
+ /** A live prompt's question and choices, and the tool call it asks about when the transcript says. */
2883
+ promptView(agent, rt) {
2884
+ const p = engineOf(agent).promptChoices(rt.tail);
2885
+ const call = rt.attention === PERMISSION ? this.pendingCall(agent) : null;
2886
+ return call ? { ...p, tool: describeTool(call) } : p;
2887
+ }
2888
+
2889
+ /** The one tool call a live prompt is waiting on, from the transcript (Claude Code); null when unsure. */
2890
+ pendingCall(agent) {
2891
+ const engine = engineOf(agent);
2892
+ if (engine.id !== 'claude' || !agent.workdir || !agent.sessionId) return null;
2893
+ const key = `${agent.sessionId}:${agent.workdir}`;
2894
+ const state = this.activity.get(key) ?? {};
2895
+ this.activity.set(key, engine.activity(engine.sessionFile(agent.workdir, agent.sessionId), state));
2896
+ return state.calls?.size === 1 ? [...state.calls.values()][0] : null;
2897
+ }
2898
+
2899
+ /** A permission prompt for a call one of the repo's allowed rules covers is answered yes by itself. */
2900
+ autoAllow(agent, rt) {
2901
+ if (this.config.rules?.auto === false) return false;
2902
+ const call = this.pendingCall(agent);
2903
+ const repo = call ? memoryKey(agent) : null;
2904
+ const rule = call && this.rules.allows(repo, call);
2905
+ if (!rule) return false;
2906
+ const first = engineOf(agent).promptChoices(rt.tail)?.choices?.[0];
2907
+ if (first?.key !== '1' || !/^yes\b/i.test(first.label)) return false;
2908
+ this.write(agent.id, '1');
2909
+ rt.attention = null;
2910
+ this.rules.used(repo, rule);
2911
+ this.plugins.emit('agent.prompt.allowed', { agent: this.auditView(agent), rule, tool: describeTool(call) });
2912
+ return true;
2913
+ }
2914
+
2915
+ /** After a rule is allowed: prompts already waiting on a call it covers are answered too. */
2916
+ applyRules() {
2917
+ for (const [id, rt] of this.runtimes) {
2918
+ const agent = this.store.get(id);
2919
+ if (agent && rt.pty && !rt.busy && rt.attention === PERMISSION && this.autoAllow(agent, rt)) this.changed(agent);
2920
+ }
2921
+ }
2922
+
2873
2923
  /** Send keys to a live prompt (e.g. "1" to pick the first option). */
2874
2924
  answerPrompt(ref, key) {
2875
2925
  const agent = this.mustGet(ref);
2876
2926
  const keys = { esc: '\x1b', enter: '\r', up: '\x1b[A', down: '\x1b[B' };
2877
2927
  const data = keys[key] ?? (/^\d$/.test(key) ? key : null);
2878
2928
  if (!data) throw Object.assign(new Error('Unsupported key'), { status: 400 });
2929
+ // A yes to a permission prompt counts toward offering a rule for it in this repo.
2930
+ const rt = this.runtimes.get(agent.id);
2931
+ if (rt?.pty && !rt.busy && rt.attention === PERMISSION) {
2932
+ const choice = engineOf(agent).promptChoices(rt.tail)?.choices?.find((c) => c.key === key);
2933
+ const call = /^yes\b/i.test(choice?.label ?? '') ? this.pendingCall(agent) : null;
2934
+ if (call) this.rules.approved(memoryKey(agent), call);
2935
+ }
2879
2936
  // Menus that read a line (the ACP client's) take Enter after the choice.
2880
2937
  this.write(agent.id, engineOf(agent).promptKeyEnter && key !== 'enter' ? `${data}\r` : data);
2881
2938
  return { ok: true };
@@ -2985,6 +3042,7 @@ export class AgentManager extends EventEmitter {
2985
3042
  const screen = rt.tail.slice(-1500).replace(ANSI, '').replace(/\s+/g, '');
2986
3043
  const a0 = this.store.get(id);
2987
3044
  rt.attention = (a0 ? engineOf(a0).attention : []).find(([re]) => re.test(screen))?.[1] ?? null;
3045
+ if (a0 && rt.attention === PERMISSION) this.autoAllow(a0, rt);
2988
3046
  const a = this.store.get(id);
2989
3047
  if (a) this.changed(a);
2990
3048
  // A turn just ended: remember the code and conversation at this point.
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
@@ -448,6 +448,16 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
448
448
  );
449
449
  // Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
450
450
  api.get('/inbox', wrap(() => manager.inbox()));
451
+ // Approval rules learned from your answers (src/rules.js): allowed per repo, and suggestions.
452
+ const ruleArgs = (req) => {
453
+ const { repo, rule } = req.body ?? {};
454
+ if (typeof repo !== 'string' || typeof rule !== 'string') throw Object.assign(new Error('repo and rule are required'), { status: 400 });
455
+ return [repo, rule];
456
+ };
457
+ api.get('/rules', wrap(() => manager.rules.view()));
458
+ api.post('/rules/accept', wrap((req) => (manager.rules.accept(...ruleArgs(req)), manager.applyRules(), manager.rules.view())));
459
+ api.post('/rules/dismiss', wrap((req) => (manager.rules.dismiss(...ruleArgs(req)), manager.rules.view())));
460
+ api.post('/rules/remove', wrap((req) => (manager.rules.remove(...ruleArgs(req)), manager.rules.view())));
451
461
  api.post('/agents/:id/tell', wrap((req) => manager.tell(req.params.id, req.body?.text)));
452
462
  api.post('/agents/:id/answer', wrap((req) => manager.answerQuestion(req.params.id, req.body?.question, req.body?.text)));
453
463
  api.post('/agents/:id/prompt-key', wrap((req) => manager.answerPrompt(req.params.id, String(req.body?.key ?? ''))));
@@ -912,6 +922,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
912
922
  };
913
923
  manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
914
924
  reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
925
+ manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
915
926
  manager.on('removed', (id) => {
916
927
  broadcast({ t: 'removed', id });
917
928
  live.closeAgent(id).catch(() => {});
@@ -948,7 +959,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
948
959
  const url = new URL(req.url, 'http://x');
949
960
  if (url.pathname === '/ws/events') {
950
961
  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() }));
962
+ 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() }));
952
963
  ws.on('close', () => eventClients.delete(ws));
953
964
  return;
954
965
  }
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);