ai-agentmanager 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0 (2026-10-31)
4
+
5
+ - **Muse Code engine:** Meta's Muse Code CLI (`muse`) as a fourth engine: `--engine muse` or **Muse Code** in the Engine picker. Session adoption, resume (messages typed once idle), conversation and usage, trust and approval questions in the inbox, a clean stop, and Explain. The picker warns when Muse is in contributor mode. Stuck detection from commands and edits isn't there yet for Muse.
6
+ - **Terminals:** reloading the page no longer types the terminal's answers to old color and cursor queries into the program (seen with Muse Code), in agents' terminals and the Terminal. The agent's terminal tab is named after its engine (Codex, Antigravity, Muse Code).
7
+ - **One agent or one per line:** New agents asks whether each line starts its own agent (a list of tickets) or the whole text goes to one agent (a long description, kept as written). The button shows how many agents it will create, and the choice is remembered.
8
+ - **Diagrams:** a **Diagrams** button beside the documents draws the problem and its solution for a quick grasp, diagrams only: the problem, architecture (changes highlighted), design, data flow with animated arrows, the key interaction and the plan. Drawn from the analysis, design, flow, proposal and plan already written, plus the agent's work and the repo layout. Also `agentmgr doc <agent> diagrams`.
9
+ - **Home page:** shows what you can do (agents, teams and races, Inbox, Code, shells and the browser, Terminal, review and Land, Backlog and the assistant, Results, your phone, several machines), each with a button to it, and stays after Getting started is done or hidden. It scrolls when it doesn't fit.
10
+ - **Restart from the app's own terminal:** `agentmgr restart` or `agentmgr update` typed in an agent's shell or the Terminal no longer dies halfway (daemon stopped, never started) when the restart closes that terminal; it finishes, and the terminal can be reopened.
11
+ - **Remote typing:** over remote access, typing or a paste whose request fails (a network blip) is retried in order instead of dropped, and you're told if it still can't get through.
12
+
3
13
  ## 1.4.0 (2026-10-31)
4
14
 
5
15
  - **Move an agent to another machine:** **Move to…** on the hub (or `agentmgr fleet move <machine>/<agent> --to <machine>`) continues an agent elsewhere with its conversation, branch and uncommitted work. The work goes through the repo's remote on a temporary handoff branch; the conversation goes through the hub in pieces (not stored). Both machines must allow the new `move` permission. A failed move puts everything back; the copy left behind is marked moved and can't be resumed.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
6
6
 
7
7
  ![The conflict radar: two agents changed the same lines of README.md; the AI judge rates it a collision and suggests which lands first](docs/images/conflict-radar.jpg)
8
8
 
9
- - **Intents in, agents out.** Give one intent or a list (one per line). Each becomes its own `claude` process.
9
+ - **Intents in, agents out.** Give a list, one per line (say, a ticket each), and each becomes its own `claude` process. Or pick **One agent for all of it** and a long description goes to a single agent as written.
10
10
  - **Repo or folder.** Give a git URL (cloned once, then each agent gets its own worktree and branch) or a local folder.
11
11
  - **Fast clones.** URLs are cloned as partial clones (`--filter=blob:none`): history now, file contents only for what's checked out.
12
12
  - **Progress.** Clone and checkout progress shows in the UI and `agentmgr ls`.
@@ -83,13 +83,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
83
83
 
84
84
  ```sh
85
85
  npm install -g ai-agentmanager # the latest release
86
- npm install -g ai-agentmanager@1.4.0 # or a specific one
86
+ npm install -g ai-agentmanager@1.5.0 # or a specific one
87
87
  ```
88
88
 
89
89
  Or from the repository, at a release tag:
90
90
 
91
91
  ```sh
92
- git clone --branch v1.4.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
92
+ git clone --branch v1.5.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
93
93
  cd ai-agentmanager-core
94
94
  npm install
95
95
  npm link # optional: puts `agentmgr` on your PATH
@@ -109,7 +109,7 @@ npm link
109
109
  | Installed with | Update |
110
110
  | --- | --- |
111
111
  | npm | `npm install -g ai-agentmanager@latest` |
112
- | A clone at a release tag | `git fetch --tags && git checkout v1.4.0 && npm install` (the tag you want) |
112
+ | A clone at a release tag | `git fetch --tags && git checkout v1.5.0 && npm install` (the tag you want) |
113
113
  | A clone of `main` | `git pull && npm install` |
114
114
 
115
115
  **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.
@@ -327,6 +327,7 @@ The Brief panel's **Share for review** buttons, or `agentmgr doc <agent> <kind>`
327
327
  | **Flow** | Summary, end-to-end flowchart with a step-by-step walk-through, key interactions as a sequence diagram, failure and edge cases |
328
328
  | **Proposal** | Summary, problem, recommendation, scope, plan, effort and dependencies, risks, decisions needed |
329
329
  | **Human vs AI** | Your top 5 decisions, each with why it needs you, the stakes, options and what the work suggests, plus a box for your answer. Up to 5 questions the AI answers, each with its confidence, evidence and how to check it. A second model's verdict on each. |
330
+ | **Diagrams** | Diagrams only, for a quick grasp: the problem, the architecture (with what changes highlighted), the design, the data flow (animated) and the key interaction, plus the plan. Drawn from the documents already written and the agent's work. Optional: **Write all** leaves it out. |
330
331
 
331
332
  **Human vs AI.** This review takes two separate read-only calls.
332
333
  1. **The split.** The agent's engine splits the open questions. *For you to decide* covers judgment calls such as intent, priorities, risk appetite and trade-offs. *Answered by AI* covers reasoning and evaluation: correctness, edge cases, test coverage and what's left.
@@ -723,12 +724,14 @@ Or use **Remote access** in the header of the UI on this computer.
723
724
  - **Firewall.** If the macOS firewall is off, the port is open to your whole network while remote access is on. Turn it off when you don't need it.
724
725
  - **Streaming.** The phone UI streams with Server-Sent Events plus plain HTTPS requests. iOS Safari doesn't apply an accepted self-signed certificate to WebSockets.
725
726
 
726
- ## Engines: Claude Code, Codex and Antigravity
727
+ ## Engines: Claude Code, Codex, Antigravity and Muse Code
727
728
 
728
729
  Agents run Claude Code by default. With OpenAI's Codex CLI installed (`npm i -g @openai/codex`, signed in), an agent can run Codex instead: `agentmgr add --engine codex …`, or the **Engine** picker in New agent (shown once a second engine is installed). Everything else works the same way: worktrees, the brief and questions, the inbox (Codex's approval menus included), token usage, stuck detection, PR tracking, Explain, the backlog and the hub. Codex records no cost, and planning while a repo clones and forks are Claude-only for now. Set `"engine": "codex"` in config.json to make it the default; `engines.codex.bin` points at another binary. On the hub, each machine reports the engines it has (`engine:codex`), so shared backlog work can ask for one. See [docs/engines.md](docs/engines.md).
729
730
 
730
731
  Google's **Antigravity CLI** (`agy`, signed in with your Google account) works the same way: `--engine antigravity`. It replaced Gemini CLI for personal Google accounts in June 2026. AI Agent Manager adopts the conversation agy starts, resumes it with `--conversation`, reads token usage, commands and file writes from its transcript, shows its permission menus (commands, file changes, access outside the folder) in the inbox, pre-trusts agent folders in agy's settings, and runs Explain in agy's print mode. agy asks before writing the brief too, unless the agent runs in `acceptEdits` mode. `engines.antigravity.bin` points at another binary.
731
732
 
733
+ Meta's **Muse Code** (`muse`, installed with `curl -fsSL https://dev.meta.ai/install.sh | bash` and signed in with `muse login`) works the same way: `--engine muse`. AI Agent Manager adopts the session Muse starts, resumes it with `muse resume` (a message for a resumed session is typed once Muse is idle), trusts the agent's folder for each run with `--trust-workspace` instead of saving it, reads the conversation and token usage from Muse's session log, shows its trust and approval questions in the inbox, stops it cleanly with SIGTERM, and runs Explain with `muse exec` in the read-only profile. Commands and edits aren't read from its log yet, so stuck detection has less to go on for Muse agents. When Muse runs in contributor mode (an account without a plan), the engine picker says so: Meta may use your code and conversations to improve its products. `engines.muse.bin` points at another binary, and `engines.muse.args` adds arguments to every run.
734
+
732
735
  ## Several machines: the hub
733
736
 
734
737
  With agents on more than one Mac, a **hub** shows all of them in one place: every machine's agents and one inbox of what needs you. Where a machine allows it, you can also act from the hub: answer, message, start and stop agents, start new ones, and open live terminals. Each Mac keeps running its own daemon. It connects out to the hub, so no ports need opening on it, and the hub's requests travel back over that same connection.
package/docs/engines.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Design: several agent engines (Claude Code, Codex CLI, Gemini CLI)
2
2
 
3
- Status: phase 1, Codex and Antigravity built. Gemini CLI dropped: since June 18, 2026 it no longer serves personal Google accounts, and Antigravity CLI (`agy`, `src/engines/antigravity.js`, checked against 1.4.3, fixtures in `test/fixtures/antigravity/`) replaced it. 0.4 (branch `v0.4-engines`) · 2026-10-18. Targets the current releases of each CLI, checked in phase 1.
3
+ Status: phase 1, Codex, Antigravity and Muse Code built. Gemini CLI dropped: since June 18, 2026 it no longer serves personal Google accounts, and Antigravity CLI (`agy`, `src/engines/antigravity.js`, checked against 1.4.3, fixtures in `test/fixtures/antigravity/`) replaced it. 0.4 (branch `v0.4-engines`) · 2026-10-18. Targets the current releases of each CLI, checked in phase 1.
4
4
 
