ai-agentmanager 1.5.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,12 @@
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
+
3
10
  ## 1.5.0 (2026-10-31)
4
11
 
5
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.
package/README.md CHANGED
@@ -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.5.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.5.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.5.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.
@@ -920,7 +921,7 @@ Usage comes from the session transcripts and is refreshed every few seconds whil
920
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.
921
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.
922
923
  - If a tab stays blank on a device, open it once with **↗** and accept the certificate there.
923
- - **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.
924
925
 
925
926
  ## Configuration
926
927
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.5.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';
@@ -3147,6 +3148,7 @@ $('#recent-bar').addEventListener('click', (e) => {
3147
3148
  // ------------------------------------------------------------------ new agents dialog
3148
3149
 
3149
3150
  const dialog = $('#new-dialog');
3151
+ const newFiles = attachBox({ slot: $('#new-attach'), form: $('#new-form'), paste: $('#new-form').intents, esc, toast });
3150
3152
  function renderProjectOptions() {
3151
3153
  $('#project-options').innerHTML = projectsState
3152
3154
  .map((p) => `<option value="${esc(p.shortcut)}">${esc(p.name)}${p.repo ? ` · ${esc(p.repo)}` : p.dir ? ` · ${esc(p.dir)}` : ''}</option>`)
@@ -3224,6 +3226,12 @@ $('#new-form').addEventListener('submit', async (e) => {
3224
3226
  e.preventDefault();
3225
3227
  const f = new FormData(e.target);
3226
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;
3227
3235
  if (f.get('when') === 'team') {
3228
3236
  // "analyst=opus reviewer=opus"
3229
3237
  const models = Object.fromEntries(
@@ -3234,6 +3242,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3234
3242
  );
3235
3243
  try {
3236
3244
  const team = await api('POST', '/teams', {
3245
+ attachments,
3237
3246
  intent: intents.join('\n'),
3238
3247
  critics: Number(f.get('critics')),
3239
3248
  mode: f.get('teamMode') || undefined,
@@ -3253,6 +3262,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3253
3262
  teams.set(team.id, team);
3254
3263
  dialog.close();
3255
3264
  e.target.reset();
3265
+ newFiles.clear();
3256
3266
  syncWhen();
3257
3267
  openTeam(team.id);
3258
3268
  toast(team.mode === 'lead' ? 'Team started: one lead session with sub-agents' : `Team started: ${team.members.length} agents`);
@@ -3274,6 +3284,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3274
3284
  });
3275
3285
  try {
3276
3286
  const race = await api('POST', '/races', {
3287
+ attachments,
3277
3288
  intent: intents.join('\n'),
3278
3289
  variants,
3279
3290
  testCommand: f.get('testCommand') || undefined,
@@ -3288,6 +3299,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3288
3299
  races.set(race.id, race);
3289
3300
  dialog.close();
3290
3301
  e.target.reset();
3302
+ newFiles.clear();
3291
3303
  syncWhen();
3292
3304
  openRace(race.id);
3293
3305
  toast(`Race started: ${race.rows.length} attempts`);
@@ -3300,6 +3312,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3300
3312
  if (f.get('when') === 'backlog') {
3301
3313
  try {
3302
3314
  const added = await api('POST', '/backlog', {
3315
+ attachments,
3303
3316
  items: intents,
3304
3317
  repo: f.get('repo') || undefined,
3305
3318
  dir: f.get('dir') || undefined,
@@ -3315,6 +3328,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3315
3328
  });
3316
3329
  dialog.close();
3317
3330
  e.target.reset();
3331
+ newFiles.clear();
3318
3332
  showTab('backlog');
3319
3333
  toast(`Queued ${added.length} item${added.length > 1 ? 's' : ''}`);
3320
3334
  } catch (err) {
@@ -3325,6 +3339,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3325
3339
  }
3326
3340
  try {
3327
3341
  const created = await api('POST', '/agents', {
3342
+ attachments,
3328
3343
  priority: Number(f.get('priority')),
3329
3344
  intents,
3330
3345
  repo: f.get('repo') || undefined,
@@ -3342,6 +3357,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3342
3357
  created.forEach((a) => agents.set(a.id, a));
3343
3358
  dialog.close();
3344
3359
  e.target.reset();
3360
+ newFiles.clear();
3345
3361
  select(created[0].id);
3346
3362
  toast(`Created ${created.length} agent${created.length > 1 ? 's' : ''}`);
3347
3363
  if (created.length === 1 && created[0].runtime.planning) term.open(created[0].id);
@@ -3418,17 +3434,29 @@ $('#ask-btn').onclick = () => {
3418
3434
  askDialog.querySelector('textarea').focus();
3419
3435
  };
3420
3436
  $('#ask-cancel').onclick = () => askDialog.close();
3437
+ const askFiles = attachBox({ slot: $('#ask-attach'), form: $('#ask-form'), paste: $('#ask-form textarea'), esc, toast });
3421
3438
  $('#ask-form').addEventListener('submit', async (e) => {
3422
3439
  e.preventDefault();
3423
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
+ }
3424
3446
  try {
3425
3447
  const a = await api('POST', '/general', {
3426
3448
  question: String(f.get('question') || '').trim() || undefined,
3427
3449
  name: String(f.get('name') || '').trim() || undefined,
3450
+ engine: f.get('engine') || undefined,
3451
+ attachments: askFiles.paths().length ? askFiles.paths() : undefined,
3428
3452
  });
3429
3453
  agents.set(a.id, a);
3430
3454
  askDialog.close();
3455
+ // Keep the engine for the next question; clear the rest.
3456
+ const engine = e.target.engine.value;
3431
3457
  e.target.reset();
3458
+ e.target.engine.value = engine;
3459
+ askFiles.clear();
3432
3460
  select(a.id);
3433
3461
  // The shared general folder is ready almost immediately; open the terminal once it runs.
3434
3462
  const t0 = Date.now();
@@ -4011,18 +4039,19 @@ const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravi
4011
4039
  async function loadEngines() {
4012
4040
  try {
4013
4041
  const list = (await api('GET', '/engines')).filter((e) => e.installed && e.id !== 'claude');
4014
- const sel = document.querySelector('#engine-field select');
4015
- for (const e of list) sel.append(Object.assign(document.createElement('option'), { value: e.id, textContent: e.label }));
4016
- $('#engine-field').hidden = !list.length;
4017
4042
  // Something to know before choosing an engine (Muse Code in contributor mode, say).
4018
4043
  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();
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
+ }
4026
4055
  } catch {}
4027
4056
  }
4028
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/index.html CHANGED
@@ -25,7 +25,7 @@
25
25
  <button id="assistant-btn" title="An always-on session that finds new work and starts agents for it">Assistant <span id="assistant-dot" class="assistant-dot" hidden></span></button>
26
26
  <button id="spend-btn" title="Results across agents (merge rate, time to PR, cost per merged PR), what they cost, and spending limits">Results <span id="spend-today" class="spend-today"></span></button>
27
27
  <button id="machine-btn" title="A terminal on this computer (your home folder), from any device you're signed in on">Terminal</button>
28
- <button id="ask-btn" title="A general Claude session with no repo or folder">Ask Claude</button>
28
+ <button id="ask-btn" title="A general session with no repo or folder, on Claude Code or another engine you have (Codex, Muse Code, …)">Ask Agent</button>
29
29
  <button id="new-btn" class="primary">+ New agents</button>
30
30
  <div class="menu-wrap">
31
31
  <button id="menu-btn" class="menu-btn" type="button" aria-haspopup="menu" aria-expanded="false" title="Settings and preferences">
@@ -173,6 +173,7 @@
173
173
  <span class="sr-only">Intents</span>
174
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>
175
175
  </label>
176
+ <div id="new-attach"></div>
176
177
  <div class="row">
177
178
  <label>
178
179
  Git repository URL
@@ -307,16 +308,24 @@
307
308
 
308
309
  <dialog id="ask-dialog">
309
310
  <form id="ask-form" method="dialog">
310
- <h2>Ask Claude</h2>
311
+ <h2>Ask an agent</h2>
311
312
  <p class="hint">A general session with no repo or folder. It is tracked and resumable like any other agent.</p>
312
313
  <label>
313
314
  Question <span class="hint">optional; you can also just type in the terminal</span>
314
315
  <textarea name="question" rows="4" placeholder="How do I tune GC for a JVM with a 32 GB heap?"></textarea>
315
316
  </label>
316
- <label>
317
- Name <span class="hint">optional</span>
318
- <input name="name" placeholder="jvm-gc-questions" />
319
- </label>
317
+ <div id="ask-attach"></div>
318
+ <div class="row">
319
+ <label>
320
+ Name <span class="hint">optional</span>
321
+ <input name="name" placeholder="jvm-gc-questions" />
322
+ </label>
323
+ <label id="ask-engine-field">
324
+ Engine
325
+ <select name="engine"><option value="">Claude Code</option></select>
326
+ <span class="hint warn" id="ask-engine-notice" hidden></span>
327
+ </label>
328
+ </div>
320
329
  <p id="ask-error" class="error" hidden></p>
321
330
  <div class="dialog-actions">
322
331
  <button type="button" id="ask-cancel" class="ghost">Cancel</button>
@@ -679,6 +688,11 @@
679
688
  <label class="check">Keep at most <input type="number" name="maxRunning" min="1" max="500" placeholder="no limit" class="num" /> agents open at once</label>
680
689
  <p class="hint">Above it, the agents idle longest are closed (they resume when you open them), so the machine stays responsive. Never closed: agents working, waiting for your answer, active in the last 5 minutes, marked Keep, or in a team that is still going. <span id="settings-running"></span></p>
681
690
  </fieldset>
691
+ <fieldset class="local-only" id="settings-service">
692
+ <legend>Start at login</legend>
693
+ <label class="check"><input type="checkbox" name="startAtLogin" /> Start AI Agent Manager when I log in</label>
694
+ <p class="hint">After a restart or a crash, the daemon comes back by itself and running agents resume; without it, it starts only when you run <code>agentmgr</code>. <span id="settings-service-state"></span></p>
695
+ </fieldset>
682
696
  <fieldset class="local-only">
683
697
  <legend>Updates</legend>
684
698
  <label class="check"><input type="checkbox" name="updateCheck" /> Check for a new release twice a day</label>
package/public/menu.js CHANGED
@@ -52,15 +52,21 @@ export function initMenu({ api, esc, toast, $ }) {
52
52
  // ------------------------------------------------------------ Settings dialog
53
53
  const dialog = $('#settings-dialog');
54
54
  const form = $('#settings-form');
55
+ let service = null; // the login service as the dialog opened
55
56
  $('#settings-btn').onclick = async () => {
56
57
  $('#settings-error').hidden = true;
57
58
  try {
58
59
  const c = await api('GET', '/cleanup');
59
60
  form.maxRunning.value = c.maxRunning ?? '';
60
61
  $('#settings-running').textContent = `Open now: ${c.running}.`;
62
+ service = null;
61
63
  if (!remote()) {
62
64
  const u = await api('GET', '/update').catch(() => null);
63
65
  form.updateCheck.checked = u?.check !== false;
66
+ service = await api('GET', '/service').catch(() => null);
67
+ $('#settings-service').hidden = !service?.supported;
68
+ form.startAtLogin.checked = Boolean(service?.installed);
69
+ $('#settings-service-state').textContent = service?.installed ? (service.loaded ? 'On: the login service is loaded.' : 'On; loads at the next login.') : 'Off now.';
64
70
  }
65
71
  dialog.showModal();
66
72
  } catch (err) {
@@ -79,6 +85,11 @@ export function initMenu({ api, esc, toast, $ }) {
79
85
  try {
80
86
  const c = await api('PUT', '/cleanup', { maxRunning: form.maxRunning.value.trim() || null });
81
87
  if (!remote()) await api('PUT', '/update', { check: form.updateCheck.checked }).catch(() => {});
88
+ // Only when it changed: installing touches launchd / systemd.
89
+ if (!remote() && service?.supported && form.startAtLogin.checked !== Boolean(service.installed)) {
90
+ const s = await api('PUT', '/service', { enabled: form.startAtLogin.checked });
91
+ toast(s.installed ? `Starts at login from now on${s.note ? `. ${s.note}` : ''}` : 'No longer starts at login', 6000);
92
+ }
82
93
  dialog.close();
83
94
  toast(c.maxRunning ? `At most ${c.maxRunning} agents open; the ones idle longest close above that` : 'No limit on open agents');
84
95
  } catch (err) {
package/public/setup.js CHANGED
@@ -3,6 +3,7 @@
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'],
6
+ ['service', 'Start at login', 'So AI Agent Manager and your agents come back by themselves after a restart.', '#settings-btn', 'Settings'],
6
7
  ['alerts', 'Get Slack alerts', 'Hear when an agent needs you, is stuck or finished.', '#alerts-btn', 'Alerts'],
7
8
  ['assistant', 'Let work come to you', 'The assistant, or triggers (a GitHub label, a webhook), start agents for new work.', '#assistant-btn', 'Assistant'],
8
9
  ['remote', 'Use it from your phone', 'Answer agents from anywhere on your network.', '#remote-btn', 'Remote access'],
package/public/style.css CHANGED
@@ -829,3 +829,11 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
829
829
  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
830
830
  .hint.warn { display: block; margin-top: 4px; color: var(--yellow); }
831
831
  .hint.warn[hidden] { display: none; }
832
+
833
+ /* Attachments in New agents and Ask: a button, then a chip per file. */
834
+ .attach-box { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -4px 0 10px; }
835
+ .attach-chips { display: contents; }
836
+ .attach-chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 4px 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--panel-2); font-size: 12px; max-width: 100%; }
837
+ .attach-chip.fail { border-color: var(--red); }
838
+ .attach-chip .link { padding: 0 4px; }
839
+ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px; }
package/src/backlog.js CHANGED
@@ -89,7 +89,7 @@ export class Backlog extends EventEmitter {
89
89
  // ------------------------------------------------------------ editing
90
90
 
91
91
  /** Queue one item per title. Options are what an agent would take (repo, dir, base, project, ...). */
92
- add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, priority = 2 } = {}) {
92
+ add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, attachments, priority = 2 } = {}) {
93
93
  const list = titles.map((t) => String(t ?? '').trim()).filter(Boolean);
94
94
  if (!list.length) throw err(400, 'At least one item is required');
95
95
  const pr = parsePriority(priority);
@@ -120,6 +120,8 @@ export class Backlog extends EventEmitter {
120
120
  permissionMode: permissionMode || null,
121
121
  model: model || null,
122
122
  engine: engine || null,
123
+ // Staged uploads; each item's agent gets its own copies when it starts.
124
+ attachments: Array.isArray(attachments) && attachments.length ? attachments.slice(0, 20).map(String) : undefined,
123
125
  plan: Boolean(plan),
124
126
  planReview: planReview ?? null,
125
127
  priority: pr,
@@ -219,6 +221,7 @@ export class Backlog extends EventEmitter {
219
221
  permissionMode: it.permissionMode,
220
222
  model: it.model,
221
223
  engine: it.engine ?? undefined,
224
+ attachments: it.attachments,
222
225
  plan: it.plan,
223
226
  planReview: it.planReview ?? undefined,
224
227
  priority: it.priority,
package/src/brief.js CHANGED
@@ -411,6 +411,81 @@ export function diagramsOnly(markdown) {
411
411
  .trim();
412
412
  }
413
413
 
414
+ // ------------------------------------------------------------------ Change walkthrough
415
+
416
+ /**
417
+ * The agent's changes explained one by one, for review: each hunk of its diff gets a title, what
418
+ * changed, why, a risk and what to check, in the order a reviewer should read them.
419
+ * `hunks`: [{ id, file, head, text }].
420
+ */
421
+ export function walkthroughPrompt({ intent, status, digest, briefMarkdown, hunks }) {
422
+ return [
423
+ "You help a person review an AI coding agent's changes, one change at a time.",
424
+ 'Below are its changes as numbered hunks (H1, H2, ...), with the request and its conversation for the reasons behind them.',
425
+ '',
426
+ 'Reply with one JSON object only, no prose around it:',
427
+ '{"summary": "<two sentences: what the changes do overall>", "changes": [{"id": "H3", "title": "<under 10 words>", "what": "<what this hunk changes, concretely>", "why": "<why, from the request or conversation; \"Not stated\" if unknown>", "risk": "low|medium|high", "check": "<what the reviewer should verify here>"}]}',
428
+ '',
429
+ 'Rules:',
430
+ '- One entry per hunk, every hunk exactly once. Order them the way a reviewer should read them: the core change first, then what depends on it, then tests, docs and small edits.',
431
+ '- Be specific: name the functions, values and behavior. Base it only on the hunks and the context; do not invent intent.',
432
+ '- risk is high for security, data loss, concurrency, money or public API changes; medium for behavior changes; low for refactors, tests, docs and formatting.',
433
+ '- Keep what, why and check to one or two sentences each.',
434
+ '',
435
+ ...contextBlock({ intent, status, digest, repo: '', briefMarkdown }),
436
+ '<hunks>',
437
+ ...hunks.map((h) => `<hunk id="${h.id}" file="${h.file}">\n${h.head}\n${h.text}\n</hunk>`),
438
+ '</hunks>',
439
+ ].join('\n');
440
+ }
441
+
442
+ /** The hunks a walkthrough explains, from a parsed diff: ids, and the text sent (capped). */
443
+ export function walkthroughHunks(files, { maxLines = 80, maxChars = 60_000 } = {}) {
444
+ const out = [];
445
+ let used = 0;
446
+ for (const f of files) {
447
+ if (f.binary) continue;
448
+ for (const h of f.hunks) {
449
+ const lines = h.lines.map((l) => `${l.t === 'add' ? '+' : l.t === 'del' ? '-' : ' '}${l.text}`);
450
+ const text = lines.length > maxLines ? [...lines.slice(0, maxLines), `… (${lines.length - maxLines} more lines)`].join('\n') : lines.join('\n');
451
+ if (used + text.length > maxChars) return { hunks: out, cut: true };
452
+ used += text.length;
453
+ out.push({ id: `H${out.length + 1}`, file: f.path, head: h.head, text });
454
+ }
455
+ }
456
+ return { hunks: out, cut: false };
457
+ }
458
+
459
+ /** The model's reply mapped back to hunks (file + header), in its reading order; unknown ids dropped. */
460
+ export function walkthroughFrom(reply, hunks) {
461
+ const byId = new Map(hunks.map((h) => [h.id, h]));
462
+ const seen = new Set();
463
+ const risk = (r) => (['low', 'medium', 'high'].includes(r) ? r : 'medium');
464
+ const changes = [];
465
+ for (const c of Array.isArray(reply?.changes) ? reply.changes : []) {
466
+ const h = byId.get(String(c?.id ?? '').trim());
467
+ if (!h || seen.has(h.id)) continue;
468
+ seen.add(h.id);
469
+ changes.push({ file: h.file, head: h.head, title: String(c.title ?? '').slice(0, 120), what: String(c.what ?? '').slice(0, 800), why: String(c.why ?? '').slice(0, 800), risk: risk(c.risk), check: String(c.check ?? '').slice(0, 600) });
470
+ }
471
+ // Hunks the model skipped still get a place (unexplained), so stepping through covers everything.
472
+ for (const h of hunks) if (!seen.has(h.id)) changes.push({ file: h.file, head: h.head, title: '', what: '', why: '', risk: 'low', check: '', unexplained: true });
473
+ return { summary: String(reply?.summary ?? '').slice(0, 600), changes };
474
+ }
475
+
476
+ /** Markdown version, for Copy / Download and the CLI. */
477
+ export function walkthroughMarkdown({ summary, changes }, title = '') {
478
+ const out = [`# Changes explained${title ? `: ${title}` : ''}`, '', summary, ''];
479
+ changes.forEach((c, i) => {
480
+ out.push(`## ${i + 1}. ${c.title || '(not explained)'}`, '', `\`${c.file}\` · \`${c.head.replace(/^(@@[^@]*@@).*/, '$1')}\` · risk: ${c.risk}`, '');
481
+ if (c.what) out.push(`- **What:** ${c.what}`);
482
+ if (c.why) out.push(`- **Why:** ${c.why}`);
483
+ if (c.check) out.push(`- **Check:** ${c.check}`);
484
+ out.push('');
485
+ });
486
+ return out.join('\n').trim() + '\n';
487
+ }
488
+
414
489
  // ------------------------------------------------------------------ Human vs AI
415
490
 
416
491
  export const contextBlock = ({ intent, status, digest, repo, briefMarkdown }) => [
package/src/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
+ import path from 'node:path';
3
4
  import { Command, Option } from 'commander';
4
5
  import WebSocket from 'ws';
5
6
  import { HOST, PORT } from './config.js';
@@ -192,6 +193,20 @@ async function attach(ref, { resume = true, kind = 'claude' } = {}) {
192
193
  });
193
194
  }
194
195
 
196
+ /** Upload local files for a request (a screenshot, a log); returns the staged paths the daemon gets. */
197
+ async function uploadFiles(files = []) {
198
+ const out = [];
199
+ for (const f of files) {
200
+ const data = fs.readFileSync(f);
201
+ const res = await fetch(`${BASE}/api/uploads?name=${encodeURIComponent(path.basename(f))}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'x-agentmgr': '1' }, body: data });
202
+ const body = await res.json().catch(() => ({}));
203
+ if (!res.ok) throw new Error(`Could not attach ${f}: ${body.error || `HTTP ${res.status}`}`);
204
+ out.push(body.path);
205
+ }
206
+ return out.length ? out : undefined;
207
+ }
208
+ const collect = (v, list = []) => [...list, v];
209
+
195
210
  function readIntentsFile(file) {
196
211
  const text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8');
197
212
  return text
@@ -295,6 +310,11 @@ export async function main(argv) {
295
310
  const st = await api('GET', '/remote').catch(() => null);
296
311
  const phone = st?.running ? st.urls.find((u) => !u.vpn) : null;
297
312
  if (phone) console.log(dim(`phone: ${phone.url} (sign-in QR: agentmgr remote status)`));
313
+ // Without the login service, nothing starts the daemon after a reboot.
314
+ if (['darwin', 'linux'].includes(process.platform)) {
315
+ const svc = await import('./service.js');
316
+ if (!(await svc.status().catch(() => ({ installed: true }))).installed) console.log(dim('tip: it won\'t start by itself after a restart; turn on Settings → Start at login, or run: agentmgr service install'));
317
+ }
298
318
  execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [BASE], () => {});
299
319
  });
300
320
 
@@ -321,7 +341,8 @@ export async function main(argv) {
321
341
  ]),
322
342
  )
323
343
  .option('-m, --model <model>', 'model (for the chosen engine)')
324
- .option('-e, --engine <engine>', 'agent engine: claude (default) or codex')
344
+ .option('-e, --engine <engine>', 'agent engine: claude (default), codex, antigravity or muse')
345
+ .option('--file-attach <path>', 'attach a file to the request (a screenshot, a log); repeat for more', collect)
325
346
  .option('--plan', 'while a repo URL clones, plan the task with Claude (moves into the repo when ready)')
326
347
  .option('--no-start', 'create without launching')
327
348
  .option('-a, --attach', 'attach to the agent after launch (single intent only)')
@@ -332,8 +353,10 @@ export async function main(argv) {
332
353
  await daemon();
333
354
  // commander sets worktree=true for --no-worktree's default; only send it when the user chose.
334
355
  const worktreeSource = cmd.getOptionValueSource('worktree');
356
+ const attachments = await uploadFiles(opts.fileAttach);
335
357
  const created = await api('POST', '/agents', {
336
358
  intents,
359
+ attachments,
337
360
  repo: opts.repo,
338
361
  dir: opts.dir,
339
362
  name: opts.name,
@@ -362,14 +385,17 @@ export async function main(argv) {
362
385
 
363
386
  program
364
387
  .command('ask')
365
- .description('Open a general Claude session (no repo or folder) and attach to it')
388
+ .description('Open a general session (no repo or folder) on any engine and attach to it')
366
389
  .argument('[question...]', 'optional first question')
367
390
  .option('-n, --name <name>', 'display name')
368
- .option('-m, --model <model>', 'claude model')
391
+ .option('-m, --model <model>', 'model (for the chosen engine)')
392
+ .option('-e, --engine <engine>', 'agent engine: claude (default), codex, antigravity or muse')
393
+ .option('--file-attach <path>', 'attach a file to the question (a screenshot, a log); repeat for more', collect)
369
394
  .option('--no-attach', 'start it in the background only')
370
395
  .action(async (words, opts) => {
371
396
  await daemon();
372
- let a = await api('POST', '/general', { question: words.join(' '), name: opts.name, model: opts.model });
397
+ const attachments = await uploadFiles(opts.fileAttach);
398
+ let a = await api('POST', '/general', { question: words.join(' '), name: opts.name, model: opts.model, engine: opts.engine, attachments });
373
399
  console.log(`${green('+')} ${bold(a.name)} ${dim(a.id)} session ${cyan(a.sessionId)}`);
374
400
  for (let i = 0; i < 100 && a.status === 'preparing'; i++) {
375
401
  await new Promise((r) => setTimeout(r, 100));
@@ -404,9 +430,9 @@ export async function main(argv) {
404
430
 
405
431
  program
406
432
  .command('doc')
407
- .description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI) or diagrams (diagrams only)')
433
+ .description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only) or walkthrough (its changes explained one by one)')
408
434
  .argument('<agent>')
409
- .argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, or all (background; diagrams not included)')
435
+ .argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, or all (background; diagrams and walkthrough not included)')
410
436
  .option('-o, --output <file>', 'also save the Markdown to this file')
411
437
  .option('--cached', 'print the last version instead of writing a new one')
412
438
  .option('-b, --background', 'start writing and return at once (kind "all" writes all five)')
package/src/manager.js CHANGED
@@ -10,6 +10,10 @@ import {
10
10
  DOC_KINDS,
11
11
  diagramsOnly,
12
12
  diagramsPrompt,
13
+ walkthroughFrom,
14
+ walkthroughHunks,
15
+ walkthroughMarkdown,
16
+ walkthroughPrompt,
13
17
  briefDir,
14
18
  briefPath,
15
19
  briefPrompt,
@@ -36,12 +40,45 @@ const SUMMARIES = {
36
40
  ...Object.fromEntries(Object.keys(DOC_KINDS).map((k) => [k, { doc: k, prompt: (ctx) => docPrompt(k, ctx) }])),
37
41
  humanai: { doc: 'humanai', run: 'runHumanAi' },
38
42
  diagrams: { doc: 'diagrams', run: 'runDiagrams' },
43
+ walkthrough: { doc: 'walkthrough', run: 'runWalkthrough' },
39
44
  };
40
45
  /** Read / write where a summary of `kind` is kept on the agent record. */
41
46
  const getSummary = (agent, kind) => (SUMMARIES[kind].doc ? agent.docs?.[kind] : agent[SUMMARIES[kind].field]);
42
47
  const summaryPatch = (agent, kind, value) =>
43
48
  SUMMARIES[kind].doc ? { docs: { ...agent.docs, [kind]: value } } : { [SUMMARIES[kind].field]: value };
44
49
 
50
+ const stagedDir = () => path.join(paths.uploads, 'staged');
51
+ const safeName = (name) => path.basename(String(name || 'file')).replace(/[^\w.@+-]+/g, '_').slice(-120) || 'file';
52
+
53
+ /** Staged uploads named in a request, checked to be files in the staging folder. */
54
+ function stagedFiles(list) {
55
+ if (list == null) return [];
56
+ if (!Array.isArray(list)) throw Object.assign(new Error('attachments must be a list of uploaded files'), { status: 400 });
57
+ const root = path.resolve(stagedDir()) + path.sep;
58
+ return list.slice(0, 20).map((p) => {
59
+ const file = path.resolve(String(p ?? ''));
60
+ if (!file.startsWith(root) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
61
+ throw Object.assign(new Error(`Not an uploaded file: ${path.basename(file)} (attach it again)`), { status: 400 });
62
+ }
63
+ return file;
64
+ });
65
+ }
66
+
67
+ /** Copy staged files into an agent's uploads folder (a copy each: one upload can serve several agents). */
68
+ function copyAttachments(files, agentId) {
69
+ const dir = path.join(paths.uploads, agentId);
70
+ fs.mkdirSync(dir, { recursive: true });
71
+ return files.map((f) => {
72
+ // Drop the staging prefix (time and a random tag) to get the name it was attached with.
73
+ const dest = path.join(dir, path.basename(f).replace(/^\d{4}-\d\d-\d\dT[\d-]+Z-[0-9a-f]{6}-/, ''));
74
+ fs.copyFileSync(f, dest);
75
+ return dest;
76
+ });
77
+ }
78
+
79
+ /** The part of the request that lists attached files. */
80
+ export const attachmentNote = (files) => `Attached files (part of this request; read them):\n${files.map((f) => `- ${f}`).join('\n')}`;
81
+
45
82
  /** The folders of a repo with how many files each holds, two levels deep: a map for drawing it. */
46
83
  async function repoLayout(dir) {
47
84
  const files = (await git.run('git', ['ls-files'], { cwd: dir, timeout: 20_000 })).split('\n').filter(Boolean);
@@ -55,6 +92,7 @@ async function repoLayout(dir) {
55
92
  }
56
93
  import { spawn } from 'node:child_process';
57
94
  import * as git from './git.js';
95
+ import { parseDiff } from '../public/review.js';
58
96
  import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
59
97
  import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
60
98
  import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
@@ -516,9 +554,9 @@ export class AgentManager extends EventEmitter {
516
554
  );
517
555
  }
518
556
 
519
- /** A general Claude session with no repo or folder; the question (if any) is its first prompt. */
520
- createGeneral({ question, name, permissionMode, model } = {}) {
521
- return this.create({ kind: 'general', intent: question, name, permissionMode, model });
557
+ /** A general session with no repo or folder, on any engine; the question (if any) is its first prompt. */
558
+ createGeneral({ question, name, permissionMode, model, engine, attachments } = {}) {
559
+ return this.create({ kind: 'general', intent: question, name, permissionMode, model, engine, attachments });
522
560
  }
523
561
 
524
562
  create({
@@ -544,8 +582,10 @@ export class AgentManager extends EventEmitter {
544
582
  imported,
545
583
  startedBy,
546
584
  trigger,
585
+ attachments,
547
586
  }) {
548
587
  intent = (intent ?? '').trim();
588
+ const staged = stagedFiles(attachments);
549
589
  const engineDef = engineOf(engine || this.config.engine || DEFAULT_ENGINE);
550
590
  const engineId = engineDef.id;
551
591
  this.plugins.check('agent.create', {
@@ -569,7 +609,7 @@ export class AgentManager extends EventEmitter {
569
609
  base ??= entry.base ?? undefined;
570
610
  project = entry.key.startsWith('custom:') ? entry.name : null;
571
611
  }
572
- if (!intent && !general) throw Object.assign(new Error('Intent is required'), { status: 400 });
612
+ if (!intent && !general && !staged.length) throw Object.assign(new Error('Intent is required'), { status: 400 });
573
613
  const id = crypto.randomBytes(3).toString('hex');
574
614
  // An imported session keeps its ID, so the first start resumes it.
575
615
  const sessionId = adopt || crypto.randomUUID();
@@ -577,6 +617,8 @@ export class AgentManager extends EventEmitter {
577
617
  const jiraKey = !name?.trim() && intent ? leadingJiraKey(intent) : null;
578
618
  const slug = name || intent ? slugify(name || jiraKey || intent) : `general-${id}`;
579
619
  const refs = extractRefs(intent, this.config.bugPatterns);
620
+ // Attached files (a screenshot, a log) go in the agent's own uploads folder, listed in its request.
621
+ if (staged.length) intent = [intent, attachmentNote(copyAttachments(staged, id))].filter(Boolean).join('\n\n');
580
622
 
581
623
  const agent = {
582
624
  id,
@@ -942,6 +984,12 @@ export class AgentManager extends EventEmitter {
942
984
  }
943
985
  }
944
986
  // Appended system prompts last only for the process they're passed to, so they go on every launch.
987
+ // Files attached for it (at creation or in its terminal): readable without a permission prompt.
988
+ const uploads = path.join(paths.uploads, agent.id);
989
+ if (fs.existsSync(uploads)) {
990
+ const dir = fs.realpathSync(uploads);
991
+ writableDir = writableDir ? [writableDir, dir].flat() : dir;
992
+ }
945
993
  return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents, allowedTools };
946
994
  }
947
995
 
@@ -1629,9 +1677,35 @@ export class AgentManager extends EventEmitter {
1629
1677
  }
1630
1678
 
1631
1679
  /** Save a file the user attached in the UI, so its path can be handed to Claude. */
1680
+ /**
1681
+ * A request with its attachments listed, for work that starts several agents from one request
1682
+ * (a team, a race): the files are copied once to a shared folder they all read.
1683
+ */
1684
+ withSharedAttachments(intent, attachments) {
1685
+ const staged = stagedFiles(attachments);
1686
+ if (!staged.length) return intent;
1687
+ const files = copyAttachments(staged, path.join('shared', crypto.randomBytes(4).toString('hex')));
1688
+ return [String(intent ?? '').trim(), attachmentNote(files)].filter(Boolean).join('\n\n');
1689
+ }
1690
+
1691
+ /** A file attached before its agent exists (New agents, Ask); create() copies it to the agent. */
1692
+ stageUpload(name, data) {
1693
+ fs.mkdirSync(stagedDir(), { recursive: true });
1694
+ // Staged files nobody used are dropped after 30 days (a backlog item may wait that long).
1695
+ const old = Date.now() - 30 * 86_400_000;
1696
+ for (const f of fs.readdirSync(stagedDir())) {
1697
+ try {
1698
+ if (fs.statSync(path.join(stagedDir(), f)).mtimeMs < old) fs.rmSync(path.join(stagedDir(), f), { force: true });
1699
+ } catch {}
1700
+ }
1701
+ const file = path.join(stagedDir(), `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(3).toString('hex')}-${safeName(name)}`);
1702
+ fs.writeFileSync(file, data);
1703
+ return { path: file, name: safeName(name), size: data.length };
1704
+ }
1705
+
1632
1706
  saveUpload(ref, name, data) {
1633
1707
  const agent = this.mustGet(ref);
1634
- const safe = path.basename(String(name || 'file')).replace(/[^\w.@+-]+/g, '_').slice(-120) || 'file';
1708
+ const safe = safeName(name);
1635
1709
  const dir = path.join(paths.uploads, agent.id);
1636
1710
  fs.mkdirSync(dir, { recursive: true });
1637
1711
  const file = path.join(dir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${safe}`);
@@ -1963,6 +2037,37 @@ export class AgentManager extends EventEmitter {
1963
2037
  });
1964
2038
  }
1965
2039
 
2040
+ /**
2041
+ * The agent's changes explained one by one for review. `changesSource` (set by the server) gives
2042
+ * the same diff the review screen shows; the result is keyed by file and hunk header.
2043
+ */
2044
+ async runWalkthrough(agent, ctx) {
2045
+ if (!this.changesSource) throw new Error('Changes are not available here');
2046
+ const data = await this.changesSource(agent.id);
2047
+ const { hunks, cut } = walkthroughHunks(parseDiff(data.diff));
2048
+ if (!hunks.length) throw Object.assign(new Error('No changes to explain yet'), { status: 400 });
2049
+ const writer = this.writerFor(agent);
2050
+ const result = walkthroughFrom(parseJsonReply(await this.oneShot(agent, walkthroughPrompt({ ...ctx, hunks }), writer)), hunks);
2051
+ const cur = this.store.get(agent.id);
2052
+ if (!cur) return null;
2053
+ return this.view(
2054
+ this.patch(
2055
+ agent.id,
2056
+ summaryPatch(cur, 'walkthrough', {
2057
+ data: result,
2058
+ cut,
2059
+ diffHash: hash(data.diff),
2060
+ markdown: walkthroughMarkdown(result, cur.name),
2061
+ writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
2062
+ at: now(),
2063
+ briefUpdatedAt: cur.brief?.updatedAt ?? null,
2064
+ pending: false,
2065
+ error: null,
2066
+ }),
2067
+ ),
2068
+ );
2069
+ }
2070
+
1966
2071
  /** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
1967
2072
  async runDiagrams(agent, ctx) {
1968
2073
  const cur0 = this.store.get(agent.id) ?? agent;
package/src/review.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Review an agent's changes: the diff against where its branch started, line comments sent
2
2
  // back to the agent as one message, and commit / push / open a pull request from the UI.
3
3
  import * as git from './git.js';
4
+ import { hash } from './judge.js';
4
5
  import { NO_REPO } from './manager.js';
5
6
  import { ghBin } from './tracker.js';
6
7
 
@@ -135,6 +136,8 @@ export class Review {
135
136
  prs: Object.values(agent.prs ?? {}),
136
137
  busy: Boolean(rt?.pty && rt.busy),
137
138
  diff,
139
+ // Matches a walkthrough's diffHash while the changes are the ones it explained.
140
+ diffHash: hash(diff),
138
141
  truncated: diff.endsWith('… (diff truncated)'),
139
142
  };
140
143
  }
package/src/server.js CHANGED
@@ -32,6 +32,7 @@ import { Notifier, isWorkflowUrl, loadNotify, publicNotify, saveNotify, sendSlac
32
32
  import { Previews, parseUrl } from './preview.js';
33
33
  import { LiveBrowser } from './livebrowser.js';
34
34
  import * as remote from './remote.js';
35
+ import * as service from './service.js';
35
36
  import { Store } from './store.js';
36
37
  import { about, latestRelease } from './about.js';
37
38
 
@@ -59,7 +60,7 @@ function isAllowedOrigin(origin, port) {
59
60
  }
60
61
 
61
62
  const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
62
- const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|update(\/|$)|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
63
+ const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|update(\/|$)|service$|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
63
64
  const HOOK_PATH = '/api/hooks/agents';
64
65
  const NO_REPO_KINDS = new Set(['general', 'assistant']);
65
66
 
@@ -73,6 +74,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
73
74
  const backlog = new Backlog({ manager, store, config });
74
75
  const races = new Races({ manager, store });
75
76
  const review = new Review({ manager });
77
+ // The walkthrough explains the same diff the review screen shows.
78
+ manager.changesSource = (id) => review.get(id);
76
79
  const studio = new Studio({ manager, review });
77
80
  const teams = new Teams({ manager, store, config });
78
81
  const cleanup = new Cleanup({ manager, store, config, teams });
@@ -259,6 +262,15 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
259
262
  api.post('/agents/:id/shell/close', wrap((req) => manager.closeShell(req.params.id, req.body?.n ? Number(req.body.n) : undefined)));
260
263
  api.post('/agents/:id/shell/new', wrap((req) => ({ n: manager.nextShellNumber(manager.mustGet(req.params.id).id) })));
261
264
  api.post('/agents/:id/shell/drop', wrap((req) => manager.dropShell(req.params.id, Number(req.body?.n) || 1)));
265
+ // A file attached before its agent exists (New agents, Ask). Raw body, name in the query.
266
+ api.post(
267
+ '/uploads',
268
+ express.raw({ type: () => true, limit: '50mb' }),
269
+ wrap((req) => {
270
+ if (!Buffer.isBuffer(req.body) || !req.body.length) throw fail(400, 'Empty file');
271
+ return manager.stageUpload(req.query.name, req.body);
272
+ }),
273
+ );
262
274
  // Raw file body; the name comes in the query. Returns the saved path to paste into a terminal.
263
275
  api.post(
264
276
  '/agents/:id/upload',
@@ -466,7 +478,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
466
478
  api.post('/agents/:id/fork', wrap((req) => manager.fork(req.params.id, req.body ?? {})));
467
479
 
468
480
  api.get('/races', wrap(() => races.list()));
469
- api.post('/races', wrap((req) => races.create(req.body ?? {})));
481
+ // Attachments: copied once to a shared folder and listed in the request every attempt gets.
482
+ const withFiles = ({ attachments, ...body } = {}) => ({ ...body, intent: manager.withSharedAttachments(body.intent, attachments) });
483
+ api.post('/races', wrap((req) => races.create(withFiles(req.body ?? {}))));
470
484
  api.get('/races/:id', wrap((req) => races.view(req.params.id)));
471
485
  api.post('/races/:id/tests', wrap((req) => races.runTests(req.params.id, req.body ?? {})));
472
486
  api.get('/races/:id/diff/:agent', wrap((req) => races.diff(req.params.id, req.params.agent)));
@@ -494,6 +508,20 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
494
508
  api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
495
509
  api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
496
510
  api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
511
+ // Start at login: the login service (launchd or systemd) that brings the daemon back after a reboot.
512
+ const serviceSupported = ['darwin', 'linux'].includes(process.platform);
513
+ const serviceView = async () => ({ supported: serviceSupported, ...(serviceSupported ? await service.status().catch(() => ({ installed: false, loaded: false })) : { installed: false, loaded: false }) });
514
+ api.get('/service', wrap(serviceView));
515
+ api.put(
516
+ '/service',
517
+ wrap(async (req) => {
518
+ if (!serviceSupported) throw fail(400, `Start at login isn't supported on ${process.platform}`);
519
+ // Installing loads it; its own copy finds this daemon running and exits cleanly (no restart),
520
+ // so it takes over at the next login.
521
+ const res = req.body?.enabled === true ? await service.install() : await service.uninstall();
522
+ return { ...(await serviceView()), note: res.note ?? null };
523
+ }),
524
+ );
497
525
  // The first-run checklist: what is set up, and what this machine is missing.
498
526
  api.get('/setup', wrap(async () => {
499
527
  const checks = (await doctor().catch(() => [])).filter((c) => c.status === 'fail' || c.status === 'warn').filter((c) => c.name !== 'Daemon');
@@ -506,6 +534,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
506
534
  assistant: agentsNow.some((a) => a.kind === 'assistant') || Boolean(config.triggers?.github?.enabled || config.triggers?.webhook?.enabled),
507
535
  remote: Boolean(remote.loadSettings().enabled),
508
536
  budget: Boolean(config.budgets?.perAgentUsd || config.budgets?.perDayUsd),
537
+ // Only where it can be set up (macOS, Linux); elsewhere there's nothing to do.
538
+ service: serviceSupported ? Boolean((await service.status().catch(() => ({}))).installed) : true,
509
539
  },
510
540
  };
511
541
  }));
@@ -544,7 +574,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
544
574
  return cleanup.run({ close: b.close !== false, delete: b.delete !== false, ids: Array.isArray(b.ids) ? b.ids : null });
545
575
  }));
546
576
  api.get('/teams', wrap(() => teams.list()));
547
- api.post('/teams', wrap((req) => teams.create(req.body ?? {})));
577
+ api.post('/teams', wrap((req) => teams.create(withFiles(req.body ?? {}))));
548
578
  api.get('/teams/:id', wrap((req) => teams.view(req.params.id)));
549
579
  api.get('/teams/:id/files/:name', wrap((req) => teams.file(req.params.id, req.params.name)));
550
580
  api.post('/teams/:id/resume', wrap((req) => teams.resume(req.params.id, req.body ?? {})));