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 +7 -0
- package/README.md +4 -1
- package/docs/agentkit.md +1 -1
- package/package.json +1 -1
- package/public/app.js +55 -5
- package/public/index.html +5 -0
- package/public/menu.js +18 -0
- package/public/style.css +6 -0
- package/src/builder.js +36 -15
- package/src/cli.js +33 -0
- package/src/manager.js +60 -2
- package/src/rules.js +161 -0
- package/src/server.js +12 -1
- package/src/stuck.js +9 -2
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
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
|
-
//
|
|
328
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 ?
|
|
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
|
-
|
|
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);
|