5
5
  AI Agent Manager runs every agent with Claude Code. If Anthropic has an outage, a rate limit hits, or prices change, every agent stops at once. Running agents on other engines as well makes the fleet resilient across providers, and lets each task use the engine that suits it.
6
6
 
@@ -167,6 +167,7 @@ The e2e suite uses a fake Claude (`test/fake-claude.js`). Each engine gets its o
167
167
  | 1. Adapter seam ✅ | `src/engines/claude.js` with today's behaviour behind the interface; `engine` on agents; real CLIs checked and recorded as fixtures |
168
168
  | 2. Antigravity CLI ✅ | Replaces Gemini CLI. `src/engines/antigravity.js`: launch with instructions in the first message, adoption of the conversation agy picks (`cache/last_conversations.json`), resume with `--conversation`, usage, commands and writes from `brain/<id>/.system_generated/logs/transcript_full.jsonl`, command / file / access menus, trust in `settings.json` `trustedWorkspaces`, Explain in print mode (`--mode plan`, prompt attached to `--print=`). Non-Claude engines get a shorter conventions note: a smaller model turned the Claude-specific one into a search of the whole disk. |
169
169
  | 3. Codex CLI ✅ | `src/engines/codex.js`: launch and resume, session adoption, brief folder (`--add-dir`), usage, stuck detection, prompts, trust, Explain through `codex exec`; engine pickers in the CLI, the page and the hub; machines report `engine:*` labels for the shared backlog. Not yet: planning while cloning and forks. Built before Gemini, which waits on a sign-in. |
