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 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.0 # or a specific one
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.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
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.0 && npm install` (the tag you want) |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.js CHANGED
@@ -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.kind === 'general') return { button: '', box: '' };
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.kind === 'general') return '';
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.kind === 'general') return '';
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.kind === 'general' || !a.git?.branch) return '';
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="Group this agent under a different project name">✎</button></dd>
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
- const name = prompt('Project name for this agent (leave empty to use its repo):', a.project ?? a.projectInfo?.name ?? '');
2880
- if (name === null) return;
2881
- act(async () => upsert(await api('PATCH', `/agents/${a.id}`, { project: name })));
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; }
@@ -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
- headers: { 'content-type': 'application/json', 'x-agentmgr': '1' },
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 === 'general') return null;
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 === 'general';
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 ? 'general' : 'task',
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 !== 'general';
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 ['name', 'notes', 'permissionMode', 'model', 'project']) if (key in fields) allowed[key] = fields[key];
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
- if ('project' in allowed) allowed.project = String(allowed.project ?? '').trim() || null;
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 === 'general' || !fs.existsSync(agent.workdir)) return;
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 !== 'general') (byRepo.get(k) ?? byRepo.set(k, []).get(k)).push(e);
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 !== 'general') this.requestReview(id, { trigger: 'screen' }).catch(() => {});
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 === 'general') return [];
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 === 'general' || !agent.branch || this.checkpointing?.has(id)) return null;
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
- return manager.createMany(Array.isArray(intents) ? intents : [intent], opts);
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}`,