ai-agentmanager 1.4.0 → 1.6.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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0 (2026-10-31)
4
+
5
+ - **Changes explained one by one:** `agentmgr doc <agent> walkthrough` explains each hunk of an agent's changes (what, why, risk, what to check) in the order a reviewer should read them, from the same diff the review screen shows. The review screen and a split view for it come next.
6
+ - **Start at login:** a switch in ☰ → Settings installs (or removes) the login service, so the daemon and running agents come back by themselves after a restart, without a terminal command. Getting started lists it until it's on, and `agentmgr ui` mentions it when it's off. Same service as `agentmgr service install` (launchd on macOS, systemd on Linux).
7
+ - **Attachments:** **📎 Attach files** in New agents and Ask Agent: pick, drop or paste screenshots, logs and documents. Each agent gets its own copy, listed in its request, in a folder Claude Code and Codex can read without a prompt (a team or race shares one copy). Backlog items keep theirs until they start. CLI: `--file-attach <path>` on `agentmgr add` and `agentmgr ask`.
8
+ - **Ask Agent:** **Ask Claude** is now **Ask Agent**, with an Engine choice (Claude Code, Codex, Antigravity, Muse Code) and the engine's notice, if any. `agentmgr ask --engine <engine>`.
9
+
10
+ ## 1.5.0 (2026-10-31)
11
+
12
+ - **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.
13
+ - **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).
14
+ - **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.
15
+ - **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`.
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+
3
20
  ## 1.4.0 (2026-10-31)
4
21
 
5
22
  - **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`.
@@ -42,7 +42,7 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
42
42
  - **Follows your conventions.** Before changing anything, agents review `CLAUDE.md` / `AGENTS.md`, skills, hooks and settings (repo and `~/.claude`), and follow them.
43
43
  - **Tokens and cost.** Input, output and cache tokens are tracked per session, subagents included, alongside the cost Claude Code reports.
44
44
  - **No trust prompt.** Folders agents run in are pre-trusted, so Claude's "trust this folder" prompt doesn't block them.
45
- - **Survives restarts and reboots.** Agents that were running when the daemon stopped (restart, crash or reboot) are resumed with the same session IDs when it starts again. `agentmgr service install` starts the daemon at login.
45
+ - **Survives restarts and reboots.** Agents that were running when the daemon stopped (restart, crash or reboot) are resumed with the same session IDs when it starts again. For that after a reboot, turn on **Start at login** in ☰ → Settings (or run `agentmgr service install`); without it, the daemon starts only when you run `agentmgr`.
46
46
  - **Shells next to each agent.** **Open shell** gives a terminal in the agent's folder for running commands by hand. **+ Shell** opens as many more as you need, each in its own tab.
47
47
  - **Browser tabs.** **+ Browser** opens a page as this machine sees it, in a tab next to the agent's terminals. That can be the agent's dev server on `localhost:5173`, a site on its network or VPN, or any address. Local addresses the agent prints are offered as suggestions. It works from your phone or laptop too (see below).
48
48
  - **Live browser.** Browser tabs run a real Chrome on this machine and stream it into the tab: you see its screen and your clicks, scrolling, typing and paste go back to it, from the laptop or the phone. Because the page runs here, `localhost` is this machine, sites that refuse to be framed load normally, and popups (a sign-in window) open as tabs of their own and come back when they close. Chrome (or Chromium, Brave, Edge; `browserBin` in config.json picks one) runs headless with its own profile in `~/.agentmanager/browser-profile`, so sign-ins are remembered; it starts on the first view and stops after 15 minutes with nobody watching. Downloads go to `~/Downloads/agentmanager`. A site that still refuses it as a bot may accept `"browserHeadless": false`, which runs Chrome with a real window on this machine's screen. On Linux where Chrome's sandbox can't start (containers, Ubuntu 23.10+ user-namespace restrictions) the tab says so; `"browserNoSandbox": true` runs it without the sandbox, at your own risk. **Lite** on the bar switches a tab to the frame mode (the page loads in your own browser; quicker for a dev server).
@@ -51,7 +51,8 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
51
51
  - **Your layout.** Drag the edge of the agent list, or the line between the brief and the terminal, to resize them; double-click a line to reset it. **Expand** (Ctrl+Shift+E) fills the window with the terminal, shell or browser tab; **Full screen** fills the screen. Agents active in the last day sort to the top; idle ones are dimmed.