170
+ | 3b. Muse Code ✅ (partly) | `src/engines/muse.js`, checked against Muse Code 1.4.14 (fixtures in `test/fixtures/muse/`). Launch with instructions in the first message; adoption of the session Muse picks (`$XDG_DATA_HOME/muse/sessions/YYYY/MM/DD/<id>/session.jsonl`, folder from the `runtime.session.metadata` record's `workspace_root`); `muse resume <id>`, which takes no message, so a message for a resumed session is typed once it is idle (`supports.resumePrompt: false`); `--trust-workspace` on each launch instead of saving trust; Plan → `:read-only` profile, Auto → `:auto-review`, Bypass → `--yolo`; prompts, replies and usage from the run events; stopped with SIGTERM (`stopSignal`), since a hangup leaves no clean end in its log; Explain through `muse exec` in the read-only profile (options after `exec`: `oneShotExtra`). A notice in the engine picker when Muse runs in contributor mode (Meta may use the content). Not yet, because real model calls weren't available while building it: commands and edits from the log (no stuck detection from them), and the approval menus are matched from the wording in the binary rather than a recorded screen. |
170
171
  | 4. Fail over | Engine health, preference lists in both backlogs, engines reported to the hub, **Continue on…** handoff |
171
172
 
172
173
  ## Open questions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.js CHANGED
@@ -245,7 +245,9 @@ const INSIGHT_ICONS = { why: '🎯', what: '📦', how: '🛠', now: '📍' };
245
245
 
246
246
  // ------------------------------------------------------------------ stakeholder documents
247
247
 
248
- const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI' };
248
+ const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI', diagrams: 'Diagrams' };
249
+ // Optional extras that "Write all" leaves out.
250
+ const DOC_EXTRAS = new Set(['diagrams']);
249
251
  const DOC_TITLES = { ...DOCS, plan: 'Implementation plan' };
250
252
  /** What the document dialog shows: a stakeholder document, or the agent's plan under review. */
251
253
  const docOf = (a, kind) => (kind === 'plan' ? a?.planReview && { markdown: a.planReview.markdown, at: a.planReview.updatedAt } : a?.docs?.[kind]);
@@ -308,7 +310,7 @@ function docHtml(markdown) {
308
310
  }
309
311
 
310
312
  function docButtons(a) {
311
- const missing = Object.keys(DOCS).filter((k) => !a.docs?.[k]?.markdown && !a.docs?.[k]?.pending);
313
+ const missing = Object.keys(DOCS).filter((k) => !DOC_EXTRAS.has(k) && !a.docs?.[k]?.markdown && !a.docs?.[k]?.pending);
312
314
  const all = missing.length
313
315
  ? `<button class="small ghost" data-doc-all title="Write ${missing.map((k) => DOCS[k]).join(', ')} in the background; you'll be told when each is ready">Write all in background</button>`
314
316
  : '';
@@ -317,7 +319,7 @@ function docButtons(a) {
317
319
  const d = a.docs?.[k];
318
320
  const stale = d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
319
321
  return `<button class="small doc-btn ${d?.markdown ? 'has' : ''}" data-doc="${k}" ${d?.pending ? 'disabled' : ''}
320
- title="${d?.markdown ? `Open the ${label.toLowerCase()} document${stale ? ' (the brief changed since)' : ''}` : `Write a ${label.toLowerCase()} document for stakeholders`}">${d?.pending ? `${label}…` : label}${d?.markdown ? (stale ? ' •' : ' ✓') : ''}</button>`;
322
+ title="${k === 'diagrams' ? (d?.markdown ? `Open the diagrams${stale ? ' (the brief changed since)' : ''}` : 'Draw the problem and its solution for a quick grasp: architecture, design, data flow (animated) and the key interaction, diagrams only. Built from the documents already written and the agent\'s work') : d?.markdown ? `Open the ${label.toLowerCase()} document${stale ? ' (the brief changed since)' : ''}` : `Write a ${label.toLowerCase()} document for stakeholders`}">${d?.pending ? `${label}…` : label}${d?.markdown ? (stale ? ' •' : ' ✓') : ''}</button>`;
321
323
  })
322
324
  .join('')}</span>`;
323
325
  }
@@ -332,8 +334,9 @@ function renderDocDialog() {
332
334
  const plan = docView.kind === 'plan';
333
335
  $('#doc-title').textContent = `${label} · ${a?.name ?? ''}`;
334
336
  const body = $('#doc-body');
337
+ body.classList.toggle('diagrams-view', docView.kind === 'diagrams');
335
338
  if (d?.pending) {
336
- body.innerHTML = `<p class="hint">${docView.kind === 'humanai' ? 'Splitting the open questions and answering the AI ones, then asking a second model to check…' : `Writing the ${label.toLowerCase()} document from the agent's work so far…`}</p>`;
339
+ body.innerHTML = `<p class="hint">${docView.kind === 'humanai' ? 'Splitting the open questions and answering the AI ones, then asking a second model to check…' : docView.kind === 'diagrams' ? "Drawing the diagrams from the documents and the agent's work so far…" : `Writing the ${label.toLowerCase()} document from the agent's work so far…`}</p>`;
337
340
  body.dataset.rendered = '';
338
341
  } else if (d?.error) body.innerHTML = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
339
342
  else if (plan && !d?.markdown) body.innerHTML = '<p class="hint">The agent is investigating and hasn\'t written its plan yet. You\'ll see it here (and in the inbox) when it has.</p>';
@@ -345,8 +348,8 @@ function renderDocDialog() {
345
348
  }
346
349
  }
347
350
  const stale = !plan && d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
348
- const by = d?.writer ? ` by ${d.writer}${d.critique ? `, checked by ${d.critique.label}` : ''}` : '';
349
- $('#doc-meta').textContent = d?.markdown ? `${plan ? `Plan · ${PLAN_LABEL[a.planReview.status]} · updated` : 'Written'} ${ago(d.at)}${by}${stale ? ' · the brief has changed since; Regenerate for the latest' : ''}` : '';
351
+ const by = d?.writer ? ` by ${d.writer}${d.critique ? `, checked by ${d.critique.label}` : ''}${d.from ? ` from ${d.from.length ? d.from.join(', ') : "the agent's work"}` : ''}` : '';
352
+ $('#doc-meta').textContent = d?.markdown ? `${plan ? `Plan · ${PLAN_LABEL[a.planReview.status]} · updated` : docView.kind === 'diagrams' ? 'Drawn' : 'Written'} ${ago(d.at)}${by}${stale ? ' · the brief has changed since; Regenerate for the latest' : ''}` : '';
350
353
  for (const id of ['doc-copy', 'doc-download', 'doc-print']) $(`#${id}`).disabled = !d?.markdown;
351
354
  $('#doc-print').hidden = plan;
352
355
  $('#doc-regen').hidden = plan;
@@ -1784,6 +1787,9 @@ const term = {
1784
1787
  this.xterm.loadAddon(this.fit);
1785
1788
  this.xterm.open($('#terminal'));
1786
1789
  this.xterm.onData((d) => {
1790
+ // Replaying the backlog makes xterm answer the color and cursor queries in it again; those
1791
+ // answers are stale, and the program would get them as typing.
1792
+ if (this.replaying) return;
1787
1793
  lastTyped = Date.now();
1788
1794
  this.send({ t: 'i', d });
1789
1795
  });
@@ -1842,6 +1848,7 @@ const term = {
1842
1848
  this.conn = null;
1843
1849
  if (ev.code === 1008) toast(`Terminal unavailable: ${ev.reason}`, 5000);
1844
1850
  },
1851
+ onInputLost: () => this.conn === conn && toast('Some typing did not reach the computer (connection lost); check the terminal before going on', 8000),
1845
1852
  },
1846
1853
  );
1847
1854
  this.conn = conn;
@@ -1852,7 +1859,8 @@ const term = {
1852
1859
  if (msg.t === 'hello') {
1853
1860
  // Also sent again after a reconnect: start from a clean screen and replay.
1854
1861
  this.xterm.reset();
1855
- this.xterm.write(msg.backlog);
1862
+ this.replaying = true;
1863
+ this.xterm.write(msg.backlog, () => (this.replaying = false));
1856
1864
  if (!matchMedia('(pointer: coarse)').matches) this.xterm.focus();
1857
1865
  } else if (msg.t === 'error') {
1858
1866
  toast(`Terminal unavailable: ${msg.error}`, 5000);
@@ -1888,6 +1896,8 @@ const term = {
1888
1896
  renderTabs() {
1889
1897
  renderSignIn();
1890
1898
  const a = this.agentId && agents.get(this.agentId);
1899
+ // The agent's own session tab is named after its engine (Codex, Muse Code, …).
1900
+ $('.term-tab[data-kind="claude"]').textContent = ENGINE_LABEL[a?.engine] ?? 'Claude';
1891
1901
  const nums = new Set(a?.runtime.shells ?? []);
1892
1902
  if (isShell(this.kind)) nums.add(shellNum(this.kind));
1893
1903
  $('#shell-tabs').innerHTML = [...nums]
@@ -3147,6 +3157,7 @@ function openNewDialog(mode = 'now') {
3147
3157
  renderProjectOptions();
3148
3158
  $('#new-error').hidden = true;
3149
3159
  dialog.querySelector(`input[name="when"][value="${mode}"]`).checked = true;
3160
+ restoreIntentSplit();
3150
3161
  syncWhen();
3151
3162
  dialog.showModal();
3152
3163
  dialog.querySelector('textarea').focus();
@@ -3156,7 +3167,55 @@ function syncWhen() {
3156
3167
  dialog.querySelector('.now-only').hidden = when !== 'now';
3157
3168
  dialog.querySelector('.race-only').hidden = when !== 'race';
3158
3169
  dialog.querySelector('.team-only').hidden = when !== 'team';
3159
- dialog.querySelector('button[type=submit]').textContent = { backlog: 'Add to backlog', race: 'Start race', team: 'Start team' }[when] ?? 'Create agents';
3170
+ // A race or a team always works on the whole text.
3171
+ dialog.querySelector('.intent-split').hidden = when === 'race' || when === 'team';
3172
+ syncIntentPlaceholder();
3173
+ syncIntentCount();
3174
+ }
3175
+
3176
+ /** The form's intents: one per line, or the whole text as one (kept as written, blank lines and all). */
3177
+ function formIntents(form) {
3178
+ const text = form.intents.value;
3179
+ const whole = form.split.value === 'one' || ['race', 'team'].includes(form.when.value);
3180
+ if (whole) return text.trim() ? [text.trim()] : [];
3181
+ return text
3182
+ .split('\n')
3183
+ .map((s) => s.trim())
3184
+ .filter(Boolean);
3185
+ }
3186
+
3187
+ function syncIntentCount() {
3188
+ const form = $('#new-form');
3189
+ const when = form.when.value;
3190
+ const n = formIntents(form).length;
3191
+ const split = !['race', 'team'].includes(when);
3192
+ $('#intent-count').textContent = split && n ? (when === 'backlog' ? `${n} item${n > 1 ? 's' : ''}` : `${n} agent${n > 1 ? 's' : ''}`) : '';
3193
+ const submit = dialog.querySelector('button[type=submit]');
3194
+ submit.textContent = { backlog: n > 1 ? `Add ${n} to backlog` : 'Add to backlog', race: 'Start race', team: 'Start team' }[when] ?? (n > 1 ? `Create ${n} agents` : 'Create agent');
3195
+ }
3196
+ $('#new-form').intents.addEventListener('input', syncIntentCount);
3197
+ for (const r of dialog.querySelectorAll('input[name="split"]')) {
3198
+ r.addEventListener('change', () => {
3199
+ try {
3200
+ localStorage.setItem('agentmgr.intentSplit', r.value);
3201
+ } catch {}
3202
+ syncIntentPlaceholder();
3203
+ syncIntentCount();
3204
+ });
3205
+ }
3206
+ const LINES_PLACEHOLDER = $('#new-form').intents.placeholder;
3207
+ const WHOLE_PLACEHOLDER = 'Describe the work in as much detail as you like: the problem, what done looks like, constraints, links. It all goes to one agent.';
3208
+ /** The remembered choice (a form reset forgets it), and a placeholder to match. */
3209
+ function restoreIntentSplit() {
3210
+ const form = $('#new-form');
3211
+ try {
3212
+ form.split.value = localStorage.getItem('agentmgr.intentSplit') === 'one' ? 'one' : 'lines';
3213
+ } catch {}
3214
+ syncIntentPlaceholder();
3215
+ }
3216
+ function syncIntentPlaceholder() {
3217
+ const form = $('#new-form');
3218
+ form.intents.placeholder = form.split.value === 'one' || ['race', 'team'].includes(form.when.value) ? WHOLE_PLACEHOLDER : LINES_PLACEHOLDER;
3160
3219
  }
3161
3220
  for (const r of dialog.querySelectorAll('input[name="when"]')) r.addEventListener('change', syncWhen);
3162
3221
  $('#new-btn').onclick = () => openNewDialog('now');
@@ -3164,10 +3223,7 @@ $('#new-cancel').onclick = () => dialog.close();
3164
3223
  $('#new-form').addEventListener('submit', async (e) => {
3165
3224
  e.preventDefault();
3166
3225
  const f = new FormData(e.target);
3167
- const intents = String(f.get('intents'))
3168
- .split('\n')
3169
- .map((s) => s.trim())
3170
- .filter(Boolean);
3226
+ const intents = formIntents(e.target);
3171
3227
  if (f.get('when') === 'team') {
3172
3228
  // "analyst=opus reviewer=opus"
3173
3229
  const models = Object.fromEntries(
@@ -3951,13 +4007,22 @@ $('#login-form').addEventListener('submit', async (e) => {
3951
4007
  });
3952
4008
 
3953
4009
  // Engines this machine can run; the New agent form offers a choice once there's more than Claude.
3954
- const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity' };
4010
+ const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
3955
4011
  async function loadEngines() {
3956
4012
  try {
3957
4013
  const list = (await api('GET', '/engines')).filter((e) => e.installed && e.id !== 'claude');
3958
4014
  const sel = document.querySelector('#engine-field select');
3959
4015
  for (const e of list) sel.append(Object.assign(document.createElement('option'), { value: e.id, textContent: e.label }));
3960
4016
  $('#engine-field').hidden = !list.length;
4017
+ // Something to know before choosing an engine (Muse Code in contributor mode, say).
4018
+ const notices = Object.fromEntries(list.filter((e) => e.notice).map((e) => [e.id, e.notice]));
4019
+ const show = () => {
4020
+ const n = $('#engine-notice');
4021
+ n.textContent = notices[sel.value] ?? '';
4022
+ n.hidden = !notices[sel.value];
4023
+ };
4024
+ sel.addEventListener('change', show);
4025
+ show();
3961
4026
  } catch {}
3962
4027
  }
3963
4028
 
package/public/hub/hub.js CHANGED
@@ -42,7 +42,7 @@ function toast(msg, bad = false) {
42
42
  toast.timer = setTimeout(() => (t.hidden = true), bad ? 6000 : 2500);
43
43
  }
44
44
 
45
- const ENGINE_LABEL = { claude: 'Claude Code', codex: 'Codex', antigravity: 'Antigravity' };
45
+ const ENGINE_LABEL = { claude: 'Claude Code', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
46
46
  /** Engines a machine reports installed (its engine:* labels). */
47
47
  const enginesOf = (runner) => (runner?.labels ?? []).filter((l) => l.startsWith('engine:')).map((l) => l.slice(7));
48
48
  /** Fill an engine picker; hidden while Claude Code is the only choice. */
package/public/index.html CHANGED
@@ -163,8 +163,14 @@
163
163
  <dialog id="new-dialog">
164
164
  <form id="new-form" method="dialog">
165
165
  <h2>New agents</h2>
166
+ <div class="row intent-split">
167
+ <span>What to do</span>
168
+ <label class="check" title="Each line starts its own agent: a list of tickets or small tasks"><input type="radio" name="split" value="lines" checked /> One agent per line</label>
169
+ <label class="check" title="The whole text goes to one agent: a long description, a spec, pasted notes"><input type="radio" name="split" value="one" /> One agent for all of it</label>
170
+ <span class="spacer"></span><span class="hint" id="intent-count"></span>
171
+ </div>
166
172
  <label>
167
- Intents <span class="hint">one per line; each gets its own agent</span>
173
+ <span class="sr-only">Intents</span>
168
174
  <textarea name="intents" rows="6" required placeholder="Add a dark mode toggle that remembers the user's choice&#10;Make search results show up as you type&#10;Find out why the dashboard takes 5 seconds to load and speed it up&#10;Add a one-click CSV export to the reports page"></textarea>
169
175
  </label>
170
176
  <div class="row">
@@ -195,6 +201,7 @@
195
201
  <label id="engine-field" hidden>
196
202
  Engine
197
203
  <select name="engine"><option value="">Claude Code</option></select>
204
+ <span class="hint warn" id="engine-notice" hidden></span>
198
205
  </label>
199
206
  </div>
200
207
  <label>
package/public/machine.js CHANGED
@@ -8,6 +8,7 @@ const MACHINE = '__machine__';
8
8
  export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
9
9
  const dialog = $('#machine-dialog');
10
10
  let xterm = null;
11
+ let replaying = false; // writing the backlog (see onData)
11
12
  let fit = null;
12
13
  let conn = null;
13
14
  let current = null;
@@ -19,7 +20,8 @@ export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
19
20
  fit = new FitAddon();
20
21
  xterm.loadAddon(fit);
21
22
  xterm.open($('#machine-term'));
22
- xterm.onData((d) => conn?.send({ t: 'i', d }));
23
+ // Answers xterm gives to queries in the replayed backlog are stale; don't send them as typing.
24
+ xterm.onData((d) => !replaying && conn?.send({ t: 'i', d }));
23
25
  xterm.onResize(({ cols, rows }) => conn?.send({ t: 'r', cols, rows }));
24
26
  new ResizeObserver(() => dialog.open && fit.fit()).observe($('#machine-term'));
25
27
  }
@@ -45,12 +47,16 @@ export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
45
47
  xterm.focus();
46
48
  },
47
49
  onMessage: (msg) => {
48
- if (msg.t === 'hello' && msg.backlog) xterm.write(msg.backlog);
50
+ if (msg.t === 'hello' && msg.backlog) {
51
+ replaying = true;
52
+ xterm.write(msg.backlog, () => (replaying = false));
53
+ }
49
54
  else if (msg.t === 'o') xterm.write(msg.d);
50
55
  else if (msg.t === 'exit') xterm.write('\r\n\x1b[2m[terminal ended; reopen it with + Terminal]\x1b[0m\r\n');
51
56
  else if (msg.t === 'error') xterm.write(`\r\n\x1b[31m${msg.error}\x1b[0m\r\n`);
52
57
  },
53
58
  onClose: ({ reason }) => reason && xterm.write(`\r\n\x1b[2m[${reason}]\x1b[0m\r\n`),
59
+ onInputLost: () => xterm.write('\r\n\x1b[33m[some typing did not reach the computer (connection lost); check before going on]\x1b[0m\r\n'),
54
60
  },
55
61
  );
56
62
  xterm.focus();
package/public/setup.js CHANGED
@@ -1,5 +1,5 @@
1
- // Getting started: shown where an agent's details go while none is selected, until every step
2
- // is done or it is hidden. Steps open the dialog that does them.
1
+ // The home page, where an agent's details go while none is selected: Getting started until every
2
+ // step is done or it is hidden (steps open the dialog that does them), then what the app can do.
3
3
 
4
4
  const STEPS = [
5
5
  ['agent', 'Start your first agent', 'Give it a request and a repo or folder; it works in its own branch.', '#new-btn', 'New agents'],
@@ -9,6 +9,21 @@ const STEPS = [
9
9
  ['budget', 'Set a budget', 'An alert at 80% of an agent budget, and a daily limit.', '#spend-btn', 'Results'],
10
10
  ];
11
11
 
12
+ // What the app can do, each with where to find it: a button to click, a sidebar tab, or a link.
13
+ const FEATURES = [
14
+ ['Agents side by side', 'Each works on its own branch in its own folder; the list shows which need you.', { btn: '#new-btn', label: 'New agents' }],
15
+ ['Teams and races', 'A team (analyst, critics, implementer, tester, reviewer) solves one problem; a race tries variants and you keep the best.', { btn: '#new-btn', label: 'New agents' }],
16
+ ['Inbox', 'Every question, permission prompt, stuck agent and overlap between agents, in one place.', { tab: 'inbox', label: 'Inbox' }],
17
+ ['Code', "Every agent has a Code tab: its files in the editor from VS Code, with search and a diff against where it started. Or open the folder in VS Code or Cursor."],
18
+ ['Shells and browser', "+ Shell opens a terminal in an agent's folder; + Browser shows a page as this computer sees it (a dev server on localhost), from your phone too."],
19
+ ['Terminal', 'A shell on this computer in your home folder, from any device you are signed in on.', { btn: '#machine-btn', label: 'Terminal' }],
20
+ ['Review and land', "Review an agent's changes and send comments back; Land shows each pull request and what it needs to merge.", { tab: 'land', label: 'Land' }],
21
+ ['Backlog and assistant', 'Queue work to start as agents finish; the assistant and triggers (a GitHub label, a webhook) bring in new work.', { tab: 'backlog', label: 'Backlog' }],
22
+ ['Results', 'Merge rate, time to PR and cost per merged PR, with budgets and a daily limit.', { btn: '#spend-btn', label: 'Results' }],
23
+ ['From your phone', 'Answer agents, review and use the terminals from anywhere on your network.', { btn: '#remote-btn', label: 'Remote access' }],
24
+ ['Several machines', 'Link your computers to a hub to see every agent in one place, and move an agent to another machine to carry on there.', { href: 'https://github.com/jdmunta/ai-agentmanager-core/blob/main/docs/distributed.md', label: 'How it works' }],
25
+ ];
26
+
12
27
  export function initSetup({ api, esc, $ }) {
13
28
  const el = $('#placeholder');
14
29
  const plain = el.innerHTML;
@@ -21,30 +36,41 @@ export function initSetup({ api, esc, $ }) {
21
36
  }
22
37
  };
23
38
 
24
- function render() {
25
- if (!data || hidden()) {
26
- el.innerHTML = plain;
27
- return;
28
- }
39
+ // A feature's button, unless what it opens isn't available here (local-only, on a phone).
40
+ function action(f) {
41
+ if (f.href) return `<a href="${f.href}" target="_blank" rel="noopener">${f.label} ↗</a>`;
42
+ if (f.btn && document.body.classList.contains('remote') && document.querySelector(f.btn)?.classList.contains('local-only')) return '';
43
+ if (f.btn) return `<button class="small ghost" data-setup-open="${f.btn}">${f.label}</button>`;
44
+ if (f.tab) return `<button class="small ghost" data-setup-tab="${f.tab}">${f.label}</button>`;
45
+ return '';
46
+ }
47
+
48
+ function steps() {
49
+ if (!data || hidden()) return '';
29
50
  const done = STEPS.filter(([k]) => data.steps[k]).length;
30
- if (done === STEPS.length && !data.checks.length) {
31
- el.innerHTML = plain;
32
- return;
33
- }
34
- el.innerHTML = `<div class="setup">
35
- <div class="setup-head"><h3>Getting started</h3><span class="hint">${done} of ${STEPS.length} done</span><span class="spacer"></span><button class="link" data-setup-hide>Hide</button></div>
51
+ if (done === STEPS.length && !data.checks.length) return '';
52
+ return `<div class="setup-head"><h3>Getting started</h3><span class="hint">${done} of ${STEPS.length} done</span><span class="spacer"></span><button class="link" data-setup-hide>Hide</button></div>
36
53
  ${data.checks.length ? `<div class="setup-checks">${data.checks.map((c) => `<div class="setup-check ${c.status}"><b>${esc(c.name)}</b>: ${esc(c.detail)}${c.fix ? `<div class="hint">${esc(c.fix)}</div>` : ''}</div>`).join('')}</div>` : ''}
37
54
  <ol class="setup-steps">${STEPS.map(([k, title, why, btn, label]) => `<li class="${data.steps[k] ? 'done' : ''}">
38
55
  <span class="setup-mark">${data.steps[k] ? '✓' : ''}</span>
39
56
  <div><b>${title}</b><div class="hint">${why}</div></div>
40
- ${data.steps[k] ? '' : `<button class="small" data-setup-open="${btn}">${label}</button>`}</li>`).join('')}</ol>
57
+ ${data.steps[k] ? '' : `<button class="small" data-setup-open="${btn}">${label}</button>`}</li>`).join('')}</ol>`;
58
+ }
59
+
60
+ function render() {
61
+ el.innerHTML = `<div class="setup">
41
62
  <p class="hint">${plain}</p>
63
+ ${steps()}
64
+ <h3 class="setup-features-head">What you can do</h3>
65
+ <ul class="setup-features">${FEATURES.map(([title, why, f = {}]) => `<li><div><b>${title}</b><div class="hint">${why}</div></div>${action(f)}</li>`).join('')}</ul>
42
66
  </div>`;
43
67
  }
44
68
 
45
69
  el.addEventListener('click', (e) => {
46
70
  const open = e.target.closest('[data-setup-open]');
47
71
  if (open) return document.querySelector(open.dataset.setupOpen)?.click();
72
+ const tab = e.target.closest('[data-setup-tab]');
73
+ if (tab) return document.querySelector(`.side-tab[data-tab="${tab.dataset.setupTab}"]`)?.click();
48
74
  if (e.target.closest('[data-setup-hide]')) {
49
75
  try {
50
76
  localStorage.setItem('agentmgr.setupHidden', '1');
package/public/style.css CHANGED
@@ -207,7 +207,7 @@ body.term-max .layout { grid-template-columns: 1fr; }
207
207
 
208
208
  /* ---------- detail */
209
209
  .detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
210
- .placeholder { margin: auto; color: var(--muted); padding: 24px; text-align: center; }
210
+ .placeholder { margin: auto; color: var(--muted); padding: 24px; text-align: center; max-height: 100%; overflow-y: auto; box-sizing: border-box; min-height: 0; width: 100%; }
211
211
  #detail-body { padding: 16px 20px; overflow-y: auto; flex: 0 1 auto; max-height: 100%; }
212
212
  .detail.with-term #detail-body { max-height: var(--brief-h, 42%); border-bottom: 1px solid var(--border); }
213
213
  .detail.with-term.brief-sized #detail-body { height: var(--brief-h); flex: none; }
@@ -735,7 +735,7 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
735
735
  .pb-form { border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-top: 10px; }
736
736
  .memory-text { width: 100%; font: 12px/1.5 var(--mono); }
737
737
  #memory-dialog { width: min(760px, calc(100vw - 32px)); }
738
- .setup { max-width: 640px; margin: 24px auto; text-align: left; color: var(--text); }
738
+ .setup { max-width: 820px; margin: 24px auto; text-align: left; color: var(--text); }
739
739
  .setup-head { display: flex; gap: 10px; align-items: baseline; }
740
740
  .setup-head h3 { margin: 0; font-size: 16px; }
741
741
  .setup-steps { list-style: none; padding: 0; margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
@@ -743,6 +743,11 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
743
743
  .setup-steps li.done { opacity: .6; }
744
744
  .setup-mark { color: var(--green); font-weight: 700; }
745
745
  .setup-check { padding: 6px 10px; border-radius: 6px; margin: 6px 0; border-left: 3px solid var(--yellow); background: var(--panel-2); font-size: 13px; }
746
+ .setup > .hint:first-child { text-align: center; margin: 0 0 16px; }
747
+ .setup-features-head { margin: 22px 0 8px; font-size: 16px; }
748
+ .setup-features { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px; }
749
+ .setup-features li { display: flex; flex-direction: column; justify-content: space-between; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
750
+ .setup-features li > :last-child:not(div) { align-self: flex-start; }
746
751
  .setup-check.fail { border-left-color: var(--red); }
747
752
 
748
753
  /* ---------------------------------------------------------------- menu and settings */
@@ -809,3 +814,18 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
809
814
  .machine-dialog[open] { display: flex; flex-direction: column; }
810
815
  .machine-dialog .terminal { flex: 1; min-height: 0; }
811
816
  .machine-dialog #machine-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8b93a7; margin-left: 8px; }
817
+
818
+ /* Diagrams: big pictures, one under another, for a quick grasp. */
819
+ #doc-body.diagrams-view h2 { margin: 26px 0 8px; padding-top: 14px; border-top: 1px solid var(--border); }
820
+ #doc-body.diagrams-view h2:first-of-type { border-top: 0; padding-top: 0; }
821
+ #doc-body.diagrams-view .diagram { display: flex; justify-content: center; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); overflow-x: auto; }
822
+ #doc-body.diagrams-view .diagram svg { max-width: 100%; height: auto; }
823
+ @media (prefers-reduced-motion: reduce) { .diagram svg * { animation: none !important; } }
824
+
825
+ .row.intent-split { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; margin-bottom: 4px; }
826
+ .intent-split > span:first-child { font-weight: 600; }
827
+ .intent-split .check { margin: 0; }
828
+ .intent-split[hidden] { display: none; }
829
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
830
+ .hint.warn { display: block; margin-top: 4px; color: var(--yellow); }
831
+ .hint.warn[hidden] { display: none; }
@@ -14,7 +14,8 @@ export const post = (url, body) =>
14
14
 
15
15
  /**
16
16
  * Open the event stream (`{ events: true }`) or a terminal (`{ agentId, kind }`).
17
- * Handlers: onOpen(), onMessage(msg), onClose({ code, reason }) when it ends for good.
17
+ * Handlers: onOpen(), onMessage(msg), onClose({ code, reason }) when it ends for good, and
18
+ * onInputLost(text) when typing could not be delivered (remote only).
18
19
  * Returns { send(msg), close() } where msg is { t: 'i', d } (input) or { t: 'r', cols, rows } (resize).
19
20
  */
20
21
  export function openStream({ events = false, agentId, kind = 'claude' }, h) {
@@ -43,16 +44,31 @@ export function openStream({ events = false, agentId, kind = 'claude' }, h) {
43
44
  }
44
45
  };
45
46
 
46
- // Keystrokes: coalesced and sent strictly in order, one request at a time.
47
+ // Keystrokes: coalesced and sent strictly in order, one request at a time. A request that fails
48
+ // (a network blip, a phone waking up) is retried, not dropped: losing a line of a paste would run
49
+ // the rest without it.
47
50
  let queue = '';
48
51
  let inflight = false;
52
+ const deliver = async (d) => {
53
+ for (let attempt = 0; attempt < 6 && !closed; attempt++) {
54
+ try {
55
+ const res = await post(`/api/agents/${agentId}/term/input`, { d, kind });
56
+ // A refusal (signed out, terminal gone) won't change on a retry.
57
+ if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)) return res.ok;
58
+ } catch {
59
+ // Offline for a moment; try again.
60
+ }
61
+ await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
62
+ }
63
+ return false;
64
+ };
49
65
  const pump = async () => {
50
66
  if (inflight || !queue) return;
51
67
  inflight = true;
52
68
  const d = queue;
53
69
  queue = '';
54
70
  try {
55
- await post(`/api/agents/${agentId}/term/input`, { d, kind });
71
+ if (!(await deliver(d)) && !closed) h.onInputLost?.(d);
56
72
  } finally {
57
73
  inflight = false;
58
74
  if (queue) pump();
package/src/brief.js CHANGED
@@ -342,6 +342,75 @@ export function docPrompt(kind, { intent, status, digest, repo, briefMarkdown, a
342
342
  ].join('\n');
343
343
  }
344
344
 
345
+ // ------------------------------------------------------------------ Diagrams
346
+
347
+ const DIAGRAM_SET = [
348
+ ['The problem', 'a flowchart of the situation today and where it goes wrong (the root cause, from the analysis)'],
349
+ ['Architecture', 'a flowchart of the components and how they connect, grouped with subgraphs; mark the components this work adds or changes with class "changed"'],
350
+ ['Design', 'a classDiagram (or erDiagram for data) of the key modules, types or records and their relationships'],
351
+ ['Data flow', 'a flowchart LR of how data moves through the system, with the main path animated'],
352
+ ['Key interaction', 'a sequenceDiagram (with autonumber) of the most important exchange between components, services or the user'],
353
+ ['Plan', 'a flowchart of the delivery steps or a stateDiagram-v2 of the main lifecycle, whichever the work is about'],
354
+ ];
355
+
356
+ /**
357
+ * Diagrams only: the problem and its solution drawn for a quick grasp, from the documents
358
+ * already written for this work (analysis, design, flow, plan) and the agent's live context.
359
+ */
360
+ export function diagramsPrompt({ intent, status, digest, repo, briefMarkdown, documents, repoMap }) {
361
+ return [
362
+ "Draw diagrams that let a person grasp an AI coding agent's work at a glance: the problem and how it is being solved.",
363
+ 'Diagrams only. No paragraphs, bullets, captions, notes or explanations outside the diagrams.',
364
+ '',
365
+ 'Output Markdown in exactly this shape: a level-1 title "# Diagrams: <short name of the work>", then for each diagram a',
366
+ 'level-2 heading with its name followed by one ```mermaid code block. Nothing else.',
367
+ '',
368
+ 'The diagrams, in this order (leave one out only when the context has nothing for it):',
369
+ ...DIAGRAM_SET.map(([name, what], i) => `${i + 1}. "${name}": ${what}.`),
370
+ '',
371
+ 'Rules:',
372
+ '- Valid Mermaid syntax. At most about 15 nodes per diagram; labels of five words or fewer; quote labels with punctuation, e.g. A["Parse (JSON)"]. No HTML in labels.',
373
+ '- Animate the main path in every flowchart: give its edges ids and animate them, e.g.',
374
+ ' A e1@--> B',
375
+ ' B e2@-->|"saves"| C',
376
+ ' e1@{ animate: true }',
377
+ ' e2@{ animate: true }',
378
+ '- In the architecture diagram, style what this work adds or changes: `classDef changed stroke-width:3px,stroke:#22c55e` then `class X,Y changed`.',
379
+ '- Use the real names of files, modules, services and data from the context. Prefer what the documents below say; do not invent components.',
380
+ '- Output only the Markdown.',
381
+ '',
382
+ contextBlock({ intent, status, digest, repo, briefMarkdown }).join('\n'),
383
+ '<documents_already_written>',
384
+ documents || '(none yet: draw from the context above)',
385
+ '</documents_already_written>',
386
+ '<repository_layout>',
387
+ repoMap || '(no repository)',
388
+ '</repository_layout>',
389
+ ].join('\n');
390
+ }
391
+
392
+ /** Keep only the title, the diagram headings and the Mermaid blocks of a reply. */
393
+ export function diagramsOnly(markdown) {
394
+ const out = [];
395
+ let block = null;
396
+ for (const line of String(markdown ?? '').split('\n')) {
397
+ if (block) {
398
+ if (/^\s*```\s*$/.test(line)) {
399
+ if (block.length) out.push('```mermaid', ...block, '```', '');
400
+ block = null;
401
+ } else block.push(line);
402
+ } else if (/^\s*```\s*mermaid\s*$/i.test(line)) block = [];
403
+ else if (/^#{1,2} \S/.test(line)) out.push(line, '');
404
+ }
405
+ if (!out.some((l) => l === '```mermaid')) throw new Error('The reply had no diagrams');
406
+ // A heading left with no diagram under it says nothing.
407
+ return out
408
+ .filter((l, i) => !/^## /.test(l) || out.slice(i + 1).find((x) => x !== '') === '```mermaid')
409
+ .join('\n')
410
+ .replace(/\n{3,}/g, '\n\n')
411
+ .trim();
412
+ }
413
+
345
414
  // ------------------------------------------------------------------ Human vs AI
346
415
 
347
416
  export const contextBlock = ({ intent, status, digest, repo, briefMarkdown }) => [
package/src/cli.js CHANGED
@@ -404,9 +404,9 @@ export async function main(argv) {
404
404
 
405
405
  program
406
406
  .command('doc')
407
- .description('Write a stakeholder document from the agent work: analysis, design, flow, proposal or humanai (Human vs AI)')
407
+ .description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI) or diagrams (diagrams only)')
408
408
  .argument('<agent>')
409
- .argument('<kind>', 'analysis, design, flow, proposal, humanai, or all (background)')
409
+ .argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, or all (background; diagrams not included)')
410
410
  .option('-o, --output <file>', 'also save the Markdown to this file')
411
411
  .option('--cached', 'print the last version instead of writing a new one')
412
412
  .option('-b, --background', 'start writing and return at once (kind "all" writes all five)')
@@ -2020,10 +2020,21 @@ export async function main(argv) {
2020
2020
  console.log(`${green('✓')} saved ${r.agents} agents (${r.running} running), scrollback for ${r.scrollback}, at ${r.at}`);
2021
2021
  });
2022
2022
 
2023
+ // The daemon's own terminals (agent shells, Open Terminal, the agents themselves) close when it
2024
+ // stops, and the hangup would end a restart typed there halfway: daemon stopped, never started.
2025
+ // So a restart from one ignores the hangup and its closed output, and finishes without it.
2026
+ const outliveTerminal = () => {
2027
+ if (!process.env.AGENTMGR_TERMINAL) return;
2028
+ console.log(dim('This terminal belongs to the daemon and closes while it restarts; the restart carries on. Reopen the terminal afterwards.'));
2029
+ process.on('SIGHUP', () => {});
2030
+ for (const out of [process.stdout, process.stderr]) out.on('error', () => {});
2031
+ };
2032
+
2023
2033
  program
2024
2034
  .command('restart')
2025
2035
  .description('Flush, stop the daemon, and start it again; running agents resume their sessions')
2026
2036
  .action(async () => {
2037
+ outliveTerminal();
2027
2038
  const svc = await import('./service.js');
2028
2039
  const before = await health();
2029
2040
  if (before && !before.closing) {
@@ -2075,6 +2086,7 @@ export async function main(argv) {
2075
2086
  const r = await up.installUpdate({ log: (m) => console.log(dim(m)) });
2076
2087
  console.log(`${green('✓')} installed ${r.to} (was ${r.from})`);
2077
2088
  if (!(await health())) return console.log(dim('The daemon is not running; it starts on the new version next time'));
2089
+ outliveTerminal();
2078
2090
  // The new version's own restart, not the copy of it loaded in this process.
2079
2091
  const { spawnSync } = await import('node:child_process');
2080
2092
  const res = spawnSync(process.execPath, [process.argv[1], 'restart'], { stdio: 'inherit' });
@@ -5,8 +5,9 @@ import path from 'node:path';
5
5
  import antigravity from './antigravity.js';
6
6
  import claude from './claude.js';
7
7
  import codex from './codex.js';
8
+ import muse from './muse.js';
8
9
 
9
- export const ENGINES = { claude, codex, antigravity };
10
+ export const ENGINES = { claude, codex, antigravity, muse };
10
11
  export const DEFAULT_ENGINE = 'claude';
11
12
 
12
13
  /** Adds an engine from a plugin. Built-in engines can't be replaced. */
@@ -33,7 +34,14 @@ export function notInstalled(engine, config) {
33
34
 
34
35
  /** Every engine, with whether it can run here, for pickers and the hub. */
35
36
  export function listEngines(config) {
36
- return Object.values(ENGINES).map((e) => ({ id: e.id, label: e.label, installed: installed(e, config), permissionModes: e.permissionModes, supports: e.supports }));
37
+ return Object.values(ENGINES).map((e) => {
38
+ const ok = installed(e, config);
39
+ let notice = null;
40
+ try {
41
+ notice = ok ? (e.notice?.(config) ?? null) : null;
42
+ } catch {}
43
+ return { id: e.id, label: e.label, installed: ok, permissionModes: e.permissionModes, supports: e.supports, notice };
44
+ });
37
45
  }
38
46
 
39
47
  /** The engine for an agent (or an engine id). */
@@ -0,0 +1,303 @@
1
+ // Meta's Muse Code CLI (`muse`) as an agent engine (checked against Muse Code 1.4.14; fixtures in
2
+ // test/fixtures/muse). Differences from Claude Code that shape this adapter:
3
+ // - Muse picks its own session IDs; the manager adopts the one it creates. Sessions live in
4
+ // $XDG_DATA_HOME/muse/sessions/YYYY/MM/DD/<id>/session.jsonl, and the runtime.session.metadata
5
+ // record near the top holds the folder it ran in (workspace_root).
6
+ // - `muse resume <id>` takes no message, so a message for a resumed session is typed once it
7
+ // is idle (supports.resumePrompt: false).
8
+ // - No flag for extra instructions: they go in the first message of a new session.
9
+ // - The log is Muse's own event log; this reads only the run events (prompt, reply, usage).
10
+ // Tool calls aren't read yet, so stuck detection has no commands or edits for Muse agents.
11
+ // - Approval questions ("Would you like to run the following command?") are matched from the
12
+ // wording in the binary; the trust question was seen on a real screen.
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { extractRefs } from '../tracker.js';
17
+
18
+ // MUSE_DATA_HOME is AI Agent Manager's own override (tests); Muse itself uses the XDG folder.
19
+ const dataHome = () => process.env.MUSE_DATA_HOME || path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'muse');
20
+ const sessionsDir = () => path.join(dataHome(), 'sessions');
21
+ const configHome = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'muse');
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
23
+ const real = (p) => {
24
+ try {
25
+ return fs.realpathSync(p);
26
+ } catch {
27
+ return path.resolve(p);
28
+ }
29
+ };
30
+ const ANSI = /\x1b(\[[0-9;?<>=]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[()][0-9A-B]|[=>78c])/g;
31
+ const readdir = (d) => {
32
+ try {
33
+ return fs.readdirSync(d);
34
+ } catch {
35
+ return [];
36
+ }
37
+ };
38
+
39
+ /** Day folders from `sinceMs` to today (sessions are filed by their start date). */
40
+ function dayDirs(sinceMs) {
41
+ const out = [];
42
+ const d = new Date(Math.min(sinceMs, Date.now()));
43
+ d.setHours(0, 0, 0, 0);
44
+ for (let i = 0; i < 400 && d.getTime() <= Date.now(); i++, d.setDate(d.getDate() + 1)) {
45
+ out.push(path.join(sessionsDir(), String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0')));
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** The folder a session ran in: workspace_root in its runtime.session.metadata record (one of the first lines). */
51
+ function sessionCwd(file) {
52
+ try {
53
+ const fd = fs.openSync(file, 'r');
54
+ try {
55
+ const buf = Buffer.alloc(256 * 1024);
56
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
57
+ for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
58
+ if (!line.includes('"runtime.session.metadata"')) continue;
59
+ const m = /"(?:workspace_root|cwd)"\s*:\s*("(?:[^"\\]|\\.)*")/.exec(line);
60
+ return m ? JSON.parse(m[1]) : null;
61
+ }
62
+ return null;
63
+ } finally {
64
+ fs.closeSync(fd);
65
+ }
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ const found = new Map(); // session id -> file
72
+ function findSession(sessionId) {
73
+ if (!UUID.test(String(sessionId))) return null;
74
+ const hit = found.get(sessionId);
75
+ if (hit && fs.existsSync(hit)) return hit;
76
+ // Newest days first; a session resumed today is still filed under the day it started.
77
+ for (const y of readdir(sessionsDir()).filter((n) => /^\d{4}$/.test(n)).sort().reverse()) {
78
+ for (const m of readdir(path.join(sessionsDir(), y)).sort().reverse()) {
79
+ for (const d of readdir(path.join(sessionsDir(), y, m)).sort().reverse()) {
80
+ const file = path.join(sessionsDir(), y, m, d, sessionId, 'session.jsonl');
81
+ if (fs.existsSync(file)) {
82
+ found.set(sessionId, file);
83
+ return file;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /** Read the complete lines added since the cursor's offset. */
92
+ function newRecords(file, cursor) {
93
+ let fd;
94
+ try {
95
+ fd = fs.openSync(file, 'r');
96
+ } catch {
97
+ return null;
98
+ }
99
+ try {
100
+ const { size, mtime } = fs.fstatSync(fd);
101
+ cursor.lastActivity = mtime.toISOString();
102
+ if (size < cursor.offset) cursor.offset = 0;
103
+ if (size === cursor.offset) return [];
104
+ const buf = Buffer.alloc(size - cursor.offset);
105
+ fs.readSync(fd, buf, 0, buf.length, cursor.offset);
106
+ const lastNl = buf.lastIndexOf(0x0a);
107
+ if (lastNl < 0) return [];
108
+ const start = cursor.offset;
109
+ cursor.offset += lastNl + 1;
110
+ const out = [];
111
+ let at = start;
112
+ for (const line of buf.subarray(0, lastNl).toString('utf8').split('\n')) {
113
+ const here = at;
114
+ at += Buffer.byteLength(line) + 1;
115
+ // Only the run events matter here; skip the rest without parsing it.
116
+ if (!line.includes('"runtime.session"') || !line.includes('"kind":"run"')) continue;
117
+ try {
118
+ out.push({ rec: JSON.parse(line), at: here });
119
+ } catch {}
120
+ }
121
+ return out;
122
+ } finally {
123
+ fs.closeSync(fd);
124
+ }
125
+ }
126
+
127
+ /** A run event: { kind, ...fields } for the person's message, the reply, usage and the end of a turn. */
128
+ const runEvent = (rec) => (rec.payload_type === 'runtime.session' && rec.payload?.kind === 'run' ? rec.payload.event ?? null : null);
129
+ const atOf = (rec) => (rec.recorded_at ? Math.floor(rec.recorded_at / 1000) : Date.now());
130
+
131
+ /**
132
+ * Permission modes in Muse terms: a built-in permission profile, or no approval and no sandbox.
133
+ * Muse's own default is the Auto-review profile (a model reviews what isn't plainly safe).
134
+ */
135
+ const MODES = {
136
+ default: [],
137
+ acceptEdits: [],
138
+ plan: ['--permission-profile', ':read-only'],
139
+ auto: ['--permission-profile', ':auto-review'],
140
+ bypassPermissions: ['--yolo'],
141
+ };
142
+
143
+ /** The model Muse is set to use (its settings file), or null. */
144
+ function configuredModel() {
145
+ try {
146
+ return JSON.parse(fs.readFileSync(path.join(configHome(), 'settings.json'), 'utf8')).model ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ export default {
153
+ id: 'muse',
154
+ label: 'Muse Code',
155
+ supports: { chooseSessionId: false, cost: false, planHandoff: false, fork: false, oneShot: true, resumePrompt: false },
156
+ permissionModes: Object.keys(MODES),
157
+ // Muse reads the first of these it finds, walking up to the repo root.
158
+ rulesFiles: ['AGENTS.md', 'CLAUDE.md'],
159
+ install: 'Install Muse Code (curl -fsSL https://dev.meta.ai/install.sh | bash) and run `muse login`',
160
+
161
+ // On a hangup Muse records no clean end and warns on the next resume; SIGTERM ends it cleanly.
162
+ stopSignal: 'SIGTERM',
163
+
164
+ bin: (config) => config.engines?.muse?.bin ?? 'muse',
165
+ extraArgs: (config) => [...(config.engines?.muse?.args ?? [])],
166
+
167
+ /** Something the person should know before choosing Muse, or null. */
168
+ notice() {
169
+ const model = configuredModel();
170
+ return /contributor/i.test(model ?? '') ? `Contributor mode (${model}): Meta may use your code and conversations to improve its products.` : null;
171
+ },
172
+
173
+ /**
174
+ * Full arguments for a launch. The folder is trusted for the run (nothing is saved), so the
175
+ * trust question doesn't stop a new agent. Resume takes no message; it is typed when idle.
176
+ */
177
+ launchArgs({ extra = [], permissionMode, model, instructions, sessionId, resume, prompt }) {
178
+ const opts = [...extra, '--trust-workspace', ...(MODES[permissionMode] ?? MODES.default)];
179
+ if (model) opts.push('--model', model);
180
+ if (resume) return ['resume', sessionId, ...opts];
181
+ const first = [instructions, prompt && (instructions ? `Your task:\n${prompt}` : prompt)].filter(Boolean).join('\n\n---\n\n');
182
+ // "--" so a message starting with "-" isn't read as a flag.
183
+ return [...opts, ...(first ? ['--', first] : [])];
184
+ },
185
+
186
+ sessionFile: (cwd, sessionId) => findSession(sessionId) ?? path.join(sessionsDir(), `missing-${sessionId}`, 'session.jsonl'),
187
+ hasSession: (cwd, sessionId) => Boolean(findSession(sessionId)),
188
+ subagentFiles: () => [],
189
+
190
+ /** Sessions Muse started in this folder since a time; the manager adopts a new one. */
191
+ recentSessions(cwd, sinceMs) {
192
+ const want = real(cwd);
193
+ const out = [];
194
+ for (const dir of dayDirs(sinceMs)) {
195
+ for (const id of readdir(dir)) {
196
+ if (!UUID.test(id)) continue;
197
+ const file = path.join(dir, id, 'session.jsonl');
198
+ let st;
199
+ try {
200
+ st = fs.statSync(file);
201
+ } catch {
202
+ continue;
203
+ }
204
+ if (st.mtimeMs < sinceMs) continue;
205
+ const where = sessionCwd(file);
206
+ if (!where || real(where) !== want) continue;
207
+ found.set(id, file);
208
+ out.push({ sessionId: id, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs });
209
+ }
210
+ }
211
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
212
+ },
213
+
214
+ /** Usage (one entry per model call), PR and bug links from the person's messages and the replies. */
215
+ scan(file, cursor, bugPatterns = []) {
216
+ const result = { prs: [], bugs: [], title: null, lastPrompt: null, cost: null, lastActivity: null };
217
+ cursor.usage ??= new Map();
218
+ cursor.offset ??= 0;
219
+ const recs = newRecords(file, cursor);
220
+ result.lastActivity = cursor.lastActivity ?? null;
221
+ if (!recs) return result;
222
+ for (const { rec, at } of recs) {
223
+ const e = runEvent(rec);
224
+ if (!e) continue;
225
+ if (e.kind === 'model_completed' && e.usage) {
226
+ const u = e.usage;
227
+ const cached = u.cached_tokens ?? 0;
228
+ // Cached tokens are counted inside input_tokens, as with OpenAI's API.
229
+ cursor.usage.set(`r${at}`, { input: Math.max(0, (u.input_tokens ?? 0) - cached), output: (u.output_tokens ?? 0) + (u.reasoning_tokens ?? 0), cacheCreation: 0, cacheRead: cached });
230
+ } else if (e.kind === 'started' || e.kind === 'assistant_message_committed') {
231
+ const text = String(e.prompt ?? e.text ?? '');
232
+ if (e.kind === 'started' && text) result.lastPrompt = text.slice(0, 500);
233
+ const refs = extractRefs(text, bugPatterns);
234
+ result.prs.push(...refs.prs);
235
+ result.bugs.push(...refs.bugs);
236
+ }
237
+ }
238
+ return result;
239
+ },
240
+
241
+ /** Commands and edits for stuck detection: not read from Muse's log yet. */
242
+ activity(file, state) {
243
+ state.events ??= [];
244
+ return state;
245
+ },
246
+
247
+ /** A readable digest of the conversation, for Explain and documents. */
248
+ digest(file, { maxChars = 24_000 } = {}) {
249
+ const events = [];
250
+ for (const { rec } of newRecords(file, { offset: 0 }) ?? []) {
251
+ const e = runEvent(rec);
252
+ if (e?.kind === 'started' && e.prompt) events.push(`[user] ${String(e.prompt).slice(0, 1200)}`);
253
+ else if (e?.kind === 'assistant_message_committed' && e.text) events.push(`[agent] ${String(e.text).slice(0, 1800)}`);
254
+ }
255
+ const head = events.find((e) => e.startsWith('[user]'));
256
+ const tail = [];
257
+ let used = head?.length ?? 0;
258
+ for (let i = events.length - 1; i >= 0 && events[i] !== head; i--) {
259
+ if (used + events[i].length > maxChars) {
260
+ tail.unshift('… (earlier events omitted) …');
261
+ break;
262
+ }
263
+ tail.unshift(events[i]);
264
+ used += events[i].length + 1;
265
+ }
266
+ return { digest: [head, ...tail].filter(Boolean).join('\n'), events: events.length, edited: [] };
267
+ },
268
+
269
+ copySession: () => false,
270
+
271
+ attention: [
272
+ [/Doyoutrustthisworkspace\?/i, 'Folder trust prompt'],
273
+ [/Wouldyouliketo(run|allow)/i, 'Permission prompt'],
274
+ ],
275
+
276
+ /** Muse's menus: "Do you trust this workspace? … ⟩ 1 Trust and continue 2 Quit Use Up/Down…". */
277
+ promptChoices(screen) {
278
+ const text = String(screen ?? '')
279
+ .replace(/\x1b\[(\d*)C/g, ' ')
280
+ .replace(ANSI, ' ')
281
+ .replace(/\s+/g, ' ');
282
+ const at = Math.max(text.lastIndexOf('Would you like'), text.lastIndexOf('Do you trust this workspace'));
283
+ if (at < 0) return { question: '', choices: [] };
284
+ const tail = text.slice(at);
285
+ const question = (/^(.*?\?)/.exec(tail)?.[1] ?? '').slice(0, 200);
286
+ const choices = new Map();
287
+ for (const m of tail.matchAll(/(?:⟩\s*)?\b(\d)\.?\s+(.+?)(?=\s+(?:⟩\s*)?\d\.?\s+[A-Z]|\s+Use Up\/Down|\s+Esc |$)/g)) {
288
+ if (!choices.has(m[1])) choices.set(m[1], m[2].trim().slice(0, 100));
289
+ }
290
+ return { question, choices: [...choices.entries()].sort(([a], [b]) => a - b).map(([key, label]) => ({ key, label })) };
291
+ },
292
+
293
+ /** Nothing to save: each launch passes --trust-workspace (trust for that run only). */
294
+ trust: () => false,
295
+
296
+ /** One-shot in the read-only profile (no writes or side-effecting commands), no web tools, not logged; the prompt is the last argument. */
297
+ oneShotArgs: (config, { model, prompt = '', extra = [] } = {}) => ['exec', ...extra, '--no-session-log', '--permission-profile', ':read-only', '--disable-web-tools', ...(model ? ['--model', model] : []), '--', prompt],
298
+ oneShotStdin: false,
299
+ // `muse exec` takes options only after the subcommand, configured extra arguments included.
300
+ oneShotExtra: true,
301
+
302
+ stripEnv: [],
303
+ };
package/src/manager.js CHANGED
@@ -8,6 +8,8 @@ import { expandHome, paths } from './config.js';
8
8
  import { DEFAULT_ENGINE, ENGINES, engineOf, installed, notInstalled } from './engines/index.js';
9
9
  import {
10
10
  DOC_KINDS,
11
+ diagramsOnly,
12
+ diagramsPrompt,
11
13
  briefDir,
12
14
  briefPath,
13
15
  briefPrompt,
@@ -33,11 +35,24 @@ const SUMMARIES = {
33
35
  insights: { field: 'insights', prompt: insightsPrompt },
34
36
  ...Object.fromEntries(Object.keys(DOC_KINDS).map((k) => [k, { doc: k, prompt: (ctx) => docPrompt(k, ctx) }])),
35
37
  humanai: { doc: 'humanai', run: 'runHumanAi' },
38
+ diagrams: { doc: 'diagrams', run: 'runDiagrams' },
36
39
  };
37
40
  /** Read / write where a summary of `kind` is kept on the agent record. */
38
41
  const getSummary = (agent, kind) => (SUMMARIES[kind].doc ? agent.docs?.[kind] : agent[SUMMARIES[kind].field]);
39
42
  const summaryPatch = (agent, kind, value) =>
40
43
  SUMMARIES[kind].doc ? { docs: { ...agent.docs, [kind]: value } } : { [SUMMARIES[kind].field]: value };
44
+
45
+ /** The folders of a repo with how many files each holds, two levels deep: a map for drawing it. */
46
+ async function repoLayout(dir) {
47
+ const files = (await git.run('git', ['ls-files'], { cwd: dir, timeout: 20_000 })).split('\n').filter(Boolean);
48
+ const counts = new Map();
49
+ for (const f of files) {
50
+ const parts = f.split('/');
51
+ const key = parts.length > 2 ? `${parts[0]}/${parts[1]}/` : parts.length === 2 ? `${parts[0]}/` : f;
52
+ counts.set(key, (counts.get(key) ?? 0) + 1);
53
+ }
54
+ return [...counts].slice(0, 150).map(([k, n]) => (k.endsWith('/') ? `${k} (${n} files)` : k)).join('\n');
55
+ }
41
56
  import { spawn } from 'node:child_process';
42
57
  import * as git from './git.js';
43
58
  import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
@@ -847,15 +862,15 @@ export class AgentManager extends EventEmitter {
847
862
  resume = true;
848
863
  }
849
864
  }
850
- const args = engine.launchArgs({
851
- ...this.launchOptions(agent),
852
- sessionId: sid,
853
- resume,
854
- name: agent.name,
855
- // A team member's first message is its first step, not just the problem.
856
- prompt: resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent),
857
- });
865
+ // A team member's first message is its first step, not just the problem.
866
+ const prompt = resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent);
867
+ const args = engine.launchArgs({ ...this.launchOptions(agent), sessionId: sid, resume, name: agent.name, prompt });
858
868
  const proc = this.spawnAgent(agent, agent.workdir, args);
869
+ // Engines whose resume takes no message get it typed once the session is idle.
870
+ if (resume && prompt && engine.supports.resumePrompt === false) {
871
+ if (!this.outbox.has(agent.id)) this.outbox.set(agent.id, []);
872
+ this.outbox.get(agent.id).unshift({ text: prompt, source: 'system', at: now() });
873
+ }
859
874
 
860
875
  const session = agent.sessions.find((s) => s.id === sid);
861
876
  if (session) {
@@ -1102,7 +1117,8 @@ export class AgentManager extends EventEmitter {
1102
1117
  done.dispose();
1103
1118
  resolve(this.view(this.store.get(agent.id) ?? agent));
1104
1119
  });
1105
- rt.pty.kill('SIGHUP');
1120
+ // A hangup, as when a terminal closes, unless the engine shuts down cleanly on another signal.
1121
+ rt.pty.kill(engineOf(agent).stopSignal ?? 'SIGHUP');
1106
1122
  rt.killTimer = setTimeout(() => rt.pty?.kill('SIGKILL'), timeoutMs);
1107
1123
  });
1108
1124
  }
@@ -1168,7 +1184,7 @@ export class AgentManager extends EventEmitter {
1168
1184
  }
1169
1185
 
1170
1186
  childEnv(agent) {
1171
- const env = { ...process.env, ...(agent ? { AGENTMGR_AGENT_ID: agent.id } : {}), TERM: 'xterm-256color', COLORTERM: 'truecolor' };
1187
+ const env = { ...process.env, ...(agent ? { AGENTMGR_AGENT_ID: agent.id } : {}), TERM: 'xterm-256color', COLORTERM: 'truecolor', AGENTMGR_TERMINAL: '1' };
1172
1188
  for (const key of STRIP_ENV) delete env[key];
1173
1189
  return env;
1174
1190
  }
@@ -1926,8 +1942,10 @@ export class AgentManager extends EventEmitter {
1926
1942
 
1927
1943
  /** One tool-less, read-only call to an engine; resolves to its text output. */
1928
1944
  oneShot(agent, text, { engine, model, timeoutMs = 300_000 }) {
1929
- // Some engines take the prompt as an argument instead of on stdin.
1930
- const args = [...engine.extraArgs(this.config), ...engine.oneShotArgs(this.config, { model, prompt: text })];
1945
+ // Some engines take the prompt as an argument instead of on stdin, and some need their extra
1946
+ // arguments after a subcommand (oneShotExtra: they place them).
1947
+ const extra = engine.extraArgs(this.config);
1948
+ const args = engine.oneShotExtra ? engine.oneShotArgs(this.config, { model, prompt: text, extra }) : [...extra, ...engine.oneShotArgs(this.config, { model, prompt: text })];
1931
1949
  return new Promise((resolve, reject) => {
1932
1950
  const child = spawn(engine.bin(this.config), args, { cwd: paths.home, env: this.childEnv(agent), stdio: ['pipe', 'pipe', 'pipe'] });
1933
1951
  let out = '';
@@ -1945,6 +1963,37 @@ export class AgentManager extends EventEmitter {
1945
1963
  });
1946
1964
  }
1947
1965
 
1966
+ /** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
1967
+ async runDiagrams(agent, ctx) {
1968
+ const cur0 = this.store.get(agent.id) ?? agent;
1969
+ const written = [
1970
+ ...['analysis', 'design', 'flow', 'proposal'].map((k) => [DOC_KINDS[k].title, cur0.docs?.[k]?.markdown]),
1971
+ ['Implementation plan', cur0.planReview?.markdown],
1972
+ ['Explanation', cur0.explanation?.markdown],
1973
+ ].filter(([, md]) => md);
1974
+ const documents = written.map(([title, md]) => `<document title="${title}">\n${md.slice(0, 12_000)}\n</document>`).join('\n');
1975
+ const repoMap = agent.workdir ? await repoLayout(agent.workdir).catch(() => '') : '';
1976
+ const writer = this.writerFor(agent);
1977
+ const markdown = diagramsOnly(await this.oneShot(agent, diagramsPrompt({ ...ctx, documents, repoMap }), writer));
1978
+ const cur = this.store.get(agent.id);
1979
+ if (!cur) return null;
1980
+ const from = written.map(([title]) => title);
1981
+ return this.view(
1982
+ this.patch(
1983
+ agent.id,
1984
+ summaryPatch(cur, 'diagrams', {
1985
+ markdown,
1986
+ from,
1987
+ writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
1988
+ at: now(),
1989
+ briefUpdatedAt: cur.brief?.updatedAt ?? null,
1990
+ pending: false,
1991
+ error: null,
1992
+ }),
1993
+ ),
1994
+ );
1995
+ }
1996
+
1948
1997
  /** Human vs AI: the writer splits and answers the open questions, then another model double-checks it. */
1949
1998
  async runHumanAi(agent, ctx) {
1950
1999
  const writer = this.writerFor(agent);