ai-agentmanager 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +22 -0
- package/package.json +1 -1
- package/public/app.js +72 -5
- package/public/style.css +4 -0
- package/src/autopilot.js +184 -0
- package/src/backlog.js +19 -7
- package/src/cli.js +24 -2
- package/src/manager.js +4 -1
- package/src/repeats.js +155 -0
- package/src/review.js +14 -2
- package/src/server.js +33 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.15.0 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
- **Autopilot for the backlog:** finished items go on to a pull request, and merged, without you, when the review scorecard says they're ready. Per project (🛫 in the Backlog tab, `agentmgr backlog autopilot <project> off|pr|merge`), off by default. *Open PRs* commits, pushes and opens the pull request. *Merge* also merges it once checks pass, updating a branch that fell behind. Not-ready work goes back to the agent with the scorecard's reasons (twice at most). Anything else waits in the Inbox as *Autopilot held*, with **Ship it**, **Review changes** and **Send back**. An item whose pull request merges is done. **Ship it** is on every item in review.
|
|
6
|
+
- **Requests you keep making become playbooks:** when you've typed alike requests 3 times (new agents and backlog items, with numbers, names, URLs and issue keys set aside), the Inbox offers **Make it a playbook?**. **Save as playbook…** opens the editor prefilled: the shared words become the request, the part that changes becomes `{{what}}`, and the projects they ran in become its projects. **Not now** stops offering it. Requests already saved as a playbook aren't offered. `"repeats": { "suggestAfter": 3, "enabled": true }` in config.json.
|
|
7
|
+
- **Commit all** no longer fails with `index.lock: File exists` when it lands on the app's own background `git status` of the same worktree. It retries for a moment.
|
|
8
|
+
|
|
3
9
|
## 1.14.0 (2026-11-02)
|
|
4
10
|
|
|
5
11
|
- **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.
|
package/README.md
CHANGED
|
@@ -514,6 +514,21 @@ Queue asks instead of starting them. Use **+ Add** in the Backlog tab, **Queue i
|
|
|
514
514
|
|
|
515
515
|
The backlog is saved with the rest of the state.
|
|
516
516
|
|
|
517
|
+
### Autopilot
|
|
518
|
+
|
|
519
|
+
Autopilot takes a project's finished items the rest of the way when the [review scorecard](#review-changes) says the work is ready. Set it per project with **🛫** in the project's row in the Backlog tab, or `agentmgr backlog autopilot <project> off|pr|merge`. It is off by default.
|
|
520
|
+
|
|
521
|
+
- **Off:** items wait in review for **Accept** or **Reopen**, as above.
|
|
522
|
+
- **Open PRs:** when an item reaches review, its changes are scored (again, if they changed since the last score). If the scorecard says *Ready to approve*, autopilot commits what's left (the item's title as the message), pushes and opens the pull request, with the scorecard in its description.
|
|
523
|
+
- **Merge:** also merges that pull request once its checks pass and GitHub allows it. A branch that fell behind is brought up to date first, and failing checks go back to the agent through [Landing](#landing-pull-requests).
|
|
524
|
+
|
|
525
|
+
What it does with work that isn't ready:
|
|
526
|
+
|
|
527
|
+
- **Not ready, for a concrete reason** (failing CI, acceptance criteria not met, barely verified): the item goes back to the same agent with those reasons. This happens twice at most; after that it waits for you.
|
|
528
|
+
- **Look at these first, a failed step, or a closed pull request:** autopilot stops and it waits for you in the **Inbox** ("Autopilot held"), saying why. You can **Review changes**, **Ship it** (go ahead anyway), or **Send back** with a note.
|
|
529
|
+
|
|
530
|
+
An item whose pull request merges is done. Each item shows where autopilot stands ("pull request open #12", "waiting for checks to merge", "sent back to the agent"). **Ship it** is also on every item in review, so you can ship one by hand with autopilot off. Plugins can still refuse the commit, the pull request or the merge (`agent.commit`, `agent.pr`, `agent.merge`).
|
|
531
|
+
|
|
517
532
|
## Playbooks
|
|
518
533
|
|
|
519
534
|
Saved requests you run again: **Backlog → Playbooks**, **Save as playbook…** in New agents, or `agentmgr playbook`. A playbook is a request, the projects to run it in (one agent each), and optionally a model, permission mode, priority and schedule:
|
|
@@ -524,6 +539,13 @@ Saved requests you run again: **Backlog → Playbooks**, **Save as playbook…**
|
|
|
524
539
|
|
|
525
540
|
Agents started by a playbook are marked **▶ <playbook>**, and the playbook lists the agents of its last run.
|
|
526
541
|
|
|
542
|
+
**Requests you keep making become playbooks.** AI Agent Manager remembers the requests you type for new agents and backlog items (the last 500, in `~/.agentmanager/asks.json`). Requests started by playbooks, triggers or the assistant aren't counted. When 3 read alike, the Inbox offers **Make it a playbook?** with the examples. "Alike" means the same words once numbers, quoted names, URLs, issue keys and filler words are set aside.
|
|
543
|
+
|
|
544
|
+
- **Save as playbook…** opens the playbook editor prefilled. The words the requests share become the request, and the part that changes becomes `{{what}}`: "Upgrade react to the latest version" and "Upgrade vite to the latest version" become "Upgrade `{{what}}` to the latest version". The projects they ran in become the playbook's projects.
|
|
545
|
+
- **Not now** stops offering that one.
|
|
546
|
+
- A request that is already a playbook isn't offered.
|
|
547
|
+
- Change the count with `"repeats": { "suggestAfter": 3 }`, or turn it off with `"repeats": { "enabled": false }`.
|
|
548
|
+
|
|
527
549
|
```sh
|
|
528
550
|
agentmgr playbook add "Flaky tests" "Find yesterday's flaky tests in CI and fix or quarantine them" --projects web,api --every weekday --at 09:00
|
|
529
551
|
agentmgr playbook ls
|
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 repeatsState = { suggestions: [] }; // requests you keep making, offered as playbooks (src/repeats.js)
|
|
130
131
|
let rulesState = { allowed: [], suggestions: [] }; // approval rules learned from your answers (src/rules.js)
|
|
131
132
|
const builtUi = initBuilt({
|
|
132
133
|
api, esc, toast, $,
|
|
@@ -855,7 +856,7 @@ function renderList() {
|
|
|
855
856
|
const ITEM_ACTIONS = {
|
|
856
857
|
queued: [['up', '▲', 'Move up'], ['down', '▼', 'Move down'], ['start', 'Start now', 'Start now, even if the project is at its limit'], ['cancel', 'Cancel', '']],
|
|
857
858
|
active: [['open', 'Open', 'Open its agent'], ['accept', 'Done', 'Mark done now'], ['cancel', 'Cancel', '']],
|
|
858
|
-
review: [['accept', 'Accept', 'Accept the work: done'], ['reopen', 'Reopen', 'Send it back to the same agent with a note'], ['open', 'Open', 'Open its agent']],
|
|
859
|
+
review: [['accept', 'Accept', 'Accept the work: done'], ['ship', 'Ship it', 'Commit, push and open its pull request (and merge it, at the Merge autopilot level)'], ['reopen', 'Reopen', 'Send it back to the same agent with a note'], ['open', 'Open', 'Open its agent']],
|
|
859
860
|
blocked: [['start', 'Retry', 'Retry the agent'], ['open', 'Open', 'Open its agent'], ['cancel', 'Cancel', '']],
|
|
860
861
|
done: [['reopen', 'Reopen', ''], ['open', 'Open', ''], ['remove', '✕', 'Remove from the list']],
|
|
861
862
|
cancelled: [['remove', '✕', 'Remove from the list']],
|
|
@@ -895,6 +896,7 @@ function renderBacklog() {
|
|
|
895
896
|
${p.shortcut ? `<span class="shortcut">${esc(p.shortcut)}</span>` : ''}
|
|
896
897
|
<span class="pname">${esc(g.name)}</span>
|
|
897
898
|
<button class="link" data-bl="limit" data-project="${esc(g.key)}" data-name="${esc(g.name)}" title="How many items of this project agents work on at once">${p.limit} at a time ✎</button>
|
|
899
|
+
<label class="bl-autopilot" title="${esc(AUTOPILOT_HINT)}">🛫 <select aria-label="Autopilot" data-bl-autopilot="${esc(g.key)}">${Object.entries(AUTOPILOT_LEVELS).map(([v, l]) => `<option value="${v}" ${(p.autopilot ?? 'off') === v ? 'selected' : ''}>${l}</option>`).join('')}</select></label>
|
|
898
900
|
</div>
|
|
899
901
|
<ul class="project-agents">${g.items.map(itemRow).join('')}</ul>
|
|
900
902
|
</li>`;
|
|
@@ -904,6 +906,17 @@ function renderBacklog() {
|
|
|
904
906
|
<p class="hint bl-foot"><label><input type="checkbox" data-bl="completed" ${showCompleted ? 'checked' : ''}/> Show done and cancelled</label></p>`;
|
|
905
907
|
}
|
|
906
908
|
|
|
909
|
+
const AUTOPILOT_LEVELS = { off: 'Off', pr: 'Open PRs', merge: 'Merge' };
|
|
910
|
+
const AUTOPILOT_HINT = 'When an item is done and its review scorecard says ready: Open PRs commits, pushes and opens the pull request; Merge also merges it once checks pass. Not-ready work goes back to the agent; anything else waits for you in the inbox.';
|
|
911
|
+
const AUTOPILOT_STATE = { queued: 'going ahead', scoring: 'scoring the changes', shipping: 'opening the pull request', pr: 'pull request open', landing: 'waiting for checks to merge', merging: 'merging', returned: 'sent back to the agent', held: 'waiting for you', merged: 'merged' };
|
|
912
|
+
/** Where autopilot stands on an item, under it in the backlog. */
|
|
913
|
+
function autopilotLine(it) {
|
|
914
|
+
const ap = it.autopilot;
|
|
915
|
+
if (!ap?.state || !['review', 'active', 'done'].includes(it.status) || (it.status === 'active' && ap.state !== 'returned')) return '';
|
|
916
|
+
const pr = ap.pr ? ` <a href="${esc(ap.pr)}" target="_blank" rel="noopener">#${esc(ap.pr.split('/').pop())} ↗</a>` : '';
|
|
917
|
+
return `<p class="hint bl-ai bl-ap ${ap.state === 'held' ? 'held' : ''}">🛫 Autopilot: ${AUTOPILOT_STATE[ap.state] ?? esc(ap.state)}${pr}${ap.note ? ` · ${esc(ap.note)}` : ''}</p>`;
|
|
918
|
+
}
|
|
919
|
+
|
|
907
920
|
function itemRow(it) {
|
|
908
921
|
const a = it.agent;
|
|
909
922
|
const st = it.status === 'active' && a?.attention ? 'attention' : it.status;
|
|
@@ -919,10 +932,11 @@ function itemRow(it) {
|
|
|
919
932
|
${it.base ? `<span>⎇ from ${esc(it.base)}</span>` : ''}
|
|
920
933
|
${it.doneAt ? `<span>done ${ago(it.doneAt)}</span>` : it.reviewAt ? `<span>ready ${ago(it.reviewAt)}</span>` : it.startedAt ? `<span>started ${ago(it.startedAt)}</span>` : `<span>added ${ago(it.createdAt)}</span>`}
|
|
921
934
|
</div>
|
|
935
|
+
${autopilotLine(it)}
|
|
922
936
|
${it.status === 'queued' && it.aiHold ? `<p class="hint bl-ai">🤖 Waits for <b>${esc(it.aiHold.afterTitle)}</b>${it.aiHold.reason ? `: ${esc(it.aiHold.reason)}` : ''}</p>` : ''}
|
|
923
937
|
${it.status === 'queued' && it.aiSplit ? `<div class="hint bl-ai">🤖 Could be split${it.aiSplit.reason ? ` (${esc(it.aiSplit.reason)})` : ''}:<ul>${it.aiSplit.parts.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
|
924
938
|
<button class="small" data-bl="split">Split into ${it.aiSplit.parts.length}</button> <button class="small ghost" data-bl="keep-whole">Keep whole</button></div>` : ''}
|
|
925
|
-
<div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).map(([act, label, title]) => `<button class="small" data-bl="${act}" title="${esc(title)}">${label}</button>`).join('')}</div>
|
|
939
|
+
<div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).filter(([act]) => act !== 'ship' || !['pr', 'landing', 'merging', 'shipping'].includes(it.autopilot?.state)).map(([act, label, title]) => `<button class="small" data-bl="${act}" title="${esc(title)}">${label}</button>`).join('')}</div>
|
|
926
940
|
</li>`;
|
|
927
941
|
}
|
|
928
942
|
|
|
@@ -942,6 +956,12 @@ function showTab(tab) {
|
|
|
942
956
|
}
|
|
943
957
|
for (const b of document.querySelectorAll('.side-tab')) b.onclick = () => showTab(b.dataset.tab);
|
|
944
958
|
|
|
959
|
+
$('#backlog-view').addEventListener('change', (e) => {
|
|
960
|
+
const key = e.target.dataset?.blAutopilot;
|
|
961
|
+
if (!key) return;
|
|
962
|
+
const level = e.target.value;
|
|
963
|
+
act(() => api('POST', '/backlog/settings', { project: key, autopilot: level }), level === 'off' ? 'Autopilot off' : `Autopilot: ${AUTOPILOT_LEVELS[level]}`);
|
|
964
|
+
});
|
|
945
965
|
$('#backlog-view').addEventListener('click', async (e) => {
|
|
946
966
|
const el = e.target.closest('[data-bl]');
|
|
947
967
|
if (!el) return;
|
|
@@ -989,6 +1009,8 @@ $('#backlog-view').addEventListener('click', async (e) => {
|
|
|
989
1009
|
return blApi('POST', `/backlog/${id}/split`, { parts: it.aiSplit.parts });
|
|
990
1010
|
case 'keep-whole':
|
|
991
1011
|
return blApi('POST', `/backlog/${id}/keep-whole`);
|
|
1012
|
+
case 'ship':
|
|
1013
|
+
return act(() => api('POST', `/backlog/${id}/ship`), 'Shipping: opening its pull request');
|
|
992
1014
|
case 'open': {
|
|
993
1015
|
const agentId = it?.agent?.id;
|
|
994
1016
|
if (!agentId) return;
|
|
@@ -1014,7 +1036,7 @@ function flags(a) {
|
|
|
1014
1036
|
}
|
|
1015
1037
|
|
|
1016
1038
|
const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
|
|
1017
|
-
const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, rule: 2, review: 2, stuck: 3, conflict: 4 };
|
|
1039
|
+
const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, rule: 2, review: 2, autopilot: 2, stuck: 3, conflict: 4, repeat: 5 };
|
|
1018
1040
|
/** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
|
|
1019
1041
|
function needsFor(a, { both = false } = {}) {
|
|
1020
1042
|
const items = [];
|
|
@@ -1044,12 +1066,22 @@ const repoLabel = (k) => String(k).replace(/^dir:/, '').split('/').filter(Boolea
|
|
|
1044
1066
|
function ruleNeeds() {
|
|
1045
1067
|
return rulesState.suggestions.map((r) => ({ a: { id: 'rules', name: repoLabel(r.repo), priority: 2 }, kind: 'rule', key: `rule:${r.repo}:${r.rule}`, r }));
|
|
1046
1068
|
}
|
|
1069
|
+
/** Backlog work autopilot held for you. */
|
|
1070
|
+
function autopilotNeeds() {
|
|
1071
|
+
return backlogState.items
|
|
1072
|
+
.filter((it) => it.status === 'review' && it.autopilot?.state === 'held' && it.autopilot.round === it.reviewAt)
|
|
1073
|
+
.map((it) => ({ a: { id: it.agentId ?? '', name: it.agent?.name ?? it.projectName, priority: it.priority }, kind: 'autopilot', key: `autopilot:${it.id}`, it }));
|
|
1074
|
+
}
|
|
1075
|
+
/** Requests you keep making, offered as a playbook. */
|
|
1076
|
+
function repeatNeeds() {
|
|
1077
|
+
return repeatsState.suggestions.map((r) => ({ a: { id: 'repeats', name: r.projects.join(', ') || 'Requests', priority: 3 }, kind: 'repeat', key: `repeat:${r.key}`, r }));
|
|
1078
|
+
}
|
|
1047
1079
|
function inboxItems() {
|
|
1048
|
-
return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds(), ...ruleNeeds()]
|
|
1080
|
+
return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds(), ...ruleNeeds(), ...autopilotNeeds(), ...repeatNeeds()]
|
|
1049
1081
|
.sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
|
|
1050
1082
|
}
|
|
1051
1083
|
|
|
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' };
|
|
1084
|
+
const NEED_LABEL = { repeat: 'Make it a playbook?', autopilot: 'Autopilot held', 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' };
|
|
1053
1085
|
/** Why the AI judge thinks so, as a small tag. */
|
|
1054
1086
|
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
1087
|
/** What a permission prompt asks about: the command, file or tool. */
|
|
@@ -1081,6 +1113,8 @@ function needCard(it, { inDetail = false } = {}) {
|
|
|
1081
1113
|
? `<a href="#" class="need-agent" data-ib="built-open">⚙ ${esc(a.name)}</a> <span class="hint">${esc(it.r.host)}</span>`
|
|
1082
1114
|
: it.kind === 'rule'
|
|
1083
1115
|
? `<span class="need-agent" title="${esc(it.r.repo)}">${esc(a.name)}</span>`
|
|
1116
|
+
: it.kind === 'repeat'
|
|
1117
|
+
? `<span class="need-agent">${esc(a.name)}</span>`
|
|
1084
1118
|
: `<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>` : ''}`;
|
|
1085
1119
|
const head = `<div class="need-head"><span class="need-kind ${it.kind}">${NEED_LABEL[it.kind]}</span>${who}</div>`;
|
|
1086
1120
|
const draft = (k, fallback = '') => esc(needDrafts.get(`${it.key}:${k}`) ?? fallback);
|
|
@@ -1090,6 +1124,16 @@ function needCard(it, { inDetail = false } = {}) {
|
|
|
1090
1124
|
body = `<p class="need-title">Wants to run <code>${esc(p.tool)}</code> <span class="hint">asked ${ago(p.at)}</span></p>
|
|
1091
1125
|
<pre class="need-input">${esc(JSON.stringify(p.input, null, 2).slice(0, 1500))}</pre>
|
|
1092
1126
|
<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>`;
|
|
1127
|
+
} else if (it.kind === 'repeat') {
|
|
1128
|
+
const r = it.r;
|
|
1129
|
+
body = `<p class="need-title">You've asked for this ${r.count} times: <code>${esc(r.intent)}</code></p>
|
|
1130
|
+
<ul class="need-files">${r.examples.map((x) => `<li>${esc(x.length > 140 ? `${x.slice(0, 140)}…` : x)}</li>`).join('')}</ul>
|
|
1131
|
+
<p class="hint">Save it as a playbook to run it again in one click, or on a schedule.</p>
|
|
1132
|
+
<div class="need-actions"><button class="small primary" data-ib="repeat-save">Save as playbook…</button><button class="small" data-ib="repeat-dismiss">Not now</button></div>`;
|
|
1133
|
+
} else if (it.kind === 'autopilot') {
|
|
1134
|
+
body = `<p class="need-title">${esc(it.it.title)}</p>
|
|
1135
|
+
<p class="hint">${esc(it.it.autopilot.note ?? '')}</p>
|
|
1136
|
+
<div class="need-actions"><button class="small primary" data-ib="ap-review">Review changes</button><button class="small" data-ib="ap-ship" title="Commit, push and open its pull request (and merge it, at the Merge level)">Ship it</button><button class="small" data-ib="ap-reopen">Send back…</button></div>`;
|
|
1093
1137
|
} else if (it.kind === 'rule') {
|
|
1094
1138
|
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
1139
|
<p class="hint">Agents in ${esc(a.name)} won't ask about it again. Remove it any time in Settings → Approval rules.</p>
|
|
@@ -1246,6 +1290,26 @@ async function onNeed(e) {
|
|
|
1246
1290
|
const ids = card.dataset.agents.split(',');
|
|
1247
1291
|
return act(() => Promise.all(ids.map((x) => api('POST', `/agents/${x}/prompt-key`, { key: '1' }))), `Allowed for ${ids.length} agents`);
|
|
1248
1292
|
}
|
|
1293
|
+
case 'repeat-save': {
|
|
1294
|
+
const r = repeatsState.suggestions.find((x) => `repeat:${x.key}` === key);
|
|
1295
|
+
return r && playbooksUi.open({ name: r.name, intent: r.intent, projects: r.projects });
|
|
1296
|
+
}
|
|
1297
|
+
case 'repeat-dismiss':
|
|
1298
|
+
return act(async () => {
|
|
1299
|
+
repeatsState = await api('POST', '/repeats/dismiss', { key: key.slice('repeat:'.length) });
|
|
1300
|
+
renderInbox();
|
|
1301
|
+
}, "Won't suggest it again");
|
|
1302
|
+
case 'ap-review':
|
|
1303
|
+
showTab('agents');
|
|
1304
|
+
select(id);
|
|
1305
|
+
return reviewUi.open(id);
|
|
1306
|
+
case 'ap-ship':
|
|
1307
|
+
return act(() => api('POST', `/backlog/${key.split(':')[1]}/ship`), 'Shipping: opening its pull request');
|
|
1308
|
+
case 'ap-reopen': {
|
|
1309
|
+
const note = prompt('What still needs doing? (sent to the same agent)', '');
|
|
1310
|
+
if (note === null) return;
|
|
1311
|
+
return act(() => api('POST', `/backlog/${key.split(':')[1]}/reopen`, { note }), 'Sent back');
|
|
1312
|
+
}
|
|
1249
1313
|
case 'rule-accept':
|
|
1250
1314
|
case 'rule-dismiss': {
|
|
1251
1315
|
const [, repo, rule] = key.match(/^rule:(.+?):((?:Bash|WebFetch|mcp__).*)$/) ?? [];
|
|
@@ -3685,6 +3749,7 @@ function handleEvent(msg) {
|
|
|
3685
3749
|
for (const t of msg.teams ?? []) teams.set(t.id, t);
|
|
3686
3750
|
builtReports = msg.built ?? [];
|
|
3687
3751
|
if (msg.rules) rulesState = msg.rules;
|
|
3752
|
+
if (msg.repeats) repeatsState = msg.repeats;
|
|
3688
3753
|
builtUi.render();
|
|
3689
3754
|
msg.agents.forEach(watchDocs);
|
|
3690
3755
|
if (selectedId && !agents.has(selectedId)) selectedId = null;
|
|
@@ -3711,6 +3776,8 @@ function handleEvent(msg) {
|
|
|
3711
3776
|
teams.delete(msg.id);
|
|
3712
3777
|
} else if (msg.t === 'rules') {
|
|
3713
3778
|
rulesState = msg.rules;
|
|
3779
|
+
} else if (msg.t === 'repeats') {
|
|
3780
|
+
repeatsState = msg.repeats;
|
|
3714
3781
|
} else if (msg.t === 'built') {
|
|
3715
3782
|
builtReports = msg.reports;
|
|
3716
3783
|
builtUi.render();
|
package/public/style.css
CHANGED
|
@@ -581,6 +581,10 @@ pre.diff .meta { color: var(--muted); font-weight: 600; }
|
|
|
581
581
|
|
|
582
582
|
.bl-ai { margin: 4px 0 0; }
|
|
583
583
|
.bl-ai ul { margin: 2px 0 4px 18px; padding: 0; }
|
|
584
|
+
.bl-ap.held { color: var(--yellow); }
|
|
585
|
+
.bl-ap { overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
586
|
+
.bl-autopilot { margin-left: auto; flex-shrink: 0; font-size: 12px; font-weight: 500; display: inline-flex; align-items: center; gap: 4px; }
|
|
587
|
+
.bl-autopilot select { font-size: 12px; padding: 1px 4px; }
|
|
584
588
|
|
|
585
589
|
/* A CLI's sign-in link, above the terminal */
|
|
586
590
|
.signin-bar { padding: 8px 12px; background: var(--panel-2); border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text); }
|
package/src/autopilot.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Autopilot: a backlog item's finished work goes on to a pull request, and merged, without you,
|
|
2
|
+
// when the review scorecard says it's ready. Set per project in the backlog:
|
|
3
|
+
// off - today's flow: the item waits in review for Accept or Reopen.
|
|
4
|
+
// pr - ready work is committed, pushed and opened as a pull request (scorecard in its description).
|
|
5
|
+
// merge - and the pull request is merged once its checks pass and GitHub allows it (a branch that
|
|
6
|
+
// fell behind is brought up to date first; failing checks go back to the agent through Landing).
|
|
7
|
+
// Work the scorecard finds not ready for a concrete reason (failing CI, criteria not met, barely
|
|
8
|
+
// verified) goes back to the agent with those reasons, a couple of times at most. Anything else
|
|
9
|
+
// ("look at these first", a failed step, a closed pull request) waits for you in the inbox, with
|
|
10
|
+
// Ship it to go ahead anyway. An item whose pull request merges is done.
|
|
11
|
+
import { RECOMMENDATION } from './scorecard.js';
|
|
12
|
+
import { landState } from './tracker.js';
|
|
13
|
+
|
|
14
|
+
export const AUTOPILOT_LEVELS = ['off', 'pr', 'merge'];
|
|
15
|
+
export const AUTOPILOT_LABEL = { off: 'Off', pr: 'Open pull requests', merge: 'Merge' };
|
|
16
|
+
const RETURNS = 2; // times not-ready work goes back to the agent before it waits for you
|
|
17
|
+
|
|
18
|
+
const now = () => new Date().toISOString();
|
|
19
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
20
|
+
|
|
21
|
+
/** The message that sends not-ready work back to its agent. */
|
|
22
|
+
export function returnMessage(sc) {
|
|
23
|
+
const reasons = sc.reasons?.length ? sc.reasons.join('; ') : RECOMMENDATION[sc.recommendation];
|
|
24
|
+
const notMet = (sc.criteria ?? []).filter((c) => c.status === 'not_met').map((c) => `- ${c.criterion}${c.evidence ? ` (${c.evidence})` : ''}`);
|
|
25
|
+
return [`Autopilot: the review scorecard says this isn't ready yet: ${reasons}.`, notMet.length ? `Acceptance criteria not met:\n${notMet.join('\n')}` : '', 'Fix these and make sure the checks pass'].filter(Boolean).join('\n\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Autopilot {
|
|
29
|
+
constructor({ manager, store, backlog, review, landing, config = {}, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
30
|
+
Object.assign(this, { manager, store, backlog, review, landing, config, log });
|
|
31
|
+
this.returns = config.autopilot?.returns ?? RETURNS;
|
|
32
|
+
this.working = new Set(); // item ids with a step in progress
|
|
33
|
+
this.queued = null;
|
|
34
|
+
this.kick = () => this.schedule();
|
|
35
|
+
manager.on('change', this.kick);
|
|
36
|
+
manager.on('prs', this.kick);
|
|
37
|
+
backlog.on('change', this.kick);
|
|
38
|
+
this.timer = setInterval(this.kick, 30_000);
|
|
39
|
+
this.timer.unref?.();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
close() {
|
|
43
|
+
clearInterval(this.timer);
|
|
44
|
+
this.manager.off('change', this.kick);
|
|
45
|
+
this.manager.off('prs', this.kick);
|
|
46
|
+
this.backlog.off('change', this.kick);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
schedule() {
|
|
50
|
+
if (this.queued) return;
|
|
51
|
+
this.queued = setTimeout(() => {
|
|
52
|
+
this.queued = null;
|
|
53
|
+
this.tick().catch((e) => this.log(`autopilot: ${e.message}`));
|
|
54
|
+
}, 250);
|
|
55
|
+
this.queued.unref?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async tick() {
|
|
59
|
+
for (const it of this.backlog.items()) {
|
|
60
|
+
if (it.status !== 'review' || this.working.has(it.id)) continue;
|
|
61
|
+
this.working.add(it.id);
|
|
62
|
+
try {
|
|
63
|
+
await this.step(it);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
this.hold(it, e.message);
|
|
66
|
+
} finally {
|
|
67
|
+
this.working.delete(it.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Record where autopilot stands on an item (shown in the backlog), only when it changed. */
|
|
73
|
+
note(it, patch) {
|
|
74
|
+
const next = { ...it.autopilot, ...patch };
|
|
75
|
+
if (JSON.stringify(next) === JSON.stringify(it.autopilot ?? {})) return;
|
|
76
|
+
it.autopilot = { ...next, at: now() };
|
|
77
|
+
this.backlog.changed();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Stop and wait for the person, for this round of review. */
|
|
81
|
+
hold(it, why) {
|
|
82
|
+
this.log(`autopilot: holding "${it.title}": ${why}`);
|
|
83
|
+
this.note(it, { state: 'held', note: String(why).slice(0, 500), round: it.reviewAt });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One look at an item in review: what's the next thing autopilot can do for it? */
|
|
87
|
+
async step(it) {
|
|
88
|
+
const agent = it.agentId && this.store.get(it.agentId);
|
|
89
|
+
if (!agent) return;
|
|
90
|
+
const ap = it.autopilot ?? {};
|
|
91
|
+
const pr = ap.pr ? agent.prs?.[ap.pr] : null;
|
|
92
|
+
// Its pull request merged (by autopilot, by you or by GitHub's auto-merge): the item is done.
|
|
93
|
+
if (pr?.state === 'MERGED') {
|
|
94
|
+
this.note(it, { state: 'merged', note: null });
|
|
95
|
+
this.backlog.accept(it.id);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const level = this.backlog.autopilotFor(it.projectKey);
|
|
99
|
+
if (level === 'off' && !ap.ship) return;
|
|
100
|
+
if (ap.state === 'held' && ap.round === it.reviewAt) return;
|
|
101
|
+
if (this.manager.view(agent).runtime.busy) return;
|
|
102
|
+
if (pr?.state === 'CLOSED') return this.hold(it, 'Its pull request was closed without merging.');
|
|
103
|
+
// Shipped in this round of review: see it through. Reopened since: the new work is scored and pushed to it.
|
|
104
|
+
if (pr && ap.shipped === it.reviewAt) return level === 'merge' ? this.land(it, agent, pr) : undefined;
|
|
105
|
+
|
|
106
|
+
// Not shipped yet: the scorecard of the current changes decides (unless you said Ship it).
|
|
107
|
+
const changes = await this.review.get(agent.id);
|
|
108
|
+
if (!changes.files?.length) return this.hold(it, 'There are no changes to ship.');
|
|
109
|
+
if (ap.ship) return this.ship(it, agent, level);
|
|
110
|
+
const card = agent.docs?.scorecard;
|
|
111
|
+
if (card?.pending) return;
|
|
112
|
+
if (card?.diffHash !== changes.diffHash || !card?.data) {
|
|
113
|
+
if (ap.scored === changes.diffHash) return this.hold(it, card?.error ? `Couldn't score the changes: ${card.error}` : "Couldn't score the changes.");
|
|
114
|
+
this.note(it, { state: 'scoring', scored: changes.diffHash, note: null });
|
|
115
|
+
await this.manager.summarize(agent.id, 'scorecard').catch((e) => {
|
|
116
|
+
throw err(e.status, `Couldn't score the changes: ${e.message}`);
|
|
117
|
+
});
|
|
118
|
+
return this.schedule();
|
|
119
|
+
}
|
|
120
|
+
const sc = card.data;
|
|
121
|
+
if (sc.recommendation === 'ready') return this.ship(it, agent, level);
|
|
122
|
+
if (sc.recommendation === 'not_ready' && (ap.returns ?? 0) < this.returns) {
|
|
123
|
+
this.note(it, { state: 'returned', returns: (ap.returns ?? 0) + 1, note: sc.reasons.join('; ') });
|
|
124
|
+
this.backlog.reopen(it.id, { note: returnMessage(sc) });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const why = sc.recommendation === 'look' ? `The scorecard says to look first (${sc.overall ?? '?'}/100)${sc.look?.length ? `: ${sc.look.map((l) => l.file).join(', ')}` : ''}.` : `Still not ready after ${ap.returns ?? 0} tr${ap.returns === 1 ? 'y' : 'ies'}: ${sc.reasons.join('; ')}.`;
|
|
128
|
+
this.hold(it, why);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Commit what's left, push and open the pull request; at "merge", landing takes it from there. */
|
|
132
|
+
async ship(it, agent, level) {
|
|
133
|
+
this.note(it, { state: 'shipping', note: null });
|
|
134
|
+
const title = it.title.split('\n')[0].slice(0, 100);
|
|
135
|
+
try {
|
|
136
|
+
await this.review.commit(agent.id, { message: title });
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e.status !== 409 || !/nothing to commit/i.test(e.message)) throw e;
|
|
139
|
+
}
|
|
140
|
+
const { url } = await this.review.pr(agent.id, { title });
|
|
141
|
+
if (!url) throw err(502, 'Pushed, but no pull request link came back.');
|
|
142
|
+
this.log(`autopilot: opened ${url} for "${it.title}"`);
|
|
143
|
+
this.note(it, { state: level === 'merge' ? 'landing' : 'pr', pr: url, shipped: it.reviewAt, ship: false });
|
|
144
|
+
await this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** At "merge": merge a ready pull request, update one that fell behind, wait out the rest. */
|
|
148
|
+
async land(it, agent, pr) {
|
|
149
|
+
const state = landState(pr);
|
|
150
|
+
if (state === 'ready') {
|
|
151
|
+
this.note(it, { state: 'merging' });
|
|
152
|
+
await this.landing.merge(agent.id, { url: pr.url });
|
|
153
|
+
this.log(`autopilot: merged ${pr.url}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (state === 'behind' && it.autopilot.updated !== pr.head) {
|
|
157
|
+
this.note(it, { state: 'landing', updated: pr.head });
|
|
158
|
+
await this.landing.updateBranch(agent.id);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const fixes = Object.keys(agent.landing?.sent ?? {}).filter((k) => k.startsWith(`${pr.url}#`)).length;
|
|
162
|
+
if (state === 'fix' && fixes >= this.landing.settings.maxFixes) return this.hold(it, `Its pull request still needs fixes after ${fixes} tries (checks, review or conflicts).`);
|
|
163
|
+
if (state === 'draft') return this.hold(it, 'Its pull request is a draft.');
|
|
164
|
+
this.note(it, { state: 'landing' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** "Ship it": go ahead with an item autopilot held (or one in review with autopilot off). */
|
|
168
|
+
shipNow(id) {
|
|
169
|
+
const it = this.backlog.get(id);
|
|
170
|
+
if (it.status !== 'review') throw err(409, `Item is ${it.status}`);
|
|
171
|
+
if (!it.agentId || !this.store.get(it.agentId)) throw err(409, 'The item has no agent');
|
|
172
|
+
this.note(it, { state: 'queued', ship: true, round: null, scored: null, note: null });
|
|
173
|
+
this.schedule();
|
|
174
|
+
return it;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Held items, for the inbox. */
|
|
178
|
+
inbox() {
|
|
179
|
+
return this.backlog
|
|
180
|
+
.items()
|
|
181
|
+
.filter((it) => it.status === 'review' && it.autopilot?.state === 'held' && it.autopilot.round === it.reviewAt)
|
|
182
|
+
.map((it) => ({ kind: 'autopilot', key: `autopilot:${it.id}`, title: it.title, itemId: it.id, agentId: it.agentId, project: it.projectName, priority: it.priority, note: it.autopilot.note, at: it.autopilot.at }));
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/backlog.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { EventEmitter } from 'node:events';
|
|
5
5
|
import { parsePriority, projectOf } from './manager.js';
|
|
6
|
+
import { AUTOPILOT_LEVELS } from './autopilot.js';
|
|
6
7
|
import { dispatchPrompt, hash } from './judge.js';
|
|
7
8
|
|
|
8
9
|
const now = () => new Date().toISOString();
|
|
@@ -19,6 +20,7 @@ export class Backlog extends EventEmitter {
|
|
|
19
20
|
this.config = config;
|
|
20
21
|
store.data.backlog ??= { items: {}, limits: {}, autoRun: true };
|
|
21
22
|
this.data = store.data.backlog;
|
|
23
|
+
this.data.autopilot ??= {};
|
|
22
24
|
this.dispatching = false;
|
|
23
25
|
this.plan = null; // the AI judge's dispatch plan: { hash, data, pending }
|
|
24
26
|
|
|
@@ -38,6 +40,12 @@ export class Backlog extends EventEmitter {
|
|
|
38
40
|
return this.data.limits[projectKey] ?? this.defaultLimit();
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** Autopilot for a project's items (see autopilot.js): "off", "pr" or "merge". */
|
|
44
|
+
autopilotFor(projectKey) {
|
|
45
|
+
const level = this.data.autopilot[projectKey] ?? this.config.autopilot?.level;
|
|
46
|
+
return AUTOPILOT_LEVELS.includes(level) ? level : 'off';
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
items() {
|
|
42
50
|
return Object.values(this.data.items).sort(
|
|
43
51
|
(a, b) => a.priority - b.priority || a.order - b.order || a.createdAt.localeCompare(b.createdAt),
|
|
@@ -63,7 +71,7 @@ export class Backlog extends EventEmitter {
|
|
|
63
71
|
const projects = {};
|
|
64
72
|
for (const it of items) {
|
|
65
73
|
const shortcut = Object.values(this.store.data.projects ?? {}).find((x) => x.key === it.projectKey)?.shortcut ?? null;
|
|
66
|
-
const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), counts: {} });
|
|
74
|
+
const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), autopilot: this.autopilotFor(it.projectKey), counts: {} });
|
|
67
75
|
p.counts[it.status] = (p.counts[it.status] ?? 0) + 1;
|
|
68
76
|
}
|
|
69
77
|
return {
|
|
@@ -180,14 +188,18 @@ export class Backlog extends EventEmitter {
|
|
|
180
188
|
return { id };
|
|
181
189
|
}
|
|
182
190
|
|
|
183
|
-
settings({ autoRun, limits, project, limit } = {}) {
|
|
191
|
+
settings({ autoRun, limits, project, limit, autopilot } = {}) {
|
|
184
192
|
if (typeof autoRun === 'boolean') this.data.autoRun = autoRun;
|
|
185
193
|
if (limits && typeof limits === 'object') Object.assign(this.data.limits, limits);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ??
|
|
190
|
-
|
|
194
|
+
const key =
|
|
195
|
+
project === undefined
|
|
196
|
+
? null
|
|
197
|
+
: this.manager.resolveProject(project)?.key ?? this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ?? project;
|
|
198
|
+
if (key && autopilot !== undefined) {
|
|
199
|
+
if (!AUTOPILOT_LEVELS.includes(autopilot)) throw err(400, `autopilot must be one of ${AUTOPILOT_LEVELS.join(', ')}`);
|
|
200
|
+
this.data.autopilot[key] = autopilot;
|
|
201
|
+
}
|
|
202
|
+
if (key && limit !== undefined) {
|
|
191
203
|
const n = Number(limit);
|
|
192
204
|
if (!(n >= 0 && n <= 20)) throw err(400, 'limit must be 0-20');
|
|
193
205
|
this.data.limits[key] = n;
|
package/src/cli.js
CHANGED
|
@@ -890,6 +890,16 @@ export async function main(argv) {
|
|
|
890
890
|
if (!items.length) return console.log(dim('Nothing needs you right now.'));
|
|
891
891
|
const label = { prompt: red('needs input'), waiting: red('waiting'), question: cyan('question'), review: cyan('AI review'), stuck: yellow('stuck?'), conflict: yellow('overlap') };
|
|
892
892
|
for (const it of items) {
|
|
893
|
+
if (it.kind === 'repeat') {
|
|
894
|
+
console.log(`${cyan('make it a playbook?')} asked ${it.count} times: ${it.intent}`);
|
|
895
|
+
console.log(dim(` agentmgr playbook add "${it.name}" "${it.intent.replace(/"/g, '\\"')}" --projects ${it.projects.join(',') || '<project>'}`));
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (it.kind === 'autopilot') {
|
|
899
|
+
console.log(`${yellow('autopilot held')} ${bold(it.project ?? '')} ${it.title}`);
|
|
900
|
+
console.log(dim(` ${it.note ?? ''}\n agentmgr backlog ship ${it.itemId} · agentmgr changes ${it.agentId}`));
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
893
903
|
if (it.kind === 'rule') {
|
|
894
904
|
console.log(`${cyan('always allow?')} ${bold(repoLabel(it.repo))} ${it.title} ${dim(`(allowed ${it.n} times)`)}`);
|
|
895
905
|
console.log(dim(' agentmgr rules'));
|
|
@@ -1244,15 +1254,18 @@ export async function main(argv) {
|
|
|
1244
1254
|
const items = v.items.filter((i) => all || !['done', 'cancelled'].includes(i.status));
|
|
1245
1255
|
console.log(`${v.autoRun ? green('running') : yellow('paused')} ${dim(`${v.defaultLimit} per project by default, ${v.maxActive} overall`)}`);
|
|
1246
1256
|
if (!items.length) return console.log(dim('Backlog is empty. Add: agentmgr backlog add "ask" -r <repo>'));
|
|
1247
|
-
const
|
|
1257
|
+
const proj = Object.fromEntries(v.projects.map((p) => [p.key, p]));
|
|
1248
1258
|
let last = null;
|
|
1249
1259
|
for (const it of items) {
|
|
1250
1260
|
if (it.projectKey !== last) {
|
|
1251
|
-
|
|
1261
|
+
const p = proj[it.projectKey];
|
|
1262
|
+
const ap = p?.autopilot && p.autopilot !== 'off' ? `, autopilot: ${p.autopilot === 'merge' ? 'merge' : 'open PRs'}` : '';
|
|
1263
|
+
console.log(`\n${bold(it.projectName)} ${dim(`(${p?.limit ?? v.defaultLimit} at a time${ap})`)}`);
|
|
1252
1264
|
last = it.projectKey;
|
|
1253
1265
|
}
|
|
1254
1266
|
const agent = it.agent ? dim(` → ${it.agent.name} (${it.agent.id})${it.agent.briefStatus ? ` brief: ${it.agent.briefStatus}` : ''}`) : '';
|
|
1255
1267
|
console.log(` ${it.id} P${it.priority} ${(ST[it.status] ?? it.status).padEnd(18)} ${truncate(it.title, 60)}${agent}`);
|
|
1268
|
+
if (it.status === 'review' && it.autopilot?.state) console.log(dim(` autopilot: ${it.autopilot.state}${it.autopilot.pr ? ` ${it.autopilot.pr}` : ''}${it.autopilot.note ? `: ${it.autopilot.note}` : ''}`));
|
|
1256
1269
|
}
|
|
1257
1270
|
};
|
|
1258
1271
|
bl.command('ls')
|
|
@@ -1340,6 +1353,15 @@ export async function main(argv) {
|
|
|
1340
1353
|
await daemon();
|
|
1341
1354
|
printBacklog(await api('POST', '/backlog/settings', { project, limit: Number(n) }));
|
|
1342
1355
|
});
|
|
1356
|
+
bl.command('autopilot')
|
|
1357
|
+
.description("Take a project's finished items on by themselves when the review scorecard says ready: off, pr (open the pull request) or merge (and merge it once checks pass)")
|
|
1358
|
+
.argument('<project>', 'project name as shown in "backlog ls"')
|
|
1359
|
+
.argument('<level>', 'off, pr or merge')
|
|
1360
|
+
.action(async (project, level) => {
|
|
1361
|
+
await daemon();
|
|
1362
|
+
printBacklog(await api('POST', '/backlog/settings', { project, autopilot: level }));
|
|
1363
|
+
});
|
|
1364
|
+
itemCmd('ship', 'Go ahead with an item autopilot held: open its pull request (and merge, at the merge level)', 'ship');
|
|
1343
1365
|
bl.command('pause')
|
|
1344
1366
|
.description('Stop starting new items (active ones continue)')
|
|
1345
1367
|
.action(async () => {
|
package/src/manager.js
CHANGED
|
@@ -286,6 +286,7 @@ export class AgentManager extends EventEmitter {
|
|
|
286
286
|
this.outbox = new Map(); // agent id -> [message] waiting to be typed into its session
|
|
287
287
|
// The AI judge: rules decide when to look, a model decides what is going on (see judge.js).
|
|
288
288
|
this.rules = new Rules({ suggestAfter: config.rules?.suggestAfter });
|
|
289
|
+
this.inboxSources = [];
|
|
289
290
|
this.judge = new Judge({ config, call: (agent, prompt, { timeoutMs }) => this.oneShot(agent, prompt, { ...this.judgeModel(agent), timeoutMs }) });
|
|
290
291
|
this.stuckVerdicts = new Map(); // agent id -> Map(finding key -> { at, since, data })
|
|
291
292
|
this.signIns = new Map(); // agent id -> { url, port, callback, terminal, at, finished } from a CLI's sign-in link
|
|
@@ -2875,7 +2876,9 @@ export class AgentManager extends EventEmitter {
|
|
|
2875
2876
|
}
|
|
2876
2877
|
// Commands you keep allowing in a repo: offer to always allow them there.
|
|
2877
2878
|
for (const r of this.rules.view().suggestions) items.push({ kind: 'rule', key: `rule:${r.repo}:${r.rule}`, title: `Always allow ${r.label}?`, priority: 2, ...r });
|
|
2878
|
-
|
|
2879
|
+
// Others' items (autopilot's held backlog work).
|
|
2880
|
+
for (const source of this.inboxSources) items.push(...source());
|
|
2881
|
+
const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, autopilot: 2, stuck: 3, conflict: 4, repeat: 5 };
|
|
2879
2882
|
return items.sort((a, b) => order[a.kind] - order[b.kind] || a.priority - b.priority);
|
|
2880
2883
|
}
|
|
2881
2884
|
|
package/src/repeats.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Requests you keep making: the asks you type (new agents and backlog items, not ones a playbook,
|
|
2
|
+
// trigger or the assistant started) are remembered, the last 500 in ~/.agentmanager/asks.json. When
|
|
3
|
+
// 3 or more read alike (the same words once numbers, quoted names, URLs, issue keys and filler are
|
|
4
|
+
// set aside), the inbox offers to save them as a playbook: the words they share become the request,
|
|
5
|
+
// the part that changes a {{what}} placeholder, and the projects they ran in its projects.
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { paths } from './config.js';
|
|
11
|
+
|
|
12
|
+
export const SUGGEST_AFTER = 3;
|
|
13
|
+
const KEEP = 500;
|
|
14
|
+
const ALIKE = 0.5; // share of distinct words two asks must have in common
|
|
15
|
+
const STOP = new Set('a an and are as at be by do does for from get have in into is it its make me my of on or our please should so that the their them then this to up use we with you your'.split(' '));
|
|
16
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
17
|
+
|
|
18
|
+
/** The words that say what an ask is about, without the parts that change from one to the next. */
|
|
19
|
+
export function words(text) {
|
|
20
|
+
const w = String(text ?? '')
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
23
|
+
.replace(/(^|\s)["'`“‘][^"'`”’\n]{1,80}["'`”’](?=[\s.,;:!?)]|$)/g, ' ')
|
|
24
|
+
.replace(/\b[a-z][a-z0-9]+-\d+\b/g, ' ')
|
|
25
|
+
.replace(/#\d+|\b\d[\w.]*/g, ' ')
|
|
26
|
+
.split(/[^a-z0-9_]+/)
|
|
27
|
+
.filter((x) => x.length > 1 && !STOP.has(x));
|
|
28
|
+
return [...new Set(w)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function alike(a, b) {
|
|
32
|
+
if (!a.length || !b.length) return 0;
|
|
33
|
+
const sb = new Set(b);
|
|
34
|
+
const both = a.filter((x) => sb.has(x)).length;
|
|
35
|
+
return both / (a.length + b.length - both);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Groups of asks that read alike, newest first; each group's seed is its newest ask. */
|
|
39
|
+
export function groups(asks, { threshold = ALIKE } = {}) {
|
|
40
|
+
const out = [];
|
|
41
|
+
// Newest first; asks recorded together keep their order.
|
|
42
|
+
const order = asks.map((a, i) => [a, i]).sort(([x, i], [y, j]) => y.at.localeCompare(x.at) || j - i);
|
|
43
|
+
for (const [ask] of order) {
|
|
44
|
+
const w = words(ask.text);
|
|
45
|
+
if (w.length < 2) continue;
|
|
46
|
+
const g = out.find((x) => alike(x.words, w) >= threshold);
|
|
47
|
+
if (g) g.asks.push(ask);
|
|
48
|
+
else out.push({ words: w, asks: [ask] });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A playbook request from asks that read alike: the words they all start and end with, around a
|
|
55
|
+
* {{what}} for the part that differs ("Upgrade react to the latest" / "Upgrade vite to the latest" →
|
|
56
|
+
* "Upgrade {{what}} to the latest"). When they share too little, the newest ask as it was written.
|
|
57
|
+
*/
|
|
58
|
+
export function template(texts) {
|
|
59
|
+
const split = texts.map((t) => String(t).trim().split(/\s+/));
|
|
60
|
+
if (texts.every((t) => t.trim() === texts[0].trim())) return texts[0].trim();
|
|
61
|
+
const shortest = Math.min(...split.map((s) => s.length));
|
|
62
|
+
const same = (get) => split.every((s) => get(s) === get(split[0]));
|
|
63
|
+
let pre = 0;
|
|
64
|
+
while (pre < shortest && same((s) => s[pre].toLowerCase())) pre++;
|
|
65
|
+
let suf = 0;
|
|
66
|
+
while (suf < shortest - pre && same((s) => s[s.length - 1 - suf].toLowerCase())) suf++;
|
|
67
|
+
if (pre + suf < Math.max(2, Math.ceil(shortest / 2))) return texts[0].trim();
|
|
68
|
+
const head = split[0].slice(0, pre).join(' ');
|
|
69
|
+
const tail = split[0].slice(split[0].length - suf).join(' ');
|
|
70
|
+
return [head, '{{what}}', tail].filter(Boolean).join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A short name for the playbook, from its request. */
|
|
74
|
+
export function nameFor(intent) {
|
|
75
|
+
const plain = intent.replace(/\{\{\w+\}\}/g, ' ').replace(/\s+/g, ' ').trim();
|
|
76
|
+
const w = plain.split(' ').slice(0, 6);
|
|
77
|
+
while (w.length > 1 && STOP.has(w[w.length - 1].toLowerCase())) w.pop();
|
|
78
|
+
const name = w.join(' ');
|
|
79
|
+
return (name.charAt(0).toUpperCase() + name.slice(1)).replace(/[.,:;]+$/, '').slice(0, 60) || 'Repeated request';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class Repeats extends EventEmitter {
|
|
83
|
+
constructor({ file = path.join(paths.home, 'asks.json'), suggestAfter = SUGGEST_AFTER, enabled = true, playbooks = () => [], projects = () => ({}) } = {}) {
|
|
84
|
+
super();
|
|
85
|
+
Object.assign(this, { file, suggestAfter, enabled, playbooks, projects });
|
|
86
|
+
try {
|
|
87
|
+
this.data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
88
|
+
} catch {
|
|
89
|
+
this.data = {};
|
|
90
|
+
}
|
|
91
|
+
this.data.asks ??= [];
|
|
92
|
+
this.data.dismissed ??= [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
save() {
|
|
96
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
97
|
+
fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
|
|
98
|
+
this.changed();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
changed() {
|
|
102
|
+
this.emit('change', this.view());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Asks the person typed: [{ text, projectKey }]. */
|
|
106
|
+
record(asks) {
|
|
107
|
+
const at = new Date().toISOString();
|
|
108
|
+
const list = asks.map((a) => ({ text: String(a.text ?? '').trim().slice(0, 2000), projectKey: a.projectKey ?? null, at })).filter((a) => a.text);
|
|
109
|
+
if (!list.length) return;
|
|
110
|
+
this.data.asks = [...this.data.asks, ...list].slice(-KEEP);
|
|
111
|
+
this.save();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Requests asked for at least suggestAfter times, not already a playbook and not dismissed. */
|
|
115
|
+
suggestions() {
|
|
116
|
+
if (!this.enabled) return [];
|
|
117
|
+
const books = this.playbooks().map((p) => words(p.intent));
|
|
118
|
+
const projects = this.projects();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const g of groups(this.data.asks)) {
|
|
121
|
+
if (g.asks.length < this.suggestAfter) continue;
|
|
122
|
+
if (books.some((b) => alike(b, g.words) >= ALIKE)) continue;
|
|
123
|
+
if (this.data.dismissed.some((d) => alike(d, g.words) >= ALIKE)) continue;
|
|
124
|
+
const intent = template(g.asks.map((a) => a.text));
|
|
125
|
+
const names = [...new Set(g.asks.map((a) => projects[a.projectKey]).filter(Boolean))];
|
|
126
|
+
out.push({
|
|
127
|
+
key: crypto.createHash('sha1').update([...g.words].sort().join(' ')).digest('hex').slice(0, 12),
|
|
128
|
+
name: nameFor(intent),
|
|
129
|
+
intent,
|
|
130
|
+
projects: names,
|
|
131
|
+
count: g.asks.length,
|
|
132
|
+
examples: [...new Set(g.asks.map((a) => a.text))].slice(0, 3),
|
|
133
|
+
lastAt: g.asks[0].at,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** "Not now": stop offering this one (asks like it keep counting, but it isn't offered again). */
|
|
140
|
+
dismiss(key) {
|
|
141
|
+
const g = groups(this.data.asks).find((x) => crypto.createHash('sha1').update([...x.words].sort().join(' ')).digest('hex').slice(0, 12) === key);
|
|
142
|
+
if (!g) throw err(404, 'No such suggestion');
|
|
143
|
+
this.data.dismissed = [...this.data.dismissed, g.words].slice(-100);
|
|
144
|
+
this.save();
|
|
145
|
+
return this.view();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
view() {
|
|
149
|
+
return { suggestions: this.suggestions(), suggestAfter: this.suggestAfter };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
inbox() {
|
|
153
|
+
return this.suggestions().map((s) => ({ ...s, kind: 'repeat', key: `repeat:${s.key}`, title: `Save "${s.name}" as a playbook?`, priority: 3 }));
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/review.js
CHANGED
|
@@ -9,6 +9,18 @@ import { ghBin } from './tracker.js';
|
|
|
9
9
|
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
10
10
|
const MAX_COMMENTS = 100;
|
|
11
11
|
|
|
12
|
+
/** A git command that writes the index, retried while another git process (the app's own status refresh) holds its lock. */
|
|
13
|
+
async function locked(args, opts, tries = 5) {
|
|
14
|
+
for (let i = 1; ; i++) {
|
|
15
|
+
try {
|
|
16
|
+
return await git.run('git', args, opts);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (i >= tries || !/index\.lock|another git process/i.test(e.message)) throw e;
|
|
19
|
+
await new Promise((r) => setTimeout(r, 300 * i));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
/** "origin/main @ 1a2b3c4d5" -> { ref: 'origin/main', sha: '1a2b3c4d5' } */
|
|
13
25
|
export function parseBaseRef(baseRef) {
|
|
14
26
|
const m = /^(.*) @ ([0-9a-f]{4,40})$/.exec(baseRef ?? '');
|
|
@@ -190,8 +202,8 @@ export class Review {
|
|
|
190
202
|
const opts = { cwd: agent.workdir, timeout: 60_000 };
|
|
191
203
|
const status = await git.run('git', ['status', '--porcelain'], opts);
|
|
192
204
|
if (!status.trim()) throw err(409, 'Nothing to commit');
|
|
193
|
-
await
|
|
194
|
-
await
|
|
205
|
+
await locked(['add', '-A'], opts);
|
|
206
|
+
await locked(['commit', '-m', message], opts);
|
|
195
207
|
const sha = await git.run('git', ['rev-parse', '--short', 'HEAD'], opts);
|
|
196
208
|
this.manager.plugins.emit('agent.commit', { agent: this.manager.auditView(agent), message, sha });
|
|
197
209
|
this.manager.refresh(agent.id).catch(() => {});
|
package/src/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import express from 'express';
|
|
10
10
|
import { WebSocketServer } from 'ws';
|
|
11
11
|
import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.js';
|
|
12
|
-
import { AgentManager, MACHINE, shellNumber } from './manager.js';
|
|
12
|
+
import { AgentManager, MACHINE, projectOf, shellNumber } from './manager.js';
|
|
13
13
|
import { Backlog } from './backlog.js';
|
|
14
14
|
import { Races } from './races.js';
|
|
15
15
|
import { Review } from './review.js';
|
|
@@ -17,9 +17,11 @@ import { Teams } from './teams.js';
|
|
|
17
17
|
import { Assistant } from './assistant.js';
|
|
18
18
|
import { Cleanup } from './cleanup.js';
|
|
19
19
|
import { Landing } from './landing.js';
|
|
20
|
+
import { Autopilot } from './autopilot.js';
|
|
20
21
|
import { Budgets } from './budgets.js';
|
|
21
22
|
import { stats } from './stats.js';
|
|
22
23
|
import { Playbooks } from './playbooks.js';
|
|
24
|
+
import { Repeats } from './repeats.js';
|
|
23
25
|
import { Updates } from './update.js';
|
|
24
26
|
import { identity } from './identity.js';
|
|
25
27
|
import { Studio } from './studio.js';
|
|
@@ -90,6 +92,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
90
92
|
const teams = new Teams({ manager, store, config });
|
|
91
93
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
92
94
|
const landing = new Landing({ manager, store, config });
|
|
95
|
+
// Backlog work the scorecard calls ready goes on to a pull request (and merged) by itself.
|
|
96
|
+
const autopilot = new Autopilot({ manager, store, backlog, review, landing, config });
|
|
97
|
+
manager.inboxSources.push(() => autopilot.inbox());
|
|
93
98
|
const assistant = new Assistant({ manager, store, config });
|
|
94
99
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
95
100
|
const notifier = new Notifier({
|
|
@@ -104,6 +109,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
104
109
|
const triggers = new Triggers({ manager, store, config, notifier });
|
|
105
110
|
const budgets = new Budgets({ manager, store, config, notifier });
|
|
106
111
|
const playbooks = new Playbooks({ manager, store });
|
|
112
|
+
// Requests you keep typing, offered as a playbook.
|
|
113
|
+
const repeats = new Repeats({
|
|
114
|
+
suggestAfter: config.repeats?.suggestAfter,
|
|
115
|
+
enabled: config.repeats?.enabled !== false,
|
|
116
|
+
playbooks: () => playbooks.list(),
|
|
117
|
+
projects: () => Object.fromEntries(Object.values(store.data.projects ?? {}).map((p) => [p.key, p.shortcut || p.name])),
|
|
118
|
+
});
|
|
119
|
+
manager.inboxSources.push(() => repeats.inbox());
|
|
107
120
|
const memory = new Memory({ manager, config });
|
|
108
121
|
const updates = new Updates({ config });
|
|
109
122
|
let remoteServer = null;
|
|
@@ -230,6 +243,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
230
243
|
// Agents the assistant's own session starts: its limits apply, and they are marked as its.
|
|
231
244
|
const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
|
|
232
245
|
const created = manager.createMany(list, { ...opts, ...extra });
|
|
246
|
+
if (!extra.startedBy) repeats.record(created.map((a) => ({ text: a.intent, projectKey: projectOf(store.get(a.id) ?? a).key })));
|
|
233
247
|
if (extra.startedBy) {
|
|
234
248
|
for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
235
249
|
}
|
|
@@ -443,7 +457,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
443
457
|
'/backlog',
|
|
444
458
|
wrap((req) => {
|
|
445
459
|
const { items, item, ...opts } = req.body ?? {};
|
|
446
|
-
|
|
460
|
+
const added = backlog.add(Array.isArray(items) ? items : [item], opts);
|
|
461
|
+
repeats.record(added.map((it) => ({ text: it.title, projectKey: it.projectKey })));
|
|
462
|
+
return added;
|
|
447
463
|
}),
|
|
448
464
|
);
|
|
449
465
|
// Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
|
|
@@ -658,9 +674,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
658
674
|
};
|
|
659
675
|
}));
|
|
660
676
|
api.get('/playbooks', wrap(() => playbooks.list()));
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
677
|
+
// Saving a playbook can answer a repeated-request suggestion.
|
|
678
|
+
const withRepeats = (fn) => (req) => {
|
|
679
|
+
const out = fn(req);
|
|
680
|
+
repeats.changed();
|
|
681
|
+
return out;
|
|
682
|
+
};
|
|
683
|
+
api.post('/playbooks', wrap(withRepeats((req) => playbooks.create(req.body ?? {}))));
|
|
684
|
+
api.put('/playbooks/:id', wrap(withRepeats((req) => playbooks.update(req.params.id, req.body ?? {}))));
|
|
685
|
+
api.delete('/playbooks/:id', wrap(withRepeats((req) => playbooks.remove(req.params.id))));
|
|
686
|
+
api.get('/repeats', wrap(() => repeats.view()));
|
|
687
|
+
api.post('/repeats/dismiss', wrap((req) => repeats.dismiss(String(req.body?.key ?? ''))));
|
|
664
688
|
api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
|
|
665
689
|
api.get('/stats', wrap((req) => {
|
|
666
690
|
const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
|
|
@@ -713,6 +737,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
713
737
|
api.post('/backlog/:id/move', wrap((req) => backlog.move(req.params.id, req.body?.where)));
|
|
714
738
|
api.post('/backlog/:id/split', wrap((req) => backlog.split(req.params.id, req.body?.parts)));
|
|
715
739
|
api.post('/backlog/:id/keep-whole', wrap((req) => backlog.keepWhole(req.params.id)));
|
|
740
|
+
api.post('/backlog/:id/ship', wrap((req) => autopilot.shipNow(req.params.id)));
|
|
716
741
|
api.post(
|
|
717
742
|
'/shutdown',
|
|
718
743
|
wrap(() => {
|
|
@@ -923,6 +948,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
923
948
|
manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
|
|
924
949
|
reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
|
|
925
950
|
manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
|
|
951
|
+
repeats.on('change', (view) => broadcast({ t: 'repeats', repeats: view }));
|
|
926
952
|
manager.on('removed', (id) => {
|
|
927
953
|
broadcast({ t: 'removed', id });
|
|
928
954
|
live.closeAgent(id).catch(() => {});
|
|
@@ -959,7 +985,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
959
985
|
const url = new URL(req.url, 'http://x');
|
|
960
986
|
if (url.pathname === '/ws/events') {
|
|
961
987
|
eventClients.add(ws);
|
|
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() }));
|
|
988
|
+
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view(), repeats: repeats.view() }));
|
|
963
989
|
ws.on('close', () => eventClients.delete(ws));
|
|
964
990
|
return;
|
|
965
991
|
}
|
|
@@ -1040,6 +1066,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
1040
1066
|
closing = true;
|
|
1041
1067
|
cleanup.close();
|
|
1042
1068
|
landing.close();
|
|
1069
|
+
autopilot.close();
|
|
1043
1070
|
triggers.close();
|
|
1044
1071
|
budgets.close();
|
|
1045
1072
|
playbooks.close();
|