52
52
  - **As an app.** In Chrome or Edge, click the install icon in the address bar (or ⋮ → Cast, save and share → Install page as app). AI Agent Manager then opens in its own window with its own Dock or taskbar icon, and the daemon keeps running as before.
53
53
  - **About.** The version next to the title opens the About box: release date, author, license, links, and how this daemon runs (data folder, port, Node.js, which `claude`). **Check for updates** asks npm for the newest version. In a terminal: `agentmgr about` (`--check` for updates).
54
- - **General sessions.** **Ask Claude** starts a session with no repo, folder or intent, for questions. It is tracked and resumable like any other agent.
54
+ - **General sessions.** **Ask Agent** starts a session with no repo, folder or intent, for questions, on Claude Code or any other engine you have (Codex, Antigravity, Muse Code). It is tracked and resumable like any other agent.
55
+ - **Attachments.** **📎 Attach files** in New agents and Ask Agent (or drop files on the dialog, or paste a screenshot into the text box) adds a screenshot, a log or a document to the request. Each agent gets its own copy in `~/.agentmanager/uploads/<agent>/`, listed in its request, and Claude Code and Codex can read that folder without asking. From the CLI: `agentmgr add … --file-attach error.log` and `agentmgr ask … --file-attach shot.png`.
55
56
 
56
57
  New in 0.2, for running many agents at once:
57
58
 
@@ -83,13 +84,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
83
84
 
84
85
  ```sh
85
86
  npm install -g ai-agentmanager # the latest release
86
- npm install -g ai-agentmanager@1.4.0 # or a specific one
87
+ npm install -g ai-agentmanager@1.6.0 # or a specific one
87
88
  ```
88
89
 
89
90
  Or from the repository, at a release tag:
90
91
 
91
92
  ```sh
92
- git clone --branch v1.4.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
93
+ git clone --branch v1.6.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
93
94
  cd ai-agentmanager-core
94
95
  npm install
95
96
  npm link # optional: puts `agentmgr` on your PATH
@@ -109,7 +110,7 @@ npm link
109
110
  | Installed with | Update |
110
111
  | --- | --- |
111
112
  | 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) |
113
+ | A clone at a release tag | `git fetch --tags && git checkout v1.6.0 && npm install` (the tag you want) |
113
114
  | A clone of `main` | `git pull && npm install` |
114
115
 
115
116
  **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 +328,7 @@ The Brief panel's **Share for review** buttons, or `agentmgr doc <agent> <kind>`
327
328
  | **Flow** | Summary, end-to-end flowchart with a step-by-step walk-through, key interactions as a sequence diagram, failure and edge cases |
328
329
  | **Proposal** | Summary, problem, recommendation, scope, plan, effort and dependencies, risks, decisions needed |
329
330
  | **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. |
331
+ | **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
332
 
331
333
  **Human vs AI.** This review takes two separate read-only calls.
332
334
  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 +725,14 @@ Or use **Remote access** in the header of the UI on this computer.
723
725
  - **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
726
  - **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
727
 
726
- ## Engines: Claude Code, Codex and Antigravity
728
+ ## Engines: Claude Code, Codex, Antigravity and Muse Code
727
729
 
728
730
  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
731
 
730
732
  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
733
 
734
+ 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.
735
+
732
736
  ## Several machines: the hub
733
737
 
734
738
  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.
@@ -917,7 +921,7 @@ Usage comes from the session transcripts and is refreshed every few seconds whil
917
921
  - **From another device** (remote access), AI Agent Manager gives each site its own HTTPS port. That port relays to the site as the machine sees it, so you can check from your phone what the agent built or what the machine can reach. Absolute links, redirects and hot-reload WebSockets on the same site keep working, and the site's "don't show me in a frame" headers are dropped. A link or redirect to a different site leaves the relay.
918
922
  - **Safety:** the relay serves only signed-in devices. It never relays to AI Agent Manager's own ports on any of the machine's addresses, and it removes AI Agent Manager's sign-in cookie before forwarding. Sites other than localhost must present a valid certificate. The relays close when remote access is turned off.
919
923
  - If a tab stays blank on a device, open it once with **↗** and accept the certificate there.
920
- - **Ask Claude** (or `agentmgr ask`) creates a `general` agent. All general sessions share `~/.agentmanager/general`, so Claude's folder-trust prompt only appears the first time.
924
+ - **Ask Agent** (or `agentmgr ask`, with `--engine` for another engine) creates a `general` agent. All general sessions share `~/.agentmanager/general`, so Claude's folder-trust prompt only appears the first time.
921
925
 
922
926
  ## Configuration
923
927
 
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.6.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
@@ -4,6 +4,7 @@ import { marked } from '/vendor/marked/marked.esm.js';
4
4
  import DOMPurify from '/vendor/dompurify/purify.es.mjs';
5
5
  import { touchScroll } from '/touchscroll.js';
6
6
  import { openStream, post, REMOTE } from '/transport.js';
7
+ import { attachBox } from '/attach.js';
7
8
  import { initReview } from '/review.js';
8
9
  import { initLand, prChips } from '/land.js';
9
10
  import { initTriggers } from '/triggers.js';
@@ -245,7 +246,9 @@ const INSIGHT_ICONS = { why: '🎯', what: '📦', how: '🛠', now: '📍' };
245
246
 
246
247
  // ------------------------------------------------------------------ stakeholder documents
247
248
 
248
- const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI' };
249
+ const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI', diagrams: 'Diagrams' };
250
+ // Optional extras that "Write all" leaves out.
251
+ const DOC_EXTRAS = new Set(['diagrams']);
249
252
  const DOC_TITLES = { ...DOCS, plan: 'Implementation plan' };
250
253
  /** What the document dialog shows: a stakeholder document, or the agent's plan under review. */
251
254
  const docOf = (a, kind) => (kind === 'plan' ? a?.planReview && { markdown: a.planReview.markdown, at: a.planReview.updatedAt } : a?.docs?.[kind]);
@@ -308,7 +311,7 @@ function docHtml(markdown) {
308
311
  }
309
312
 
310
313
  function docButtons(a) {
311
- const missing = Object.keys(DOCS).filter((k) => !a.docs?.[k]?.markdown && !a.docs?.[k]?.pending);
314
+ const missing = Object.keys(DOCS).filter((k) => !DOC_EXTRAS.has(k) && !a.docs?.[k]?.markdown && !a.docs?.[k]?.pending);
312
315
  const all = missing.length
313
316
  ? `<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
