ai-agentmanager 1.1.0 → 1.2.1
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 +10 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/public/app.js +196 -14
- package/public/index.html +66 -0
- package/public/style.css +12 -1
- package/src/assistant.js +269 -0
- package/src/cleanup.js +1 -0
- package/src/cli.js +99 -1
- package/src/client.js +2 -1
- package/src/manager.js +70 -11
- package/src/notify.js +2 -1
- package/src/server.js +23 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.1 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
- **Edit an agent:** **Edit** on an agent changes its name, its request (and can tell the agent about the change), priority, permission mode and model (from its next launch). `agentmgr edit`.
|
|
6
|
+
- **Move an agent to another project:** **✎ Move** next to its project, the Edit dialog, or `agentmgr move <agent> <project>`. Pick any known project, a new project name, or its own repo again. Only where it is listed changes; its code stays in its own repo and branch.
|
|
7
|
+
|
|
8
|
+
## 1.2.0 (2026-10-30)
|
|
9
|
+
|
|
10
|
+
- **Assistant:** an always-on session that works on your behalf. Every few minutes it checks Jira (new assignments, comments and mentions), GitHub (review requests, failing checks on your PRs) and Slack with its own tools, and starts agents for new work, or passes the news to the agent already on it. The daemon enforces a daily cap, a cap on its running agents and one agent per Jira key; agents it starts are marked **by assistant**, and Slack alerts can announce them. **Assistant** in the top bar, or `agentmgr assistant`.
|
|
11
|
+
- **Project names** in the agent list are larger and bold, so they stand out from the agents under them.
|
|
12
|
+
|
|
3
13
|
## 1.1.0 (2026-10-30)
|
|
4
14
|
|
|
5
15
|
- **Teams:** give one problem to a team of agents in roles. An analyst writes the analysis and critics challenge it until they agree; an implementer builds it, a tester tests it, and a reviewer approves it or sends it back to the implementer. They hand off through files with a verdict line, which you can read in the team view. The team asks you only at the end (open a PR, keep the branch, or send it back) or when they can't agree within the round limits. **+ New agents → Team**, or `agentmgr team start`.
|
package/README.md
CHANGED
|
@@ -76,13 +76,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
|
|
|
76
76
|
|
|
77
77
|
```sh
|
|
78
78
|
npm install -g ai-agentmanager # the latest release
|
|
79
|
-
npm install -g ai-agentmanager@1.1
|
|
79
|
+
npm install -g ai-agentmanager@1.2.1 # or a specific one
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
Or from the repository, at a release tag:
|
|
83
83
|
|
|
84
84
|
```sh
|
|
85
|
-
git clone --branch v1.1
|
|
85
|
+
git clone --branch v1.2.1 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
|
|
86
86
|
cd ai-agentmanager-core
|
|
87
87
|
npm install
|
|
88
88
|
npm link # optional: puts `agentmgr` on your PATH
|
|
@@ -102,7 +102,7 @@ npm link
|
|
|
102
102
|
| Installed with | Update |
|
|
103
103
|
| --- | --- |
|
|
104
104
|
| npm | `npm install -g ai-agentmanager@latest` |
|
|
105
|
-
| A clone at a release tag | `git fetch --tags && git checkout v1.1
|
|
105
|
+
| A clone at a release tag | `git fetch --tags && git checkout v1.2.1 && npm install` (the tag you want) |
|
|
106
106
|
| A clone of `main` | `git pull && npm install` |
|
|
107
107
|
|
|
108
108
|
**Check your setup.** `agentmgr doctor` checks Node.js, Claude Code and its sign-in, git, `gh`, the data folder, the daemon's port and Chrome, and says how to fix anything missing.
|
|
@@ -439,6 +439,17 @@ The sidebar groups agents into projects, shown as a tree:
|
|
|
439
439
|
- **Ordering.** Projects needing you sort first. Collapsed projects stay collapsed.
|
|
440
440
|
- **CLI.** `agentmgr ls` groups by project too.
|
|
441
441
|
|
|
442
|
+
### Editing and moving agents
|
|
443
|
+
|
|
444
|
+
**Edit** on an agent changes its name, its request, priority, permission mode and model. Changing the request updates what AI Agent Manager records (and tracks any new Jira links); tick **Also tell the agent** to send it to the session as well. Permission mode and model apply from its next launch.
|
|
445
|
+
|
|
446
|
+
To list an agent under another project, use **✎ Move** next to its project (or the Edit dialog) and pick a known project, a new project name, or **Its own repo or folder** to move it back. Only the grouping changes: its code stays in its own worktree and branch.
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
agentmgr edit 3fa2b1 --name login-timeout --priority P1 --intent "Fix the login timeout on slow networks" --tell
|
|
450
|
+
agentmgr move 3fa2b1 @billing # "" moves it back to its own project
|
|
451
|
+
```
|
|
452
|
+
|
|
442
453
|
## Backlog
|
|
443
454
|
|
|
444
455
|
Queue asks instead of starting them. Use **+ Add** in the Backlog tab, **Queue in backlog** in the New agents dialog, or `agentmgr backlog add`. Each item is one ask, plus where to do it (repo or folder, base branch, project) and a priority from P1 (high) to P3 (low).
|
|
@@ -721,6 +732,32 @@ agentmgr leave # the hub forgets this Mac
|
|
|
721
732
|
|
|
722
733
|
**On Railway.** `hub/Dockerfile` builds the hub alone, with no node-pty and no Claude. Set `RAILWAY_DOCKERFILE_PATH=hub/Dockerfile`, `AGENTMGR_HUB_TOKEN`, `AGENTMGR_HUB_URL` (its https address) and, for Google sign-in, the three variables above; add a volume at `/data` so enrollments survive redeploys, and join with the `https://…up.railway.app` address.
|
|
723
734
|
|
|
735
|
+
## Assistant
|
|
736
|
+
|
|
737
|
+
An always-on Claude session that works on your behalf. **Assistant** in the top bar (or `agentmgr assistant start`) sets it up. Every 15 minutes the daemon asks it to check for new work, and it starts an agent for each new item, or passes the news to the agent already on it. You don't approve each one; it tells you in its activity log and, with Slack alerts on, in Slack.
|
|
738
|
+
|
|
739
|
+
| It watches | What it does |
|
|
740
|
+
|---|---|
|
|
741
|
+
| Jira issues newly assigned to you | Starts an agent in the matching project, with the issue link first so the agent is named after the issue (`OPS-7`) |
|
|
742
|
+
| New comments and mentions on your Jira issues | Passes the comment to the agent on that issue (`agentmgr tell`), or starts one if the comment asks for new work |
|
|
743
|
+
| GitHub pull requests waiting for your review | Starts a review agent that drafts the review for you to post |
|
|
744
|
+
| Failing checks on your open pull requests | Tells the agent on that PR to fix it, or starts one |
|
|
745
|
+
| Slack messages to you that ask for work | Starts an agent for clear requests in a known project |
|
|
746
|
+
|
|
747
|
+
It reads Jira and Slack with the tools your Claude Code already has (no credentials in agentmgr), so connect the Atlassian and Slack MCP servers at user scope, and sign in to `gh`. It runs in `~/.agentmanager/assistant` in `auto` permission mode, so it can run `agentmgr` and `gh` without asking. It never merges, approves, comments or sends messages as you.
|
|
748
|
+
|
|
749
|
+
The daemon enforces its limits, whatever the session decides: at most 10 new agents a day, at most 5 of its agents running at once, and never a second agent for the same Jira key. Agents it started are marked **by assistant**. It keeps what it has handled in `handled.json` so it never acts twice, and writes `activity.md`, shown in the dialog, with a **Needs you** list for anything it couldn't place. Use **Notes for the assistant** to say which Jira project goes to which repo. Clean up never closes the assistant.
|
|
750
|
+
|
|
751
|
+
```bash
|
|
752
|
+
agentmgr assistant start # set it up, or turn it back on
|
|
753
|
+
agentmgr assistant # on or paused, last check, agents it started (--activity for its log)
|
|
754
|
+
agentmgr assistant check # check now
|
|
755
|
+
agentmgr assistant set --every 10 --sources jira,comments,reviews,ci --max-per-day 5 --notes "OPS is the @cc repo"
|
|
756
|
+
agentmgr assistant pause # stop checking and close the session
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
Settings live in `config.json` under `"assistant"`. A running assistant reads new watch lists and notes after **Pause** and **Turn on**.
|
|
760
|
+
|
|
724
761
|
## Cleaning up old agents
|
|
725
762
|
|
|
726
763
|
Agents you no longer use keep a session open and a worktree on disk. **Clean up** (in the top bar, or `agentmgr cleanup`) closes agents with no activity for a while and deletes agents that stayed closed long after that. Pick a strategy:
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -200,7 +200,7 @@ function explainBox(a) {
|
|
|
200
200
|
const REVIEW_VERDICT = { complete: '✓ Complete', partial: '≈ Partly done', not_done: '✗ Not done' };
|
|
201
201
|
/** The AI judge's review of the work against the intent and brief: button, and the last result once handled. */
|
|
202
202
|
function reviewBox(a) {
|
|
203
|
-
if (a
|
|
203
|
+
if (noRepo(a)) return { button: '', box: '' };
|
|
204
204
|
const r = a.aiReview;
|
|
205
205
|
const button = `<button class="small" data-act="review" ${r?.pending ? 'disabled' : ''}
|
|
206
206
|
title="Check the work against the intent and brief: what is met, missing or risky. A separate one-off model call; runs by itself when the agent says it is done">${r?.pending ? 'Reviewing…' : 'Review with AI'}</button>`;
|
|
@@ -429,7 +429,7 @@ function openPlan(agentId) {
|
|
|
429
429
|
const PLAN_LABEL = { writing: 'being written', review: 'waiting for your review', changes: 'changes requested', approved: 'approved' };
|
|
430
430
|
/** The plan-review row in the brief card: status, open, approve; or the switch to turn review on. */
|
|
431
431
|
function planRow(a) {
|
|
432
|
-
if (a
|
|
432
|
+
if (noRepo(a)) return '';
|
|
433
433
|
const p = a.planReview;
|
|
434
434
|
if (!p) {
|
|
435
435
|
return a.status === 'done' ? '' : `<div class="plan-row"><button class="small ghost" data-act="plan-on" title="The agent writes an implementation plan and waits for your approval before changing anything">Review its plan first</button></div>`;
|
|
@@ -489,7 +489,7 @@ function resultsCard(a) {
|
|
|
489
489
|
}
|
|
490
490
|
|
|
491
491
|
function briefPanel(a) {
|
|
492
|
-
if (a
|
|
492
|
+
if (noRepo(a)) return '';
|
|
493
493
|
const b = a.brief;
|
|
494
494
|
const ex = explainBox(a);
|
|
495
495
|
const rv = reviewBox(a);
|
|
@@ -598,12 +598,14 @@ function saveCollapsed() {
|
|
|
598
598
|
const prioChip = (p, { always = false } = {}) =>
|
|
599
599
|
p === 2 && !always ? '' : `<span class="prio p${p}" title="Priority P${p}">P${p}</span>`;
|
|
600
600
|
|
|
601
|
+
const noRepo = (a) => a.kind === 'general' || a.kind === 'assistant';
|
|
602
|
+
|
|
601
603
|
function agentItem(a) {
|
|
602
604
|
const st = statusOf(a);
|
|
603
605
|
const idle = !isActive(a);
|
|
604
606
|
return `<li data-id="${a.id}" class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
|
|
605
607
|
<div class="line1">${prioChip(a.priority ?? 2)}${a.results?.outcome === 'blocked' ? '<span class="outcome-mark" title="Blocked">⛔</span>' : a.results?.outcome === 'done' ? '<span class="outcome-mark" title="Done">✅</span>' : ''}<span class="name">${esc(a.name)}</span>${flags(a)}<span class="pill ${st}">${pillText(st)}</span></div>
|
|
606
|
-
<div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a.intent || 'General session')}</div>
|
|
608
|
+
<div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">🤖 by assistant</span> ' : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a.intent || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
|
|
607
609
|
${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
|
|
608
610
|
<div class="meta">
|
|
609
611
|
<span class="mono">${a.id}</span>
|
|
@@ -652,7 +654,7 @@ function projectGroups(list) {
|
|
|
652
654
|
const same = byName ? byName.get(g.name).length : out.filter((x) => x.name === g.name).length;
|
|
653
655
|
g.label = same > 1 && g.full ? g.full : g.name;
|
|
654
656
|
}
|
|
655
|
-
const special = (g) => (g.key === 'general' ? 1 : g.key === 'other' ? 2 : 0);
|
|
657
|
+
const special = (g) => (g.key === 'assistant' ? -1 : g.key === 'general' ? 1 : g.key === 'other' ? 2 : 0);
|
|
656
658
|
return out.sort(
|
|
657
659
|
(a, b) =>
|
|
658
660
|
special(a) - special(b) ||
|
|
@@ -1119,7 +1121,7 @@ for (const root of [$('#inbox-view'), $('#detail-body')]) {
|
|
|
1119
1121
|
// ------------------------------------------------------------------ checkpoints and forks
|
|
1120
1122
|
|
|
1121
1123
|
function checkpointsCard(a) {
|
|
1122
|
-
if (a
|
|
1124
|
+
if (noRepo(a) || !a.git?.branch) return '';
|
|
1123
1125
|
const cps = [...(a.checkpoints ?? [])].reverse();
|
|
1124
1126
|
const forks = [...agents.values()].filter((x) => x.forkOf?.agentId === a.id);
|
|
1125
1127
|
return `<div class="card">
|
|
@@ -1545,7 +1547,7 @@ function renderDetail() {
|
|
|
1545
1547
|
${[0, 1, 2, 3].map((p) => `<option value="${p}" ${p === (a.priority ?? 2) ? 'selected' : ''}>P${p}</option>`).join('')}
|
|
1546
1548
|
</select>
|
|
1547
1549
|
<span class="d-name">${esc(a.name)}</span> <span class="pill ${st}">${pillText(st)}</span>${a.kind === 'general' ? ' <span class="tag">general</span>' : ''}</h2>
|
|
1548
|
-
<p class="intent">${esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : ''))}</p>
|
|
1550
|
+
<p class="intent">${esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : a.kind === 'assistant' ? 'Your assistant: it checks for new work every few minutes and starts agents for it. Settings and activity: Assistant in the top bar.' : ''))}</p>
|
|
1549
1551
|
</div>
|
|
1550
1552
|
<div class="d-actions">
|
|
1551
1553
|
${a.status === 'error' && !a.workdir
|
|
@@ -1562,6 +1564,7 @@ function renderDetail() {
|
|
|
1562
1564
|
<button data-act="fresh" ${running || a.status === 'preparing' ? 'disabled' : ''} title="Start over with a new session ID (old ones are kept)">New session</button>
|
|
1563
1565
|
<button data-act="shell" ${a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="A separate terminal in this agent's folder for running commands by hand">Open shell</button>
|
|
1564
1566
|
<button data-act="fork" ${a.workdir && a.git?.branch ? '' : 'disabled'} title="A new agent with this one's code and conversation, in its own worktree, to try another approach">Fork</button>
|
|
1567
|
+
<button data-act="edit" title="Change its name, request, project, priority, permission mode or model">Edit</button>
|
|
1565
1568
|
<button data-act="refresh" title="Re-scan git, PRs and transcripts">Refresh</button>
|
|
1566
1569
|
<button data-act="keep" class="${a.keep ? 'on' : ''}" title="${a.keep ? 'Kept: clean up never closes or deletes it. Click to let clean up handle it again' : 'Clean up never closes or deletes an agent marked Keep'}">${a.keep ? '📌 Kept' : 'Keep'}</button>
|
|
1567
1570
|
<button class="danger" data-act="delete">Delete</button>
|
|
@@ -1626,7 +1629,7 @@ function renderDetail() {
|
|
|
1626
1629
|
<h3>Workspace</h3>
|
|
1627
1630
|
<dl class="kv">
|
|
1628
1631
|
<dt>Folder</dt><dd><code>${esc(a.workdir ?? (a.status === 'preparing' ? 'preparing…' : '—'))}</code>${a.workdir ? copyBtn(a.workdir) : ''}</dd>
|
|
1629
|
-
<dt>Project</dt><dd>${esc(a.projectInfo?.full || a.projectInfo?.name || '—')} <button class="link" data-act="project" title="
|
|
1632
|
+
<dt>Project</dt><dd>${esc(a.projectInfo?.full || a.projectInfo?.name || '—')} ${noRepo(a) ? '' : '<button class="link" data-act="project" title="Move this agent under another project">✎ Move</button>'}</dd>
|
|
1630
1633
|
<dt>Repo</dt><dd>${a.repo ? `<code>${esc(a.repo)}</code>` : '—'}</dd>
|
|
1631
1634
|
<dt>Branch</dt><dd>${a.git?.branch ? `<code>${esc(a.git.branch)}</code>` : '—'}${a.git?.dirtyFiles ? ` <span class="tag">${a.git.dirtyFiles} changed</span>` : ''}</dd>
|
|
1632
1635
|
<dt>Head</dt><dd>${a.git?.head ? `<code>${esc(a.git.head.sha)}</code> ${esc(a.git.head.subject)}` : '—'}</dd>
|
|
@@ -2875,12 +2878,10 @@ $('#detail-body').addEventListener('click', async (e) => {
|
|
|
2875
2878
|
term.open(a.id);
|
|
2876
2879
|
}, 'Moved into the repository');
|
|
2877
2880
|
break;
|
|
2878
|
-
case 'project':
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
break;
|
|
2883
|
-
}
|
|
2881
|
+
case 'project':
|
|
2882
|
+
return openEdit(a, 'project');
|
|
2883
|
+
case 'edit':
|
|
2884
|
+
return openEdit(a);
|
|
2884
2885
|
case 'plan-open':
|
|
2885
2886
|
return openPlan(a.id);
|
|
2886
2887
|
case 'plan-on':
|
|
@@ -3503,6 +3504,187 @@ $('#cleanup-plan').addEventListener('click', (e) => {
|
|
|
3503
3504
|
select(go.dataset.cleanupGoto);
|
|
3504
3505
|
});
|
|
3505
3506
|
|
|
3507
|
+
// ------------------------------------------------------------------ edit an agent
|
|
3508
|
+
|
|
3509
|
+
let editing = null;
|
|
3510
|
+
let enginesCache = null;
|
|
3511
|
+
// The project picked in the dialog: "" while it is listed under its own repo or folder.
|
|
3512
|
+
const editProject = (a) => (a.projectMove || a.project ? (a.projectInfo?.shortcut ?? '') : '');
|
|
3513
|
+
|
|
3514
|
+
async function openEdit(a, focus) {
|
|
3515
|
+
editing = a;
|
|
3516
|
+
const f = $('#edit-form');
|
|
3517
|
+
f.name.value = a.name;
|
|
3518
|
+
f.intent.value = a.intent ?? '';
|
|
3519
|
+
f.tell.checked = false;
|
|
3520
|
+
f.priority.value = String(a.priority ?? 2);
|
|
3521
|
+
f.model.value = a.model ?? '';
|
|
3522
|
+
$('#edit-project-row').hidden = noRepo(a);
|
|
3523
|
+
$('#edit-tell-row').hidden = !a.intent;
|
|
3524
|
+
// Its own repo or folder, every known project, or a new name.
|
|
3525
|
+
f.project.innerHTML = [
|
|
3526
|
+
'<option value="">Its own repo or folder</option>',
|
|
3527
|
+
...projectsState.map((p) => `<option value="${esc(p.shortcut)}">${esc(p.name)} (@${esc(p.shortcut)})</option>`),
|
|
3528
|
+
'<option value="__new">New project name…</option>',
|
|
3529
|
+
].join('');
|
|
3530
|
+
f.project.value = editProject(a);
|
|
3531
|
+
$('#edit-newproject-row').hidden = true;
|
|
3532
|
+
f.newProject.value = '';
|
|
3533
|
+
enginesCache ??= await api('GET', '/engines').catch(() => []);
|
|
3534
|
+
const modes = enginesCache.find((e) => e.id === (a.engine ?? 'claude'))?.permissionModes ?? [];
|
|
3535
|
+
f.permissionMode.innerHTML = ['<option value="">As configured</option>', ...modes.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`)].join('');
|
|
3536
|
+
f.permissionMode.value = a.permissionMode ?? '';
|
|
3537
|
+
$('#edit-error').hidden = true;
|
|
3538
|
+
$('#edit-dialog').showModal();
|
|
3539
|
+
(focus === 'project' ? f.project : f.name).focus();
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
$('#edit-form').project.addEventListener('change', (e) => {
|
|
3543
|
+
$('#edit-newproject-row').hidden = e.target.value !== '__new';
|
|
3544
|
+
if (e.target.value === '__new') $('#edit-form').newProject.focus();
|
|
3545
|
+
});
|
|
3546
|
+
$('#edit-close').onclick = () => $('#edit-dialog').close();
|
|
3547
|
+
$('#edit-form').addEventListener('submit', async (e) => {
|
|
3548
|
+
e.preventDefault();
|
|
3549
|
+
const a = editing;
|
|
3550
|
+
const f = e.target;
|
|
3551
|
+
const patch = {};
|
|
3552
|
+
if (f.name.value.trim() !== a.name) patch.name = f.name.value.trim();
|
|
3553
|
+
if (f.intent.value.trim() !== (a.intent ?? '')) patch.intent = f.intent.value.trim();
|
|
3554
|
+
if (Number(f.priority.value) !== (a.priority ?? 2)) patch.priority = Number(f.priority.value);
|
|
3555
|
+
if ((f.permissionMode.value || null) !== (a.permissionMode ?? null)) patch.permissionMode = f.permissionMode.value || null;
|
|
3556
|
+
if ((f.model.value.trim() || null) !== (a.model ?? null)) patch.model = f.model.value.trim() || null;
|
|
3557
|
+
if (!noRepo(a)) {
|
|
3558
|
+
const target = f.project.value === '__new' ? f.newProject.value.trim() : f.project.value;
|
|
3559
|
+
const was = editProject(a);
|
|
3560
|
+
if (f.project.value === '__new' && !target) {
|
|
3561
|
+
$('#edit-error').textContent = 'Give the new project a name';
|
|
3562
|
+
$('#edit-error').hidden = false;
|
|
3563
|
+
return;
|
|
3564
|
+
}
|
|
3565
|
+
if (target !== was) patch.project = target;
|
|
3566
|
+
}
|
|
3567
|
+
try {
|
|
3568
|
+
if (Object.keys(patch).length) upsert(await api('PATCH', `/agents/${a.id}`, patch));
|
|
3569
|
+
if (patch.intent && f.tell.checked) await api('POST', `/agents/${a.id}/tell`, { text: `The request changed. It is now:\n${patch.intent}` });
|
|
3570
|
+
$('#edit-dialog').close();
|
|
3571
|
+
const relaunch = 'permissionMode' in patch || 'model' in patch;
|
|
3572
|
+
toast(!Object.keys(patch).length ? 'Nothing changed' : `Saved${patch.project !== undefined ? '; moved' : ''}${relaunch ? '. Permission mode and model apply from its next launch' : ''}`);
|
|
3573
|
+
} catch (err) {
|
|
3574
|
+
$('#edit-error').textContent = err.message;
|
|
3575
|
+
$('#edit-error').hidden = false;
|
|
3576
|
+
}
|
|
3577
|
+
});
|
|
3578
|
+
|
|
3579
|
+
// ------------------------------------------------------------------ assistant
|
|
3580
|
+
|
|
3581
|
+
const ASSISTANT_SOURCES = ['jira', 'comments', 'reviews', 'ci', 'slack'];
|
|
3582
|
+
let assistantState = null;
|
|
3583
|
+
|
|
3584
|
+
function renderAssistantDot() {
|
|
3585
|
+
const a = assistantState;
|
|
3586
|
+
const dot = $('#assistant-dot');
|
|
3587
|
+
dot.hidden = !a?.agentId;
|
|
3588
|
+
if (a) {
|
|
3589
|
+
dot.className = `assistant-dot ${a.on ? (a.busy ? 'busy' : 'on') : 'off'}`;
|
|
3590
|
+
dot.title = a.on ? (a.busy ? 'Checking now' : 'On') : 'Paused';
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
async function loadAssistant({ fill = true } = {}) {
|
|
3595
|
+
const a = await api('GET', '/assistant');
|
|
3596
|
+
assistantState = a;
|
|
3597
|
+
renderAssistantDot();
|
|
3598
|
+
const f = $('#assistant-form');
|
|
3599
|
+
if (fill) {
|
|
3600
|
+
for (const k of ASSISTANT_SOURCES) f[`src_${k}`].checked = a.sources.includes(k);
|
|
3601
|
+
f.everyMin.value = a.everyMin;
|
|
3602
|
+
f.maxPerDay.value = a.maxPerDay;
|
|
3603
|
+
f.maxRunning.value = a.maxRunning;
|
|
3604
|
+
f.notes.value = a.notes ?? '';
|
|
3605
|
+
}
|
|
3606
|
+
const state = !a.agentId ? 'Not set up yet. Start it to create its session.' : a.on ? (a.busy ? 'On: checking now.' : 'On.') : 'Paused: not checking.';
|
|
3607
|
+
$('#assistant-status').innerHTML = `<p><b>${esc(state)}</b>
|
|
3608
|
+
${a.lastCheckAt ? ` Last check ${ago(a.lastCheckAt)}.` : ''}${a.nextAt && a.on ? ` Next ${new Date(a.nextAt) <= new Date() ? 'when it is free' : `at ${new Date(a.nextAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}.` : ''}
|
|
3609
|
+
${a.agentId ? ` Started ${a.startedToday} agent${a.startedToday === 1 ? '' : 's'} today, ${a.startedTotal} in all.` : ''}</p>
|
|
3610
|
+
${a.started.length ? `<ul class="assistant-started">${a.started.slice(0, 8).map((x) => `<li><a href="#${x.id}" data-assistant-goto="${x.id}">${esc(x.name)}</a> <span class="hint">${ago(x.createdAt)} · ${esc(x.intent.slice(0, 90))}</span></li>`).join('')}</ul>` : ''}`;
|
|
3611
|
+
$('#assistant-activity').innerHTML = a.activity ? `<h4>Activity</h4><div class="md">${md(a.activity.split(/\n(?=## )/).reverse().slice(0, 10).join('\n').replace(/([^\n])\n(?=[^\n#\-*])/g, '$1 \n'))}</div>` : a.agentId ? '<p class="hint">No activity yet. Its first check writes here.</p>' : '';
|
|
3612
|
+
$('#assistant-start').hidden = Boolean(a.on);
|
|
3613
|
+
$('#assistant-start').textContent = a.agentId ? 'Turn on' : 'Start';
|
|
3614
|
+
$('#assistant-pause').hidden = !a.on;
|
|
3615
|
+
$('#assistant-check').hidden = !a.on;
|
|
3616
|
+
$('#assistant-open').hidden = !a.agentId;
|
|
3617
|
+
$('#assistant-error').hidden = true;
|
|
3618
|
+
return a;
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
const assistantPatch = (f) => ({
|
|
3622
|
+
sources: ASSISTANT_SOURCES.filter((k) => f[`src_${k}`].checked),
|
|
3623
|
+
everyMin: Number(f.everyMin.value),
|
|
3624
|
+
maxPerDay: Number(f.maxPerDay.value),
|
|
3625
|
+
maxRunning: Number(f.maxRunning.value),
|
|
3626
|
+
notes: f.notes.value,
|
|
3627
|
+
});
|
|
3628
|
+
const assistantError = (err) => {
|
|
3629
|
+
$('#assistant-error').textContent = err.message;
|
|
3630
|
+
$('#assistant-error').hidden = false;
|
|
3631
|
+
};
|
|
3632
|
+
$('#assistant-btn').onclick = async () => {
|
|
3633
|
+
$('#assistant-dialog').showModal();
|
|
3634
|
+
await loadAssistant().catch(assistantError);
|
|
3635
|
+
};
|
|
3636
|
+
$('#assistant-close').onclick = () => $('#assistant-dialog').close();
|
|
3637
|
+
$('#assistant-form').addEventListener('submit', async (e) => {
|
|
3638
|
+
e.preventDefault();
|
|
3639
|
+
try {
|
|
3640
|
+
await api('PUT', '/assistant', assistantPatch(e.target));
|
|
3641
|
+
toast(assistantState?.running ? 'Saved. A running assistant uses the new watch list and notes after Pause and Turn on.' : 'Assistant settings saved');
|
|
3642
|
+
await loadAssistant();
|
|
3643
|
+
} catch (err) {
|
|
3644
|
+
assistantError(err);
|
|
3645
|
+
}
|
|
3646
|
+
});
|
|
3647
|
+
$('#assistant-start').onclick = async () => {
|
|
3648
|
+
try {
|
|
3649
|
+
if (!assistantState?.agentId) await api('PUT', '/assistant', assistantPatch($('#assistant-form')));
|
|
3650
|
+
await api('POST', '/assistant/start');
|
|
3651
|
+
toast('Assistant on: its first check starts in a moment');
|
|
3652
|
+
await loadAssistant({ fill: false });
|
|
3653
|
+
} catch (err) {
|
|
3654
|
+
assistantError(err);
|
|
3655
|
+
}
|
|
3656
|
+
};
|
|
3657
|
+
$('#assistant-pause').onclick = async () => {
|
|
3658
|
+
try {
|
|
3659
|
+
await api('POST', '/assistant/pause');
|
|
3660
|
+
await loadAssistant({ fill: false });
|
|
3661
|
+
} catch (err) {
|
|
3662
|
+
assistantError(err);
|
|
3663
|
+
}
|
|
3664
|
+
};
|
|
3665
|
+
$('#assistant-check').onclick = async () => {
|
|
3666
|
+
try {
|
|
3667
|
+
await api('POST', '/assistant/check');
|
|
3668
|
+
toast('Asked the assistant to check now');
|
|
3669
|
+
await loadAssistant({ fill: false });
|
|
3670
|
+
} catch (err) {
|
|
3671
|
+
assistantError(err);
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
3674
|
+
$('#assistant-open').onclick = () => {
|
|
3675
|
+
$('#assistant-dialog').close();
|
|
3676
|
+
if (assistantState?.agentId) select(assistantState.agentId);
|
|
3677
|
+
};
|
|
3678
|
+
$('#assistant-dialog').addEventListener('click', (e) => {
|
|
3679
|
+
const go = e.target.closest('[data-assistant-goto]');
|
|
3680
|
+
if (!go) return;
|
|
3681
|
+
e.preventDefault();
|
|
3682
|
+
$('#assistant-dialog').close();
|
|
3683
|
+
select(go.dataset.assistantGoto);
|
|
3684
|
+
});
|
|
3685
|
+
loadAssistant({ fill: false }).catch(() => {});
|
|
3686
|
+
setInterval(() => loadAssistant({ fill: false }).catch(() => {}), 60_000);
|
|
3687
|
+
|
|
3506
3688
|
// ------------------------------------------------------------------ Slack alerts (from this computer)
|
|
3507
3689
|
|
|
3508
3690
|
async function loadAlerts() {
|
package/public/index.html
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
</div>
|
|
23
23
|
<div class="top-actions">
|
|
24
24
|
<input id="filter" type="search" placeholder="Filter agents" aria-label="Filter agents" />
|
|
25
|
+
<button id="assistant-btn" title="An always-on session that finds new work and starts agents for it">Assistant <span id="assistant-dot" class="assistant-dot" hidden></span></button>
|
|
25
26
|
<button id="cleanup-btn" title="Close agents idle for a while, and delete old closed ones">Clean up</button>
|
|
26
27
|
<button id="alerts-btn" class="local-only" title="Slack alerts when an agent needs you">Alerts</button>
|
|
27
28
|
<button id="remote-btn" class="local-only" title="Open AI Agent Manager from your phone">Remote access</button>
|
|
@@ -383,6 +384,7 @@
|
|
|
383
384
|
<label class="check"><input type="checkbox" name="ev_stuck" /> seems stuck (same failure, undoing its edits, no progress)</label>
|
|
384
385
|
<label class="check"><input type="checkbox" name="ev_conflict" /> changes the same files as another agent</label>
|
|
385
386
|
<label class="check"><input type="checkbox" name="ev_question" /> asks a question in the inbox</label>
|
|
387
|
+
<label class="check"><input type="checkbox" name="ev_assistant" /> is started by the assistant for new work</label>
|
|
386
388
|
<label class="check"><input type="checkbox" name="ev_waiting" /> finished its turn and is idle after <input name="waitingDelaySec" type="number" min="0" class="num" />s</label>
|
|
387
389
|
<label class="check"><input type="checkbox" name="skipWhenWatching" /> not while I have its terminal open</label>
|
|
388
390
|
<label class="check"><input type="checkbox" name="includeIntent" /> include the intent text in the message</label>
|
|
@@ -399,6 +401,70 @@
|
|
|
399
401
|
</form>
|
|
400
402
|
</dialog>
|
|
401
403
|
|
|
404
|
+
<dialog id="edit-dialog">
|
|
405
|
+
<form id="edit-form" method="dialog" class="alerts-body">
|
|
406
|
+
<h2>Edit agent</h2>
|
|
407
|
+
<label>Name <input name="name" required maxlength="80" /></label>
|
|
408
|
+
<label>Request <span class="hint">what the agent was asked to do</span>
|
|
409
|
+
<textarea name="intent" rows="4"></textarea>
|
|
410
|
+
</label>
|
|
411
|
+
<label class="check" id="edit-tell-row"><input type="checkbox" name="tell" /> Also tell the agent about the changed request</label>
|
|
412
|
+
<label id="edit-project-row">Project
|
|
413
|
+
<select name="project"></select>
|
|
414
|
+
</label>
|
|
415
|
+
<label id="edit-newproject-row" hidden>New project name <input name="newProject" maxlength="60" /></label>
|
|
416
|
+
<div class="row three">
|
|
417
|
+
<label>Priority
|
|
418
|
+
<select name="priority"><option value="0">P0 urgent</option><option value="1">P1 high</option><option value="2">P2 normal</option><option value="3">P3 low</option></select>
|
|
419
|
+
</label>
|
|
420
|
+
<label>Permission mode <select name="permissionMode"></select></label>
|
|
421
|
+
<label>Model <input name="model" placeholder="as configured" /></label>
|
|
422
|
+
</div>
|
|
423
|
+
<p class="hint" id="edit-hint">Moving changes only where the agent is listed; its code stays in its own repo and branch. Permission mode and model apply from its next launch.</p>
|
|
424
|
+
<p id="edit-error" class="error" hidden></p>
|
|
425
|
+
<div class="dialog-actions">
|
|
426
|
+
<span class="spacer"></span>
|
|
427
|
+
<button type="button" id="edit-close" class="ghost">Cancel</button>
|
|
428
|
+
<button class="primary">Save</button>
|
|
429
|
+
</div>
|
|
430
|
+
</form>
|
|
431
|
+
</dialog>
|
|
432
|
+
|
|
433
|
+
<dialog id="assistant-dialog">
|
|
434
|
+
<form id="assistant-form" method="dialog" class="alerts-body">
|
|
435
|
+
<h2>Assistant</h2>
|
|
436
|
+
<p class="hint">An always-on Claude session that works on your behalf. Every few minutes it checks for new work with its own Jira, GitHub and Slack tools, and starts an agent for each new item, or passes news to the agent already on it. It never merges, approves, comments or sends messages as you.</p>
|
|
437
|
+
<div id="assistant-status" class="assistant-status"></div>
|
|
438
|
+
<fieldset>
|
|
439
|
+
<legend>Watch</legend>
|
|
440
|
+
<label class="check"><input type="checkbox" name="src_jira" /> Jira issues newly assigned to me</label>
|
|
441
|
+
<label class="check"><input type="checkbox" name="src_comments" /> New comments and mentions on my Jira issues (passed to the agent on it)</label>
|
|
442
|
+
<label class="check"><input type="checkbox" name="src_reviews" /> GitHub pull requests waiting for my review</label>
|
|
443
|
+
<label class="check"><input type="checkbox" name="src_ci" /> Failing checks on my open pull requests</label>
|
|
444
|
+
<label class="check"><input type="checkbox" name="src_slack" /> Slack messages to me that ask for work</label>
|
|
445
|
+
</fieldset>
|
|
446
|
+
<div class="row three">
|
|
447
|
+
<label>Check every <span class="hint">minutes</span><input name="everyMin" type="number" min="1" /></label>
|
|
448
|
+
<label>New agents a day <span class="hint">at most</span><input name="maxPerDay" type="number" min="0" /></label>
|
|
449
|
+
<label>Running at once <span class="hint">at most</span><input name="maxRunning" type="number" min="0" /></label>
|
|
450
|
+
</div>
|
|
451
|
+
<label>Notes for the assistant <span class="hint">e.g. "Jira project OPS is the @cc repo; skip issues labeled design"</span>
|
|
452
|
+
<textarea name="notes" rows="3"></textarea>
|
|
453
|
+
</label>
|
|
454
|
+
<div id="assistant-activity" class="assistant-activity"></div>
|
|
455
|
+
<p id="assistant-error" class="error" hidden></p>
|
|
456
|
+
<div class="dialog-actions">
|
|
457
|
+
<button type="button" id="assistant-start" class="primary">Start</button>
|
|
458
|
+
<button type="button" id="assistant-check">Check now</button>
|
|
459
|
+
<button type="button" id="assistant-pause">Pause</button>
|
|
460
|
+
<button type="button" id="assistant-open" class="ghost">Open its session</button>
|
|
461
|
+
<span class="spacer"></span>
|
|
462
|
+
<button type="button" id="assistant-close" class="ghost">Close</button>
|
|
463
|
+
<button id="assistant-save">Save</button>
|
|
464
|
+
</div>
|
|
465
|
+
</form>
|
|
466
|
+
</dialog>
|
|
467
|
+
|
|
402
468
|
<dialog id="cleanup-dialog">
|
|
403
469
|
<form id="cleanup-form" method="dialog" class="alerts-body">
|
|
404
470
|
<h2>Clean up old agents</h2>
|
package/public/style.css
CHANGED
|
@@ -157,7 +157,7 @@ body.term-max .layout { grid-template-columns: 1fr; }
|
|
|
157
157
|
.shortcut { flex: none; font: 600 11px/16px var(--mono); padding: 0 6px; border-radius: 4px; background: var(--accent); color: var(--accent-text); cursor: pointer; }
|
|
158
158
|
.bl-head .shortcut { cursor: default; }
|
|
159
159
|
.project-head .caret { width: 12px; color: var(--muted); flex: none; }
|
|
160
|
-
.project-head .pname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
160
|
+
.project-head .pname { font-size: 16px; font-weight: 700; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
161
161
|
.project-head .pcounts { display: flex; gap: 6px; align-items: center; color: var(--muted); font-weight: 500; font-size: 12px; }
|
|
162
162
|
.project-head .prun { color: var(--green); }
|
|
163
163
|
.project-head .pcount { white-space: nowrap; }
|
|
@@ -384,6 +384,17 @@ body.remote .local-only { display: none; }
|
|
|
384
384
|
.alerts-body .check { display: flex; gap: 8px; align-items: center; font-size: 13px; }
|
|
385
385
|
.alerts-body .check input[type=checkbox] { width: auto; }
|
|
386
386
|
.alerts-body .num { width: 64px; padding: 2px 6px; }
|
|
387
|
+
.assistant-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 4px; vertical-align: middle; background: var(--muted); }
|
|
388
|
+
.assistant-dot.on { background: var(--green); }
|
|
389
|
+
.assistant-dot.busy { background: var(--yellow); }
|
|
390
|
+
.assistant-status { font-size: 13px; }
|
|
391
|
+
.assistant-status p { margin: 4px 0; }
|
|
392
|
+
.assistant-started { margin: 4px 0; padding-left: 18px; font-size: 13px; }
|
|
393
|
+
.assistant-activity { font-size: 13px; max-height: 240px; overflow: auto; }
|
|
394
|
+
.assistant-activity h4 { margin: 8px 0 4px; }
|
|
395
|
+
.assistant-activity .md h1, .assistant-activity .md h2, .assistant-activity .md h3 { font-size: 13px; margin: 10px 0 2px; color: var(--muted); }
|
|
396
|
+
.assistant-activity .md p, .assistant-activity .md ul { margin: 2px 0; }
|
|
397
|
+
.tag.assistant { background: var(--panel-2); }
|
|
387
398
|
.cleanup-plan { font-size: 13px; max-height: 260px; overflow: auto; }
|
|
388
399
|
.cleanup-plan h4 { margin: 8px 0 4px; font-size: 13px; }
|
|
389
400
|
.cleanup-plan ul { margin: 0; padding-left: 18px; }
|
package/src/assistant.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// The assistant: one always-on session that works on the person's behalf. Every few minutes the
|
|
2
|
+
// daemon asks it to check for new work (newly assigned Jira issues, GitHub review requests,
|
|
3
|
+
// failing checks on the person's PRs, new Jira comments, Slack messages) with its own tools, and
|
|
4
|
+
// it starts agents for that work through agentmgr, or passes news to the agent already on it.
|
|
5
|
+
// The daemon keeps the limits: a daily cap, a cap on its agents running at once, and no second
|
|
6
|
+
// agent for the same issue.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { paths } from './config.js';
|
|
11
|
+
import { leadingJiraKey } from './tracker.js';
|
|
12
|
+
|
|
13
|
+
export const SOURCES = {
|
|
14
|
+
jira: 'Jira issues newly assigned to the person',
|
|
15
|
+
reviews: "GitHub pull requests where the person's review is requested",
|
|
16
|
+
ci: "the person's own open GitHub pull requests whose checks fail",
|
|
17
|
+
comments: 'new comments and mentions on Jira issues the person owns',
|
|
18
|
+
slack: 'Slack messages to the person (direct messages and mentions) that ask for work',
|
|
19
|
+
};
|
|
20
|
+
export const ASSISTANT_DEFAULTS = {
|
|
21
|
+
everyMin: 15,
|
|
22
|
+
sources: Object.keys(SOURCES),
|
|
23
|
+
maxPerDay: 10,
|
|
24
|
+
maxRunning: 5,
|
|
25
|
+
permissionMode: 'auto',
|
|
26
|
+
model: null,
|
|
27
|
+
notes: '',
|
|
28
|
+
};
|
|
29
|
+
const DAY = 86_400_000;
|
|
30
|
+
const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'agentmgr.js');
|
|
31
|
+
|
|
32
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
33
|
+
|
|
34
|
+
export const assistantDir = () => path.join(paths.home, 'assistant');
|
|
35
|
+
|
|
36
|
+
/** Settings from config.json's "assistant", with defaults and sane bounds. */
|
|
37
|
+
export function assistantSettings(config = {}) {
|
|
38
|
+
const c = { ...ASSISTANT_DEFAULTS, ...config.assistant };
|
|
39
|
+
const n = (v, d, lo, hi) => (Number.isFinite(Number(v)) && Number(v) >= lo ? Math.min(hi, Math.floor(Number(v))) : d);
|
|
40
|
+
const sources = Array.isArray(c.sources) ? c.sources.filter((s) => SOURCES[s]) : ASSISTANT_DEFAULTS.sources;
|
|
41
|
+
return {
|
|
42
|
+
everyMin: n(c.everyMin, ASSISTANT_DEFAULTS.everyMin, 1, 1440),
|
|
43
|
+
sources,
|
|
44
|
+
maxPerDay: n(c.maxPerDay, ASSISTANT_DEFAULTS.maxPerDay, 0, 500),
|
|
45
|
+
maxRunning: n(c.maxRunning, ASSISTANT_DEFAULTS.maxRunning, 0, 100),
|
|
46
|
+
permissionMode: typeof c.permissionMode === 'string' && c.permissionMode ? c.permissionMode : null,
|
|
47
|
+
model: typeof c.model === 'string' && c.model ? c.model : null,
|
|
48
|
+
notes: typeof c.notes === 'string' ? c.notes.slice(0, 4000) : '',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The assistant's standing instructions (appended to its system prompt on every launch). */
|
|
53
|
+
export function assistantPrompt(s, dir, bin = `node ${BIN}`) {
|
|
54
|
+
const am = bin;
|
|
55
|
+
const watch = s.sources.map((k) => `- ${SOURCES[k]}`).join('\n');
|
|
56
|
+
const how = [];
|
|
57
|
+
if (s.sources.includes('jira')) {
|
|
58
|
+
how.push(
|
|
59
|
+
`Jira, new assignments: with your Jira / Atlassian tools, find unresolved issues assigned to the person that were created or assigned since your last check (on the first check, the last 2 days). ` +
|
|
60
|
+
`For each one you haven't handled, start an agent: ${am} add @<project> "<the issue link> <a one-line summary of what to do>". ` +
|
|
61
|
+
`Start the request with the issue link: the agent is then named after the issue key, and the daemon refuses a second agent for the same key.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (s.sources.includes('comments')) {
|
|
65
|
+
how.push(
|
|
66
|
+
`Jira, comments and mentions: find new comments on issues the person owns or is mentioned in, since your last check. ` +
|
|
67
|
+
`If an agent is already working on that issue (its name is the issue key; see ${am} ls --json), pass the comment on: ${am} tell <agent id> "New comment on <key> from <author>: <the comment>". ` +
|
|
68
|
+
`A comment that asks for new work on an issue with no agent: start one as above. Otherwise just record it.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (s.sources.includes('reviews')) {
|
|
72
|
+
how.push(
|
|
73
|
+
`GitHub reviews: gh search prs --review-requested=@me --state=open --json url,title,repository,updatedAt. ` +
|
|
74
|
+
`For each pull request you haven't handled, start a review agent in its project: ${am} add @<project> "Review <the PR link>: read the change, check out the branch if needed, and write the review as comments for the person to post. Don't approve, request changes or post anything yourself."`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (s.sources.includes('ci')) {
|
|
78
|
+
how.push(
|
|
79
|
+
`Failing checks: gh search prs --author=@me --state=open --json url,title,repository, then gh pr checks <url> for each. ` +
|
|
80
|
+
`For a pull request with failing checks: if an agent already works on it (${am} ls --json lists each agent's PRs), tell that agent: ${am} tell <agent id> "Checks fail on <PR link>: <which checks>. Find out why and fix it." ` +
|
|
81
|
+
`Otherwise start one: ${am} add @<project> "Checks fail on <PR link>: find out why and fix it on that branch."`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (s.sources.includes('slack')) {
|
|
85
|
+
how.push(
|
|
86
|
+
`Slack: with your Slack tools, read direct messages to the person and messages that mention them, since your last check. ` +
|
|
87
|
+
`When one clearly asks for work in a known project, start an agent for it and include the message link in the request. Don't reply in Slack; record what you did.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return [
|
|
91
|
+
`You are the person's assistant inside AI Agent Manager (agentmgr). You run all the time in the background and work on their behalf: you find new work and start agents for it, so the work is under way before they look. You don't do the work yourself.`,
|
|
92
|
+
`Watch:\n${watch}`,
|
|
93
|
+
`Each time you get a message starting with "Check now", do one check of every source above, act, record it, and wait for the next message. Don't loop or sleep on your own.`,
|
|
94
|
+
`How:\n${how.map((h, i) => `${i + 1}. ${h}`).join('\n')}`,
|
|
95
|
+
`Projects: ${am} project ls shows the projects and their shortcuts. Pick the project whose repository matches the work (the Jira project, the GitHub repository). ` +
|
|
96
|
+
`If no project fits, use --repo <git URL> when you know the repository; if you don't know where the work belongs, don't start an agent: list it under "Needs you" in the activity file.`,
|
|
97
|
+
`Limits: the daemon allows at most ${s.maxPerDay} new agents a day and ${s.maxRunning} of yours running at once, and refuses a second agent for the same issue. When ${am} add is refused, don't retry it in this check: record the reason.`,
|
|
98
|
+
`Memory: keep ${path.join(dir, 'handled.json')} as a JSON object from item keys (a Jira key, a PR URL, "<key>#comment-<id>", a Slack message link) to { "at": time, "action": "started <agent id>" | "told <agent id>" | "skipped: why" }. Read it at the start of each check and add what you handled, so you never act on the same item twice. Keep "lastCheck" in it too.`,
|
|
99
|
+
`Activity: after each check, append a short entry to ${path.join(dir, 'activity.md')}: a "## <date and time>" heading, then one line per action ("Started OPS-7 (agent 3fa2b1) in @cc: fix the login timeout", "Told 3fa2b1 about a new comment on OPS-7"), and a "Needs you" list for anything you couldn't decide. Write "Nothing new." when nothing came in. AI Agent Manager shows this file to the person.`,
|
|
100
|
+
`Never merge, approve, close, reassign or comment on anything in Jira, GitHub or Slack, and never send messages as the person. Reading, and starting or telling agents, is all you do.`,
|
|
101
|
+
s.notes ? `The person's notes for you:\n${s.notes}` : '',
|
|
102
|
+
]
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.join('\n\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class Assistant {
|
|
108
|
+
constructor({ manager, store, config = {}, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
109
|
+
this.manager = manager;
|
|
110
|
+
this.store = store;
|
|
111
|
+
this.config = config;
|
|
112
|
+
this.log = log;
|
|
113
|
+
store.data.assistant ??= {};
|
|
114
|
+
this.timer = setInterval(() => this.tick(), 30_000);
|
|
115
|
+
this.timer.unref?.();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
close() {
|
|
119
|
+
clearInterval(this.timer);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get settings() {
|
|
123
|
+
return assistantSettings(this.config);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get state() {
|
|
127
|
+
return this.store.data.assistant;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The assistant agent, if there is one. */
|
|
131
|
+
agent() {
|
|
132
|
+
const id = this.state.agentId;
|
|
133
|
+
const a = id && this.store.get(id);
|
|
134
|
+
return a?.kind === 'assistant' ? a : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Change settings; kept in config.json by the caller. Running sessions get them on the next launch. */
|
|
138
|
+
update(patch = {}) {
|
|
139
|
+
if (patch.sources !== undefined && (!Array.isArray(patch.sources) || patch.sources.some((s) => !SOURCES[s]))) {
|
|
140
|
+
throw err(400, `sources must be a list of: ${Object.keys(SOURCES).join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
for (const k of ['everyMin', 'maxPerDay', 'maxRunning']) {
|
|
143
|
+
if (patch[k] !== undefined && !(Number(patch[k]) >= (k === 'everyMin' ? 1 : 0))) throw err(400, `${k} must be ${k === 'everyMin' ? 1 : 0} or more`);
|
|
144
|
+
}
|
|
145
|
+
const next = assistantSettings({ assistant: { ...this.config.assistant, ...patch } });
|
|
146
|
+
this.config.assistant = next;
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Create the assistant if needed and start it; the first check follows once it is ready. */
|
|
151
|
+
start() {
|
|
152
|
+
let a = this.agent();
|
|
153
|
+
if (!a) {
|
|
154
|
+
const s = this.settings;
|
|
155
|
+
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
156
|
+
a = this.manager.create({ kind: 'assistant', name: 'Assistant', permissionMode: s.permissionMode || undefined, model: s.model || undefined, start: true });
|
|
157
|
+
this.state.agentId = a.id;
|
|
158
|
+
this.state.nextAt = new Date(Date.now() + 20_000).toISOString();
|
|
159
|
+
this.store.save();
|
|
160
|
+
this.log('assistant started');
|
|
161
|
+
return this.status();
|
|
162
|
+
}
|
|
163
|
+
this.state.paused = false;
|
|
164
|
+
this.state.nextAt ??= new Date(Date.now() + 20_000).toISOString();
|
|
165
|
+
this.store.save();
|
|
166
|
+
if (!this.manager.runtimes.get(a.id)?.pty) this.manager.start(a.id);
|
|
167
|
+
return this.status();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Stop checking and close the session (it keeps its memory; start resumes it). */
|
|
171
|
+
async pause() {
|
|
172
|
+
const a = this.agent();
|
|
173
|
+
this.state.paused = true;
|
|
174
|
+
this.store.save();
|
|
175
|
+
if (a && this.manager.runtimes.get(a.id)?.pty) await this.manager.stop(a.id);
|
|
176
|
+
return this.status();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Ask for a check now (queued until the session is idle). */
|
|
180
|
+
check({ by = 'person' } = {}) {
|
|
181
|
+
const a = this.agent();
|
|
182
|
+
if (!a) throw err(409, 'The assistant is not set up yet: start it first');
|
|
183
|
+
const last = this.state.lastCheckAt;
|
|
184
|
+
const n = (this.state.checks ?? 0) + 1;
|
|
185
|
+
const text = `Check now (check ${n}, ${new Date().toISOString()}${last ? `; last check ${last}` : '; first check'}${by === 'person' ? ', asked by the person' : ''}).`;
|
|
186
|
+
this.manager.tell(a.id, text, { source: 'assistant' });
|
|
187
|
+
this.state.checks = n;
|
|
188
|
+
this.state.lastCheckAt = new Date().toISOString();
|
|
189
|
+
this.state.nextAt = new Date(Date.now() + this.settings.everyMin * 60_000).toISOString();
|
|
190
|
+
this.store.save();
|
|
191
|
+
return this.status();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The scheduler: a check every everyMin minutes while the assistant is on and free. */
|
|
195
|
+
tick(t = Date.now()) {
|
|
196
|
+
const a = this.agent();
|
|
197
|
+
// Paused, or closed by hand: no checks until it is started again.
|
|
198
|
+
if (!a || this.state.paused || a.desired === 'stopped') return;
|
|
199
|
+
if (Date.parse(this.state.nextAt ?? 0) > t) return;
|
|
200
|
+
const rt = this.manager.runtimes.get(a.id);
|
|
201
|
+
// Busy with the last check, or a message still waiting to be typed in: try again shortly.
|
|
202
|
+
if (rt?.busy || this.manager.outbox.get(a.id)?.length) return;
|
|
203
|
+
if (!rt?.pty && a.status === 'preparing') return;
|
|
204
|
+
try {
|
|
205
|
+
this.check({ by: 'schedule' });
|
|
206
|
+
} catch (e) {
|
|
207
|
+
this.log(`assistant: ${e.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Agents the assistant started. */
|
|
212
|
+
started() {
|
|
213
|
+
const a = this.agent();
|
|
214
|
+
return a ? this.store.list().filter((x) => x.startedBy === a.id) : [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Called before the assistant's own session creates agents: enforces the daily cap, the cap on
|
|
219
|
+
* its running agents, and one agent per issue. Returns the creation options to add.
|
|
220
|
+
*/
|
|
221
|
+
admit(callerId, intents, opts = {}) {
|
|
222
|
+
const a = this.agent();
|
|
223
|
+
if (!a || callerId !== a.id) return {};
|
|
224
|
+
const s = this.settings;
|
|
225
|
+
const mine = this.started();
|
|
226
|
+
const today = mine.filter((x) => Date.now() - Date.parse(x.createdAt) < DAY).length;
|
|
227
|
+
if (today + intents.length > s.maxPerDay) throw err(429, `The assistant's limit is ${s.maxPerDay} new agents a day (${today} so far); not started`);
|
|
228
|
+
const running = mine.filter((x) => this.manager.runtimes.get(x.id)?.pty || x.status === 'preparing').length;
|
|
229
|
+
if (running + intents.length > s.maxRunning) throw err(429, `The assistant's limit is ${s.maxRunning} of its agents running at once (${running} now); not started`);
|
|
230
|
+
for (const intent of intents) {
|
|
231
|
+
const key = opts.name?.trim() || leadingJiraKey(intent);
|
|
232
|
+
const dup = key && this.store.list().find((x) => x.kind === 'task' && x.name.toLowerCase() === key.toLowerCase());
|
|
233
|
+
if (dup) throw err(409, `An agent for ${key} already exists: ${dup.name} (${dup.id}); tell it instead`);
|
|
234
|
+
}
|
|
235
|
+
return { startedBy: a.id };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
activity({ chars = 20_000 } = {}) {
|
|
239
|
+
try {
|
|
240
|
+
const text = fs.readFileSync(path.join(assistantDir(), 'activity.md'), 'utf8');
|
|
241
|
+
return text.length > chars ? text.slice(text.indexOf('\n## ', text.length - chars) + 1 || text.length - chars) : text;
|
|
242
|
+
} catch {
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
status() {
|
|
248
|
+
const a = this.agent();
|
|
249
|
+
const rt = a && this.manager.runtimes.get(a.id);
|
|
250
|
+
const mine = this.started();
|
|
251
|
+
return {
|
|
252
|
+
...this.settings,
|
|
253
|
+
agentId: a?.id ?? null,
|
|
254
|
+
on: Boolean(a && !this.state.paused),
|
|
255
|
+
running: Boolean(rt?.pty),
|
|
256
|
+
busy: Boolean(rt?.busy),
|
|
257
|
+
checks: this.state.checks ?? 0,
|
|
258
|
+
lastCheckAt: this.state.lastCheckAt ?? null,
|
|
259
|
+
nextAt: a && !this.state.paused ? (this.state.nextAt ?? null) : null,
|
|
260
|
+
startedToday: mine.filter((x) => Date.now() - Date.parse(x.createdAt) < DAY).length,
|
|
261
|
+
startedTotal: mine.length,
|
|
262
|
+
started: mine
|
|
263
|
+
.sort((x, y) => y.createdAt.localeCompare(x.createdAt))
|
|
264
|
+
.slice(0, 20)
|
|
265
|
+
.map((x) => ({ id: x.id, name: x.name, intent: x.intent, createdAt: x.createdAt })),
|
|
266
|
+
activity: this.activity(),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/cleanup.js
CHANGED
|
@@ -81,6 +81,7 @@ export class Cleanup {
|
|
|
81
81
|
/** Why an agent is left alone whatever its age, or null. */
|
|
82
82
|
protectedBy(agent) {
|
|
83
83
|
if (agent.keep) return 'marked Keep';
|
|
84
|
+
if (agent.kind === 'assistant') return 'the assistant';
|
|
84
85
|
if (agent.status === 'preparing') return 'still setting up';
|
|
85
86
|
const rt = this.manager.runtimes.get(agent.id);
|
|
86
87
|
if (rt?.pty && rt.busy) return 'working';
|
package/src/cli.js
CHANGED
|
@@ -451,6 +451,42 @@ export async function main(argv) {
|
|
|
451
451
|
console.log(`${res.name}: P${res.priority}`);
|
|
452
452
|
});
|
|
453
453
|
|
|
454
|
+
program
|
|
455
|
+
.command('edit')
|
|
456
|
+
.description("Change an agent's name, request, project, priority, permission mode or model")
|
|
457
|
+
.argument('<agent>')
|
|
458
|
+
.option('-n, --name <name>', 'display name')
|
|
459
|
+
.option('-i, --intent <text>', 'the request as recorded (add --tell to send it to the session too)')
|
|
460
|
+
.option('--tell', 'with --intent: also tell the agent about the changed request')
|
|
461
|
+
.option('--project <project>', 'move under this project (shortcut or name); "" for its own repo or folder')
|
|
462
|
+
.option('--priority <P0-P3>', 'priority')
|
|
463
|
+
.option('-p, --permission-mode <mode>', 'permission mode, from the next launch ("" for the default)')
|
|
464
|
+
.option('-m, --model <model>', 'model, from the next launch ("" for the default)')
|
|
465
|
+
.action(async (ref, opts) => {
|
|
466
|
+
await daemon();
|
|
467
|
+
const a = await resolveAgent(ref);
|
|
468
|
+
const patch = Object.fromEntries(
|
|
469
|
+
[['name', opts.name], ['intent', opts.intent], ['project', opts.project], ['priority', opts.priority], ['permissionMode', opts.permissionMode], ['model', opts.model]].filter(([, v]) => v !== undefined),
|
|
470
|
+
);
|
|
471
|
+
if (!Object.keys(patch).length) throw new Error('Nothing to change: give --name, --intent, --project, --priority, --permission-mode or --model');
|
|
472
|
+
const res = await api('PATCH', `/agents/${a.id}`, patch);
|
|
473
|
+
if (opts.tell && opts.intent) await api('POST', `/agents/${a.id}/tell`, { text: `The request changed. It is now:\n${opts.intent}` });
|
|
474
|
+
console.log(`${green('✓')} ${bold(res.name)} ${dim(res.id)} ${res.projectInfo?.name ?? ''} P${res.priority}${res.permissionMode ? ` ${res.permissionMode}` : ''}${res.model ? ` ${res.model}` : ''}`);
|
|
475
|
+
if (patch.permissionMode !== undefined || patch.model !== undefined) console.log(dim('Permission mode and model apply from its next launch.'));
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
program
|
|
479
|
+
.command('move')
|
|
480
|
+
.description('Move an agent under another project (shortcut or name); "" moves it back to its own')
|
|
481
|
+
.argument('<agent>')
|
|
482
|
+
.argument('<project>')
|
|
483
|
+
.action(async (ref, project) => {
|
|
484
|
+
await daemon();
|
|
485
|
+
const a = await resolveAgent(ref);
|
|
486
|
+
const res = await api('PATCH', `/agents/${a.id}`, { project });
|
|
487
|
+
console.log(`${green('✓')} ${bold(res.name)} is now under ${bold(res.projectInfo?.full || res.projectInfo?.name)}`);
|
|
488
|
+
});
|
|
489
|
+
|
|
454
490
|
program
|
|
455
491
|
.command('plan')
|
|
456
492
|
.description('While the workspace is still being prepared, plan the task with Claude and attach')
|
|
@@ -1043,6 +1079,68 @@ export async function main(argv) {
|
|
|
1043
1079
|
if (c.lastRun) console.log(dim(`\nLast run ${new Date(c.lastRun.at).toLocaleString()} (${c.lastRun.by}): closed ${c.lastRun.closed.length}, deleted ${c.lastRun.deleted.length}`));
|
|
1044
1080
|
});
|
|
1045
1081
|
|
|
1082
|
+
const asCmd = program.command('assistant').description('An always-on session that finds new work (Jira, GitHub, Slack) and starts agents for it');
|
|
1083
|
+
const showAssistant = (a) => {
|
|
1084
|
+
const state = !a.agentId ? dim('not set up (agentmgr assistant start)') : a.on ? green(a.busy ? 'on, checking now' : 'on') : yellow('paused');
|
|
1085
|
+
console.log(`${bold('Assistant')}: ${state}${a.agentId ? dim(` agent ${a.agentId}`) : ''}`);
|
|
1086
|
+
console.log(`checks every ${a.everyMin} min · watches ${a.sources.join(', ') || 'nothing'} · at most ${a.maxPerDay} new agents a day, ${a.maxRunning} running`);
|
|
1087
|
+
if (a.lastCheckAt) console.log(dim(`last check ${new Date(a.lastCheckAt).toLocaleString()}${a.nextAt ? ` · next ${new Date(a.nextAt).toLocaleTimeString()}` : ''} · started ${a.startedToday} today, ${a.startedTotal} in all`));
|
|
1088
|
+
for (const x of a.started.slice(0, 10)) console.log(` ${green('+')} ${bold(x.name)} ${dim(x.id)} ${truncate(x.intent, 70)}`);
|
|
1089
|
+
};
|
|
1090
|
+
asCmd
|
|
1091
|
+
.command('status', { isDefault: true })
|
|
1092
|
+
.description('Show whether it is on, its settings, and the agents it started')
|
|
1093
|
+
.option('--activity', 'also print its activity log')
|
|
1094
|
+
.action(async (opts) => {
|
|
1095
|
+
await daemon();
|
|
1096
|
+
const a = await api('GET', '/assistant');
|
|
1097
|
+
showAssistant(a);
|
|
1098
|
+
if (opts.activity) console.log(`\n${a.activity || dim('No activity yet.')}`);
|
|
1099
|
+
});
|
|
1100
|
+
asCmd
|
|
1101
|
+
.command('start')
|
|
1102
|
+
.description('Set it up (first time) or turn it back on')
|
|
1103
|
+
.action(async () => {
|
|
1104
|
+
await daemon();
|
|
1105
|
+
showAssistant(await api('POST', '/assistant/start'));
|
|
1106
|
+
});
|
|
1107
|
+
asCmd
|
|
1108
|
+
.command('pause')
|
|
1109
|
+
.description('Stop checking and close its session (start resumes it with its memory)')
|
|
1110
|
+
.action(async () => {
|
|
1111
|
+
await daemon();
|
|
1112
|
+
showAssistant(await api('POST', '/assistant/pause'));
|
|
1113
|
+
});
|
|
1114
|
+
asCmd
|
|
1115
|
+
.command('check')
|
|
1116
|
+
.description('Check for new work now')
|
|
1117
|
+
.action(async () => {
|
|
1118
|
+
await daemon();
|
|
1119
|
+
await api('POST', '/assistant/check');
|
|
1120
|
+
console.log(`${green('✓')} asked the assistant to check now`);
|
|
1121
|
+
});
|
|
1122
|
+
asCmd
|
|
1123
|
+
.command('set')
|
|
1124
|
+
.description('Change its settings (they apply from its next launch; pause and start to apply now)')
|
|
1125
|
+
.option('--every <minutes>', 'check this often')
|
|
1126
|
+
.option('--sources <list>', 'comma-separated: jira,comments,reviews,ci,slack')
|
|
1127
|
+
.option('--max-per-day <n>', 'new agents a day, at most')
|
|
1128
|
+
.option('--max-running <n>', 'of its agents running at once, at most')
|
|
1129
|
+
.option('--permission-mode <mode>', 'its own permission mode (default auto, so it can run agentmgr and gh without asking)')
|
|
1130
|
+
.option('--notes <text>', 'standing notes, e.g. which Jira project goes to which repo')
|
|
1131
|
+
.action(async (opts) => {
|
|
1132
|
+
await daemon();
|
|
1133
|
+
const patch = {
|
|
1134
|
+
...(opts.every ? { everyMin: Number(opts.every) } : {}),
|
|
1135
|
+
...(opts.sources ? { sources: opts.sources.split(',').map((x) => x.trim()).filter(Boolean) } : {}),
|
|
1136
|
+
...(opts.maxPerDay ? { maxPerDay: Number(opts.maxPerDay) } : {}),
|
|
1137
|
+
...(opts.maxRunning ? { maxRunning: Number(opts.maxRunning) } : {}),
|
|
1138
|
+
...(opts.permissionMode ? { permissionMode: opts.permissionMode } : {}),
|
|
1139
|
+
...(opts.notes !== undefined ? { notes: opts.notes } : {}),
|
|
1140
|
+
};
|
|
1141
|
+
showAssistant(await api('PUT', '/assistant', patch));
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1046
1144
|
program
|
|
1047
1145
|
.command('doctor')
|
|
1048
1146
|
.description('Check what AI Agent Manager needs on this machine (Node.js, Claude Code, git, gh, the daemon) and how to fix it')
|
|
@@ -1459,7 +1557,7 @@ export async function main(argv) {
|
|
|
1459
1557
|
i === 0 || proj(agents[i - 1]) !== proj(a) ? bold(truncate(proj(a), 30)) : '',
|
|
1460
1558
|
a.priority === 0 ? red(bold('P0')) : a.priority === 1 ? red('P1') : a.priority === 3 ? dim('P3') : 'P2',
|
|
1461
1559
|
a.id,
|
|
1462
|
-
truncate(a.kind === 'general' ? `${a.name} (general)` : a.name, 28),
|
|
1560
|
+
truncate(a.kind === 'general' ? `${a.name} (general)` : a.startedBy ? `${a.name} (assistant)` : a.name, 28),
|
|
1463
1561
|
statusLabel(a),
|
|
1464
1562
|
a.git?.branch ?? '',
|
|
1465
1563
|
String(a.prs.length || ''),
|
package/src/client.js
CHANGED
|
@@ -10,7 +10,8 @@ const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin',
|
|
|
10
10
|
export async function api(method, route, body) {
|
|
11
11
|
const res = await fetch(`${BASE}/api${route}`, {
|
|
12
12
|
method,
|
|
13
|
-
|
|
13
|
+
// From inside an agent's session, say which agent is asking (the assistant's limits use it).
|
|
14
|
+
headers: { 'content-type': 'application/json', 'x-agentmgr': '1', ...(process.env.AGENTMGR_AGENT_ID ? { 'x-agentmgr-agent': process.env.AGENTMGR_AGENT_ID } : {}) },
|
|
14
15
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
15
16
|
});
|
|
16
17
|
const data = await res.json().catch(() => ({}));
|
package/src/manager.js
CHANGED
|
@@ -46,6 +46,7 @@ import { Judge, clashPrompt, finishMessage, hash, reviewPrompt, screenPrompt, st
|
|
|
46
46
|
import { findLocalUrls, findSignInLinks, parseCallback, parseUrl } from './preview.js';
|
|
47
47
|
import { Plugins } from './plugins.js';
|
|
48
48
|
import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
|
|
49
|
+
import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
|
|
49
50
|
import { runningClaude, scanClaudeSessions } from './sessions.js';
|
|
50
51
|
import {
|
|
51
52
|
bugFromRef,
|
|
@@ -119,9 +120,15 @@ export function repoCacheDir(repo) {
|
|
|
119
120
|
return path.join(paths.repos, `${git.repoNameFromUrl(repo)}-${hash}`);
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
/** Sessions with no repo or folder of their own. */
|
|
124
|
+
export const NO_REPO = new Set(['general', 'assistant']);
|
|
125
|
+
|
|
122
126
|
export function projectOf(agent) {
|
|
127
|
+
// Moved under another known project by hand.
|
|
128
|
+
if (agent.projectMove?.key) return { key: agent.projectMove.key, name: agent.projectMove.name, ...(agent.projectMove.full ? { full: agent.projectMove.full } : {}), source: 'moved' };
|
|
123
129
|
if (agent.project) return { key: `custom:${agent.project.toLowerCase()}`, name: agent.project, source: 'custom' };
|
|
124
130
|
if (agent.kind === 'general') return { key: 'general', name: 'General', source: 'general' };
|
|
131
|
+
if (agent.kind === 'assistant') return { key: 'assistant', name: 'Assistant', source: 'assistant' };
|
|
125
132
|
const url = agent.repo || agent.git?.remote;
|
|
126
133
|
if (url && /[:/]/.test(url)) {
|
|
127
134
|
const path = url
|
|
@@ -363,7 +370,9 @@ export class AgentManager extends EventEmitter {
|
|
|
363
370
|
|
|
364
371
|
/** Make sure the agent's project has a shortcut; remember where it lives for "add @cc". */
|
|
365
372
|
registerProject(agent) {
|
|
366
|
-
if (!agent || agent.kind
|
|
373
|
+
if (!agent || NO_REPO.has(agent.kind)) return null;
|
|
374
|
+
// A moved agent's repo says nothing about the project it was moved to.
|
|
375
|
+
if (agent.projectMove) return null;
|
|
367
376
|
const info = projectOf(agent);
|
|
368
377
|
if (info.key === 'other') return null;
|
|
369
378
|
const reg = this.store.data.projects;
|
|
@@ -512,6 +521,7 @@ export class AgentManager extends EventEmitter {
|
|
|
512
521
|
engine,
|
|
513
522
|
sessionId: adopt,
|
|
514
523
|
imported,
|
|
524
|
+
startedBy,
|
|
515
525
|
}) {
|
|
516
526
|
intent = (intent ?? '').trim();
|
|
517
527
|
const engineDef = engineOf(engine || this.config.engine || DEFAULT_ENGINE);
|
|
@@ -524,7 +534,7 @@ export class AgentManager extends EventEmitter {
|
|
|
524
534
|
}
|
|
525
535
|
// Planning while cloning moves the session between folders, which needs the engine's support.
|
|
526
536
|
if (!engineDef.supports.planHandoff) plan = false;
|
|
527
|
-
const general = kind
|
|
537
|
+
const general = NO_REPO.has(kind);
|
|
528
538
|
// A project shortcut (or name) fills in the repo / folder / base branch.
|
|
529
539
|
const entry = !general && project ? this.resolveProject(project) : null;
|
|
530
540
|
if (entry) {
|
|
@@ -548,10 +558,11 @@ export class AgentManager extends EventEmitter {
|
|
|
548
558
|
|
|
549
559
|
const agent = {
|
|
550
560
|
id,
|
|
551
|
-
kind: general ?
|
|
561
|
+
kind: general ? kind : 'task',
|
|
552
562
|
engine: engineId,
|
|
553
563
|
project: project?.trim() || null,
|
|
554
564
|
priority: parsePriority(priority),
|
|
565
|
+
...(startedBy ? { startedBy } : {}),
|
|
555
566
|
name: name?.trim() || jiraKey || slug,
|
|
556
567
|
slug,
|
|
557
568
|
intent,
|
|
@@ -687,6 +698,10 @@ export class AgentManager extends EventEmitter {
|
|
|
687
698
|
fs.mkdirSync(paths.general, { recursive: true });
|
|
688
699
|
return { workdir: paths.general };
|
|
689
700
|
}
|
|
701
|
+
if (agent.kind === 'assistant') {
|
|
702
|
+
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
703
|
+
return { workdir: assistantDir() };
|
|
704
|
+
}
|
|
690
705
|
let repo = agent.repo;
|
|
691
706
|
let dir = agent.requestedDir ? path.resolve(expandHome(agent.requestedDir)) : null;
|
|
692
707
|
const branch = `agent/${slug}-${id}`;
|
|
@@ -858,7 +873,7 @@ export class AgentManager extends EventEmitter {
|
|
|
858
873
|
let writableDir = null;
|
|
859
874
|
let agents = null;
|
|
860
875
|
const prompts = [];
|
|
861
|
-
const task = agent.kind
|
|
876
|
+
const task = !NO_REPO.has(agent.kind);
|
|
862
877
|
const withBrief = Boolean(this.config.brief && task);
|
|
863
878
|
if (this.config.reviewConventions && task) prompts.push(conventionsPrompt({ withBrief, rulesFiles: engine.rulesFiles ?? null }));
|
|
864
879
|
if (withBrief) {
|
|
@@ -876,6 +891,12 @@ export class AgentManager extends EventEmitter {
|
|
|
876
891
|
prompts.push(agent.planReview.status === 'approved' ? planApprovedPrompt(file) : planReviewPrompt(file));
|
|
877
892
|
}
|
|
878
893
|
if (planning) prompts.push(planningPrompt(agent));
|
|
894
|
+
if (agent.kind === 'assistant') {
|
|
895
|
+
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
896
|
+
const dir = fs.realpathSync(assistantDir());
|
|
897
|
+
writableDir = dir;
|
|
898
|
+
prompts.push(assistantPrompt(assistantSettings(this.config), dir));
|
|
899
|
+
}
|
|
879
900
|
if (agent.team) {
|
|
880
901
|
// Team members write their handoff files in the team's folder.
|
|
881
902
|
fs.mkdirSync(teamDir(agent.team.id), { recursive: true });
|
|
@@ -1190,8 +1211,28 @@ export class AgentManager extends EventEmitter {
|
|
|
1190
1211
|
|
|
1191
1212
|
update(ref, fields) {
|
|
1192
1213
|
const agent = this.mustGet(ref);
|
|
1214
|
+
const bad = (m) => Object.assign(new Error(m), { status: 400 });
|
|
1193
1215
|
const allowed = {};
|
|
1194
|
-
for (const key of ['
|
|
1216
|
+
for (const key of ['notes']) if (key in fields) allowed[key] = fields[key];
|
|
1217
|
+
if ('name' in fields) {
|
|
1218
|
+
const name = String(fields.name ?? '').trim();
|
|
1219
|
+
if (!name) throw bad('Name is required');
|
|
1220
|
+
allowed.name = name.slice(0, 80);
|
|
1221
|
+
}
|
|
1222
|
+
// The request as recorded; the session only hears about a change when told.
|
|
1223
|
+
if ('intent' in fields) {
|
|
1224
|
+
const intent = String(fields.intent ?? '').trim();
|
|
1225
|
+
if (!intent && !NO_REPO.has(agent.kind)) throw bad('The request is required');
|
|
1226
|
+
allowed.intent = intent;
|
|
1227
|
+
}
|
|
1228
|
+
// Permission mode and model apply from the next launch.
|
|
1229
|
+
if ('permissionMode' in fields) {
|
|
1230
|
+
const mode = fields.permissionMode || null;
|
|
1231
|
+
const modes = engineOf(agent).permissionModes;
|
|
1232
|
+
if (mode && !modes.includes(mode)) throw bad(`${engineOf(agent).label} has no "${mode}" mode (use ${modes.join(', ')})`);
|
|
1233
|
+
allowed.permissionMode = mode;
|
|
1234
|
+
}
|
|
1235
|
+
if ('model' in fields) allowed.model = String(fields.model ?? '').trim() || null;
|
|
1195
1236
|
// Keep: clean up never closes or deletes it.
|
|
1196
1237
|
if ('keep' in fields) allowed.keep = Boolean(fields.keep);
|
|
1197
1238
|
if ('priority' in fields) {
|
|
@@ -1199,7 +1240,25 @@ export class AgentManager extends EventEmitter {
|
|
|
1199
1240
|
if (p === null) throw Object.assign(new Error('priority must be P0, P1, P2 or P3'), { status: 400 });
|
|
1200
1241
|
allowed.priority = p;
|
|
1201
1242
|
}
|
|
1202
|
-
|
|
1243
|
+
// Move: under a known project (shortcut or name), under a new project name, or back to its own ("").
|
|
1244
|
+
if ('project' in fields) {
|
|
1245
|
+
if (NO_REPO.has(agent.kind)) throw bad(`A ${agent.kind} session has no project`);
|
|
1246
|
+
const ref = String(fields.project ?? '').trim();
|
|
1247
|
+
const entry = ref ? this.resolveProject(ref) : null;
|
|
1248
|
+
if (!ref) Object.assign(allowed, { project: null, projectMove: null });
|
|
1249
|
+
else if (entry && entry.key !== projectOf({ ...agent, project: null, projectMove: null }).key) {
|
|
1250
|
+
// Its full name (org/repo), as the project's own agents show it.
|
|
1251
|
+
const info = this.store.list().map((a) => (a.projectMove ? null : projectOf(a))).find((i) => i?.key === entry.key);
|
|
1252
|
+
Object.assign(allowed, { project: null, projectMove: { key: entry.key, name: entry.name, ...(info?.full ? { full: info.full } : {}), at: now() } });
|
|
1253
|
+
} else if (entry) Object.assign(allowed, { project: null, projectMove: null });
|
|
1254
|
+
else Object.assign(allowed, { project: ref.slice(0, 60), projectMove: null });
|
|
1255
|
+
}
|
|
1256
|
+
if ('intent' in allowed) {
|
|
1257
|
+
const refs = extractRefs(allowed.intent, this.config.bugPatterns);
|
|
1258
|
+
const bugs = { ...(agent.bugs ?? {}) };
|
|
1259
|
+
for (const b of refs.bugs) bugs[b.key] ??= { ...b, source: 'intent' };
|
|
1260
|
+
allowed.bugs = bugs;
|
|
1261
|
+
}
|
|
1203
1262
|
return this.view(this.patch(agent.id, allowed));
|
|
1204
1263
|
}
|
|
1205
1264
|
|
|
@@ -2049,7 +2108,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2049
2108
|
/** Recompute what an agent's branch changed since it branched (needs a base commit). */
|
|
2050
2109
|
async updateChanges(agent) {
|
|
2051
2110
|
const baseSha = agent.baseRef?.split(' @ ')[1];
|
|
2052
|
-
if (!agent.workdir || !baseSha || agent.kind
|
|
2111
|
+
if (!agent.workdir || !baseSha || NO_REPO.has(agent.kind) || !fs.existsSync(agent.workdir)) return;
|
|
2053
2112
|
// A full diff against the base is heavier than git status; once a minute is enough for overlaps.
|
|
2054
2113
|
const prev0 = this.changeSets.get(agent.id);
|
|
2055
2114
|
if (prev0 && Date.now() - Date.parse(prev0.at) < (this.config.conflictRefreshSec ?? 60) * 1000) return;
|
|
@@ -2087,7 +2146,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2087
2146
|
const byRepo = new Map();
|
|
2088
2147
|
for (const e of entries) {
|
|
2089
2148
|
const k = repoKey(e.agent);
|
|
2090
|
-
if (k && e.agent.kind
|
|
2149
|
+
if (k && !NO_REPO.has(e.agent.kind)) (byRepo.get(k) ?? byRepo.set(k, []).get(k)).push(e);
|
|
2091
2150
|
}
|
|
2092
2151
|
for (const group of byRepo.values()) {
|
|
2093
2152
|
for (let i = 0; i < group.length; i++) {
|
|
@@ -2323,7 +2382,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2323
2382
|
const a = this.store.get(id);
|
|
2324
2383
|
if (!a) return;
|
|
2325
2384
|
this.changed(a);
|
|
2326
|
-
if (sure && data.state === 'done' && a.kind
|
|
2385
|
+
if (sure && data.state === 'done' && !NO_REPO.has(a.kind)) this.requestReview(id, { trigger: 'screen' }).catch(() => {});
|
|
2327
2386
|
}
|
|
2328
2387
|
|
|
2329
2388
|
/** The person says the agent is not actually waiting on them. */
|
|
@@ -2404,7 +2463,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2404
2463
|
// ------------------------------------------------------- decision inbox
|
|
2405
2464
|
|
|
2406
2465
|
questionsView(agent) {
|
|
2407
|
-
if (agent.kind
|
|
2466
|
+
if (NO_REPO.has(agent.kind)) return [];
|
|
2408
2467
|
const answers = agent.answers ?? {};
|
|
2409
2468
|
return loadQuestions(agent.id).map((q) => ({ ...q, answer: answers[q.id] ?? null }));
|
|
2410
2469
|
}
|
|
@@ -2464,7 +2523,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2464
2523
|
*/
|
|
2465
2524
|
async checkpoint(id) {
|
|
2466
2525
|
const agent = this.store.get(id);
|
|
2467
|
-
if (!agent?.workdir || agent.kind
|
|
2526
|
+
if (!agent?.workdir || NO_REPO.has(agent.kind) || !agent.branch || this.checkpointing?.has(id)) return null;
|
|
2468
2527
|
// Output pauses mid-turn too (a slow command); one snapshot per half minute is plenty.
|
|
2469
2528
|
this.lastCheckpointTry ??= new Map();
|
|
2470
2529
|
if (Date.now() - (this.lastCheckpointTry.get(id) ?? 0) < 30_000) return null;
|
package/src/notify.js
CHANGED
|
@@ -16,6 +16,7 @@ export const EVENT_DEFAULTS = {
|
|
|
16
16
|
stuck: true, // going in circles: same failure, undoing its edits, or long stretches without changes
|
|
17
17
|
conflict: true, // changing the same files as another agent in the same repo
|
|
18
18
|
question: true, // asked a question in the decision inbox
|
|
19
|
+
assistant: true, // the assistant started an agent for new work
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
// Alerts the AI judge may hold for the digest or drop; the rest are always sent at once.
|
|
@@ -264,7 +265,7 @@ export class Notifier {
|
|
|
264
265
|
|
|
265
266
|
deliver(kind, agent, what, n, urgency = null) {
|
|
266
267
|
if (!n.enabled || !n.events[kind]) return;
|
|
267
|
-
const icon = { needsInput: ':raised_hand:', review: ':white_check_mark:', error: ':x:', waiting: ':hourglass_flowing_sand:', blocked: ':no_entry:', stuck: ':repeat:', conflict: ':crossed_swords:', question: ':question:' }[kind];
|
|
268
|
+
const icon = { needsInput: ':raised_hand:', review: ':white_check_mark:', error: ':x:', waiting: ':hourglass_flowing_sand:', blocked: ':no_entry:', stuck: ':repeat:', conflict: ':crossed_swords:', question: ':question:', assistant: ':robot_face:' }[kind];
|
|
268
269
|
const project = agent.projectInfo?.name ? ` (${agent.projectInfo.name})` : '';
|
|
269
270
|
const link = this.linkFor(agent.id);
|
|
270
271
|
const intent = n.includeIntent && agent.intent ? `\n> ${agent.intent.replace(/\s+/g, ' ').slice(0, 200)}` : '';
|
package/src/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { AgentManager, shellNumber } from './manager.js';
|
|
|
11
11
|
import { Backlog } from './backlog.js';
|
|
12
12
|
import { Races } from './races.js';
|
|
13
13
|
import { Teams } from './teams.js';
|
|
14
|
+
import { Assistant } from './assistant.js';
|
|
14
15
|
import { Cleanup } from './cleanup.js';
|
|
15
16
|
import { listEngines, registerEngine } from './engines/index.js';
|
|
16
17
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
@@ -59,6 +60,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
59
60
|
const races = new Races({ manager, store });
|
|
60
61
|
const teams = new Teams({ manager, store, config });
|
|
61
62
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
63
|
+
const assistant = new Assistant({ manager, store, config });
|
|
62
64
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
63
65
|
const notifier = new Notifier({
|
|
64
66
|
manager,
|
|
@@ -177,8 +179,16 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
177
179
|
api.post(
|
|
178
180
|
'/agents',
|
|
179
181
|
wrap((req) => {
|
|
180
|
-
const { intents, intent, ...opts } = req.body ?? {};
|
|
181
|
-
|
|
182
|
+
const { intents, intent, startedBy, ...opts } = req.body ?? {};
|
|
183
|
+
if (opts.kind === 'assistant') throw fail(400, 'Start the assistant with POST /assistant/start');
|
|
184
|
+
const list = (Array.isArray(intents) ? intents : [intent]).map((i) => String(i ?? '').trim()).filter(Boolean);
|
|
185
|
+
// Agents the assistant's own session starts: its limits apply, and they are marked as its.
|
|
186
|
+
const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
|
|
187
|
+
const created = manager.createMany(list, { ...opts, ...extra });
|
|
188
|
+
if (extra.startedBy) {
|
|
189
|
+
for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
190
|
+
}
|
|
191
|
+
return created;
|
|
182
192
|
}),
|
|
183
193
|
);
|
|
184
194
|
api.post('/general', wrap((req) => manager.createGeneral(req.body ?? {})));
|
|
@@ -404,6 +414,15 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
404
414
|
api.delete('/races/:id', wrap((req) => races.remove(req.params.id)));
|
|
405
415
|
|
|
406
416
|
// Clean up old agents: the strategy and days (kept in config.json), what a run would do, run now.
|
|
417
|
+
api.get('/assistant', wrap(() => assistant.status()));
|
|
418
|
+
api.put('/assistant', wrap((req) => {
|
|
419
|
+
const next = assistant.update(req.body ?? {});
|
|
420
|
+
saveConfig({ assistant: next });
|
|
421
|
+
return assistant.status();
|
|
422
|
+
}));
|
|
423
|
+
api.post('/assistant/start', wrap(() => assistant.start()));
|
|
424
|
+
api.post('/assistant/pause', wrap(() => assistant.pause()));
|
|
425
|
+
api.post('/assistant/check', wrap(() => assistant.check()));
|
|
407
426
|
api.get('/cleanup', wrap(() => cleanup.status()));
|
|
408
427
|
api.put('/cleanup', wrap(async (req) => {
|
|
409
428
|
const next = cleanup.update(req.body ?? {});
|
|
@@ -756,6 +775,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
756
775
|
if (closing) return;
|
|
757
776
|
closing = true;
|
|
758
777
|
cleanup.close();
|
|
778
|
+
assistant.close();
|
|
759
779
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
760
780
|
await manager.stopAll({ final: true });
|
|
761
781
|
await manager.flush().catch((err) => console.error(`[agentmgr] final flush failed: ${err.message}`));
|
|
@@ -792,6 +812,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
792
812
|
manager,
|
|
793
813
|
teams,
|
|
794
814
|
cleanup,
|
|
815
|
+
assistant,
|
|
795
816
|
backlog,
|
|
796
817
|
live,
|
|
797
818
|
url: `http://${host}:${port}`,
|