317
  : '';
@@ -317,7 +320,7 @@ function docButtons(a) {
317
320
  const d = a.docs?.[k];
318
321
  const stale = d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
319
322
  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>`;
323
+ 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
324
  })
322
325
  .join('')}</span>`;
323
326
  }
@@ -332,8 +335,9 @@ function renderDocDialog() {
332
335
  const plan = docView.kind === 'plan';
333
336
  $('#doc-title').textContent = `${label} · ${a?.name ?? ''}`;
334
337
  const body = $('#doc-body');
338
+ body.classList.toggle('diagrams-view', docView.kind === 'diagrams');
335
339
  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>`;
340
+ 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
341
  body.dataset.rendered = '';
338
342
  } else if (d?.error) body.innerHTML = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
339
343
  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 +349,8 @@ function renderDocDialog() {
345
349
  }
346
350
  }
347
351
  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' : ''}` : '';
352
+ 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"}` : ''}` : '';
353
+ $('#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
354
  for (const id of ['doc-copy', 'doc-download', 'doc-print']) $(`#${id}`).disabled = !d?.markdown;
351
355
  $('#doc-print').hidden = plan;
352
356
  $('#doc-regen').hidden = plan;
@@ -1784,6 +1788,9 @@ const term = {
1784
1788
  this.xterm.loadAddon(this.fit);
1785
1789
  this.xterm.open($('#terminal'));
1786
1790
  this.xterm.onData((d) => {
1791
+ // Replaying the backlog makes xterm answer the color and cursor queries in it again; those
1792
+ // answers are stale, and the program would get them as typing.
1793
+ if (this.replaying) return;
1787
1794
  lastTyped = Date.now();
1788
1795
  this.send({ t: 'i', d });
1789
1796
  });
@@ -1842,6 +1849,7 @@ const term = {
1842
1849
  this.conn = null;
1843
1850
  if (ev.code === 1008) toast(`Terminal unavailable: ${ev.reason}`, 5000);
1844
1851
  },
1852
+ onInputLost: () => this.conn === conn && toast('Some typing did not reach the computer (connection lost); check the terminal before going on', 8000),
1845
1853
  },
1846
1854
  );
1847
1855
  this.conn = conn;
@@ -1852,7 +1860,8 @@ const term = {
1852
1860
  if (msg.t === 'hello') {
1853
1861
  // Also sent again after a reconnect: start from a clean screen and replay.
1854
1862
  this.xterm.reset();
1855
- this.xterm.write(msg.backlog);
1863
+ this.replaying = true;
1864
+ this.xterm.write(msg.backlog, () => (this.replaying = false));
1856
1865
  if (!matchMedia('(pointer: coarse)').matches) this.xterm.focus();
1857
1866
  } else if (msg.t === 'error') {
1858
1867
  toast(`Terminal unavailable: ${msg.error}`, 5000);
@@ -1888,6 +1897,8 @@ const term = {
1888
1897
  renderTabs() {
1889
1898
  renderSignIn();
1890
1899
  const a = this.agentId && agents.get(this.agentId);
1900
+ // The agent's own session tab is named after its engine (Codex, Muse Code, …).
1901
+ $('.term-tab[data-kind="claude"]').textContent = ENGINE_LABEL[a?.engine] ?? 'Claude';
1891
1902
  const nums = new Set(a?.runtime.shells ?? []);
1892
1903
  if (isShell(this.kind)) nums.add(shellNum(this.kind));
1893
1904
  $('#shell-tabs').innerHTML = [...nums]
@@ -3137,6 +3148,7 @@ $('#recent-bar').addEventListener('click', (e) => {
3137
3148
  // ------------------------------------------------------------------ new agents dialog
3138
3149
 
3139
3150
  const dialog = $('#new-dialog');
3151
+ const newFiles = attachBox({ slot: $('#new-attach'), form: $('#new-form'), paste: $('#new-form').intents, esc, toast });
3140
3152
  function renderProjectOptions() {
3141
3153
  $('#project-options').innerHTML = projectsState
3142
3154
  .map((p) => `<option value="${esc(p.shortcut)}">${esc(p.name)}${p.repo ? ` · ${esc(p.repo)}` : p.dir ? ` · ${esc(p.dir)}` : ''}</option>`)
@@ -3147,6 +3159,7 @@ function openNewDialog(mode = 'now') {
3147
3159
  renderProjectOptions();
3148
3160
  $('#new-error').hidden = true;
3149
3161
  dialog.querySelector(`input[name="when"][value="${mode}"]`).checked = true;
3162
+ restoreIntentSplit();
3150
3163
  syncWhen();
3151
3164
  dialog.showModal();
3152
3165
  dialog.querySelector('textarea').focus();
@@ -3156,7 +3169,55 @@ function syncWhen() {
3156
3169
  dialog.querySelector('.now-only').hidden = when !== 'now';
3157
3170
  dialog.querySelector('.race-only').hidden = when !== 'race';
3158
3171
  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';
3172
+ // A race or a team always works on the whole text.
3173
+ dialog.querySelector('.intent-split').hidden = when === 'race' || when === 'team';
3174
+ syncIntentPlaceholder();
3175
+ syncIntentCount();
3176
+ }
3177
+
3178
+ /** The form's intents: one per line, or the whole text as one (kept as written, blank lines and all). */
3179
+ function formIntents(form) {
3180
+ const text = form.intents.value;
3181
+ const whole = form.split.value === 'one' || ['race', 'team'].includes(form.when.value);
3182
+ if (whole) return text.trim() ? [text.trim()] : [];
3183
+ return text
3184
+ .split('\n')
3185
+ .map((s) => s.trim())
3186
+ .filter(Boolean);
3187
+ }
3188
+
3189
+ function syncIntentCount() {
3190
+ const form = $('#new-form');
3191
+ const when = form.when.value;
3192
+ const n = formIntents(form).length;
3193
+ const split = !['race', 'team'].includes(when);
3194
+ $('#intent-count').textContent = split && n ? (when === 'backlog' ? `${n} item${n > 1 ? 's' : ''}` : `${n} agent${n > 1 ? 's' : ''}`) : '';
3195
+ const submit = dialog.querySelector('button[type=submit]');
3196
+ 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');
3197
+ }
3198
+ $('#new-form').intents.addEventListener('input', syncIntentCount);
3199
+ for (const r of dialog.querySelectorAll('input[name="split"]')) {
3200
+ r.addEventListener('change', () => {
3201
+ try {
3202
+ localStorage.setItem('agentmgr.intentSplit', r.value);
3203
+ } catch {}
3204
+ syncIntentPlaceholder();
3205
+ syncIntentCount();
3206
+ });
3207
+ }
3208
+ const LINES_PLACEHOLDER = $('#new-form').intents.placeholder;
3209
+ 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.';
3210
+ /** The remembered choice (a form reset forgets it), and a placeholder to match. */
3211
+ function restoreIntentSplit() {
3212
+ const form = $('#new-form');
3213
+ try {
3214
+ form.split.value = localStorage.getItem('agentmgr.intentSplit') === 'one' ? 'one' : 'lines';
3215
+ } catch {}
3216
+ syncIntentPlaceholder();
3217
+ }
3218
+ function syncIntentPlaceholder() {
3219
+ const form = $('#new-form');
3220
+ form.intents.placeholder = form.split.value === 'one' || ['race', 'team'].includes(form.when.value) ? WHOLE_PLACEHOLDER : LINES_PLACEHOLDER;
3160
3221
  }
3161
3222
  for (const r of dialog.querySelectorAll('input[name="when"]')) r.addEventListener('change', syncWhen);
3162
3223
  $('#new-btn').onclick = () => openNewDialog('now');
@@ -3164,10 +3225,13 @@ $('#new-cancel').onclick = () => dialog.close();
3164
3225
  $('#new-form').addEventListener('submit', async (e) => {
3165
3226
  e.preventDefault();
3166
3227
  const f = new FormData(e.target);
3167
- const intents = String(f.get('intents'))
3168
- .split('\n')
3169
- .map((s) => s.trim())
3170
- .filter(Boolean);
3228
+ const intents = formIntents(e.target);
3229
+ if (newFiles.pending()) {
3230
+ $('#new-error').textContent = 'Still uploading the attached files; try again in a moment';
3231
+ $('#new-error').hidden = false;
3232
+ return;
3233
+ }
3234
+ const attachments = newFiles.paths().length ? newFiles.paths() : undefined;
3171
3235
  if (f.get('when') === 'team') {
3172
3236
  // "analyst=opus reviewer=opus"
3173
3237
  const models = Object.fromEntries(
@@ -3178,6 +3242,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3178
3242
  );
3179
3243
  try {
3180
3244
  const team = await api('POST', '/teams', {
3245
+ attachments,
3181
3246
  intent: intents.join('\n'),
3182
3247
  critics: Number(f.get('critics')),
3183
3248
  mode: f.get('teamMode') || undefined,
@@ -3197,6 +3262,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3197
3262
  teams.set(team.id, team);
3198
3263
  dialog.close();
3199
3264
  e.target.reset();
3265
+ newFiles.clear();
3200
3266
  syncWhen();
3201
3267
  openTeam(team.id);
3202
3268
  toast(team.mode === 'lead' ? 'Team started: one lead session with sub-agents' : `Team started: ${team.members.length} agents`);
@@ -3218,6 +3284,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3218
3284
  });
3219
3285
  try {
3220
3286
  const race = await api('POST', '/races', {
3287
+ attachments,
3221
3288
  intent: intents.join('\n'),
3222
3289
  variants,
3223
3290
  testCommand: f.get('testCommand') || undefined,
@@ -3232,6 +3299,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3232
3299
  races.set(race.id, race);
3233
3300
  dialog.close();
3234
3301
  e.target.reset();
3302
+ newFiles.clear();
3235
3303
  syncWhen();
3236
3304
  openRace(race.id);
3237
3305
  toast(`Race started: ${race.rows.length} attempts`);
@@ -3244,6 +3312,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3244
3312
  if (f.get('when') === 'backlog') {
3245
3313
  try {
3246
3314
  const added = await api('POST', '/backlog', {
3315
+ attachments,
3247
3316
  items: intents,
3248
3317
  repo: f.get('repo') || undefined,
3249
3318
  dir: f.get('dir') || undefined,
@@ -3259,6 +3328,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3259
3328
  });
3260
3329
  dialog.close();
3261
3330
  e.target.reset();
3331
+ newFiles.clear();
3262
3332
  showTab('backlog');
3263
3333
  toast(`Queued ${added.length} item${added.length > 1 ? 's' : ''}`);
3264
3334
  } catch (err) {
@@ -3269,6 +3339,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3269
3339
  }
3270
3340
  try {
3271
3341
  const created = await api('POST', '/agents', {
3342
+ attachments,
3272
3343
  priority: Number(f.get('priority')),
3273
3344
  intents,
3274
3345
  repo: f.get('repo') || undefined,
@@ -3286,6 +3357,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3286
3357
  created.forEach((a) => agents.set(a.id, a));
3287
3358
  dialog.close();
3288
3359
  e.target.reset();
3360
+ newFiles.clear();
3289
3361
  select(created[0].id);
3290
3362
  toast(`Created ${created.length} agent${created.length > 1 ? 's' : ''}`);
3291
3363
  if (created.length === 1 && created[0].runtime.planning) term.open(created[0].id);
@@ -3362,17 +3434,29 @@ $('#ask-btn').onclick = () => {
3362
3434
  askDialog.querySelector('textarea').focus();
3363
3435
  };
3364
3436
  $('#ask-cancel').onclick = () => askDialog.close();
3437
+ const askFiles = attachBox({ slot: $('#ask-attach'), form: $('#ask-form'), paste: $('#ask-form textarea'), esc, toast });
3365
3438
  $('#ask-form').addEventListener('submit', async (e) => {
3366
3439
  e.preventDefault();
3367
3440
  const f = new FormData(e.target);
3441
+ if (askFiles.pending()) {
3442
+ $('#ask-error').textContent = 'Still uploading the attached files; try again in a moment';
3443
+ $('#ask-error').hidden = false;
3444
+ return;
3445
+ }
3368
3446
  try {
3369
3447
  const a = await api('POST', '/general', {
3370
3448
  question: String(f.get('question') || '').trim() || undefined,
3371
3449
  name: String(f.get('name') || '').trim() || undefined,
3450
+ engine: f.get('engine') || undefined,
3451
+ attachments: askFiles.paths().length ? askFiles.paths() : undefined,
3372
3452
  });
3373
3453
  agents.set(a.id, a);
3374
3454
  askDialog.close();
3455
+ // Keep the engine for the next question; clear the rest.
3456
+ const engine = e.target.engine.value;
3375
3457
  e.target.reset();
3458
+ e.target.engine.value = engine;
3459
+ askFiles.clear();
3376
3460
  select(a.id);
3377
3461
  // The shared general folder is ready almost immediately; open the terminal once it runs.
3378
3462
  const t0 = Date.now();
@@ -3951,13 +4035,23 @@ $('#login-form').addEventListener('submit', async (e) => {
3951
4035
  });
3952
4036
 
3953
4037
  // 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' };
4038
+ const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
3955
4039
  async function loadEngines() {
3956
4040
  try {
3957
4041
  const list = (await api('GET', '/engines')).filter((e) => e.installed && e.id !== 'claude');
3958
- const sel = document.querySelector('#engine-field select');
3959
- for (const e of list) sel.append(Object.assign(document.createElement('option'), { value: e.id, textContent: e.label }));
3960
- $('#engine-field').hidden = !list.length;
4042
+ // Something to know before choosing an engine (Muse Code in contributor mode, say).
4043
+ const notices = Object.fromEntries(list.filter((e) => e.notice).map((e) => [e.id, e.notice]));
4044
+ for (const [field, notice] of [['#engine-field', '#engine-notice'], ['#ask-engine-field', '#ask-engine-notice']]) {
4045
+ const sel = document.querySelector(`${field} select`);
4046
+ for (const e of list) sel.append(Object.assign(document.createElement('option'), { value: e.id, textContent: e.label }));
4047
+ $(field).hidden = !list.length;
4048
+ const show = () => {
4049
+ $(notice).textContent = notices[sel.value] ?? '';
4050
+ $(notice).hidden = !notices[sel.value];
4051
+ };
4052
+ sel.addEventListener('change', show);
4053
+ show();
4054
+ }
3961
4055
  } catch {}
3962
4056
  }
3963
4057
 
@@ -0,0 +1,100 @@
1
+ // Attachments for a request that has no agent yet (New agents, Ask): pick, drop or paste files
2
+ // (a screenshot, a log). Each uploads to the daemon right away and shows as a chip; the form sends
3
+ // the uploaded paths, and the daemon copies them to each agent it creates.
4
+
5
+ const fmtSize = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
6
+
7
+ /**
8
+ * `slot` is an element to fill with the button and chips; `form` takes drops; `paste` (a textarea)
9
+ * takes pasted files. Returns { paths(), pending(), clear() }.
10
+ */
11
+ export function attachBox({ slot, form, paste, esc, toast }) {
12
+ const items = []; // { id, name, size, path?, error?, uploading }
13
+ let seq = 0;
14
+ slot.classList.add('attach-box');
15
+ slot.innerHTML = `<button type="button" class="small ghost attach-pick" title="Attach files: a screenshot, a log, a document. You can also drop them here or paste into the text box">📎 Attach files</button>
16
+ <input type="file" multiple hidden />
17
+ <span class="attach-chips"></span>`;
18
+ const input = slot.querySelector('input[type=file]');
19
+ const chips = slot.querySelector('.attach-chips');
20
+
21
+ function render() {
22
+ chips.innerHTML = items
23
+ .map(
24
+ (it) => `<span class="attach-chip ${it.error ? 'fail' : ''}" title="${esc(it.error ?? it.path ?? 'Uploading…')}">${esc(it.name)} <span class="hint">${it.uploading ? 'uploading…' : it.error ? 'failed' : fmtSize(it.size)}</span><button type="button" class="link" data-drop="${it.id}" title="Remove">×</button></span>`,
25
+ )
26
+ .join('');
27
+ }
28
+
29
+ async function add(files) {
30
+ for (const file of files) {
31
+ const name = file.name || `pasted-${Date.now()}.${(file.type.split('/')[1] || 'bin').replace(/\W/g, '')}`;
32
+ const it = { id: ++seq, name, size: file.size, uploading: true };
33
+ items.push(it);
34
+ render();
35
+ try {
36
+ const res = await fetch(`/api/uploads?name=${encodeURIComponent(name)}`, {
37
+ method: 'POST',
38
+ credentials: 'same-origin',
39
+ headers: { 'content-type': 'application/octet-stream', 'x-agentmgr': '1' },
40
+ body: file,
41
+ });
42
+ const data = await res.json().catch(() => ({}));
43
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
44
+ Object.assign(it, { path: data.path, size: data.size });
45
+ } catch (err) {
46
+ it.error = err.message;
47
+ toast(`Could not attach ${name}: ${err.message}`, 5000);
48
+ }
49
+ it.uploading = false;
50
+ render();
51
+ }
52
+ }
53
+
54
+ slot.querySelector('.attach-pick').onclick = () => input.click();
55
+ input.onchange = () => {
56
+ add([...input.files]);
57
+ input.value = '';
58
+ };
59
+ chips.addEventListener('click', (e) => {
60
+ const id = Number(e.target.closest('[data-drop]')?.dataset.drop);
61
+ if (!id) return;
62
+ items.splice(
63
+ items.findIndex((it) => it.id === id),
64
+ 1,
65
+ );
66
+ render();
67
+ });
68
+ form.addEventListener('dragover', (e) => {
69
+ if ([...(e.dataTransfer?.types ?? [])].includes('Files')) {
70
+ e.preventDefault();
71
+ form.classList.add('dropping');
72
+ }
73
+ });
74
+ form.addEventListener('dragleave', (e) => {
75
+ if (!form.contains(e.relatedTarget)) form.classList.remove('dropping');
76
+ });
77
+ form.addEventListener('drop', (e) => {
78
+ form.classList.remove('dropping');
79
+ const files = [...(e.dataTransfer?.files ?? [])];
80
+ if (!files.length) return;
81
+ e.preventDefault();
82
+ add(files);
83
+ });
84
+ // A pasted screenshot (or copied files) attaches; pasted text stays text.
85
+ paste?.addEventListener('paste', (e) => {
86
+ const files = [...(e.clipboardData?.files ?? [])];
87
+ if (!files.length) return;
88
+ e.preventDefault();
89
+ add(files);
90
+ });
91
+
92
+ return {
93
+ paths: () => items.filter((it) => it.path).map((it) => it.path),
94
+ pending: () => items.some((it) => it.uploading),
95
+ clear() {
96
+ items.length = 0;
97
+ render();
98
+ },
99
+ };
100
+ }
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. */