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/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">
@@ -163,10 +163,17 @@
163
163
  <dialog id="new-dialog">
164
164
  <form id="new-form" method="dialog">
165
165
  <h2>New agents</h2>
166
+ <div class="row intent-split">
167
+ <span>What to do</span>
168
+ <label class="check" title="Each line starts its own agent: a list of tickets or small tasks"><input type="radio" name="split" value="lines" checked /> One agent per line</label>
169
+ <label class="check" title="The whole text goes to one agent: a long description, a spec, pasted notes"><input type="radio" name="split" value="one" /> One agent for all of it</label>
170
+ <span class="spacer"></span><span class="hint" id="intent-count"></span>
171
+ </div>
166
172
  <label>
167
- Intents <span class="hint">one per line; each gets its own agent</span>
173
+ <span class="sr-only">Intents</span>
168
174
  <textarea name="intents" rows="6" required placeholder="Add a dark mode toggle that remembers the user's choice&#10;Make search results show up as you type&#10;Find out why the dashboard takes 5 seconds to load and speed it up&#10;Add a one-click CSV export to the reports page"></textarea>
169
175
  </label>
176
+ <div id="new-attach"></div>
170
177
  <div class="row">
171
178
  <label>
172
179
  Git repository URL
@@ -195,6 +202,7 @@
195
202
  <label id="engine-field" hidden>
196
203
  Engine
197
204
  <select name="engine"><option value="">Claude Code</option></select>
205
+ <span class="hint warn" id="engine-notice" hidden></span>
198
206
  </label>
199
207
  </div>
200
208
  <label>
@@ -300,16 +308,24 @@
300
308
 
301
309
  <dialog id="ask-dialog">
302
310
  <form id="ask-form" method="dialog">
303
- <h2>Ask Claude</h2>
311
+ <h2>Ask an agent</h2>
304
312
  <p class="hint">A general session with no repo or folder. It is tracked and resumable like any other agent.</p>
305
313
  <label>
306
314
  Question <span class="hint">optional; you can also just type in the terminal</span>
307
315
  <textarea name="question" rows="4" placeholder="How do I tune GC for a JVM with a 32 GB heap?"></textarea>
308
316
  </label>
309
- <label>
310
- Name <span class="hint">optional</span>
311
- <input name="name" placeholder="jvm-gc-questions" />
312
- </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>
313
329
  <p id="ask-error" class="error" hidden></p>
314
330
  <div class="dialog-actions">
315
331
  <button type="button" id="ask-cancel" class="ghost">Cancel</button>
@@ -672,6 +688,11 @@
672
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>
673
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>
674
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>
675
696
  <fieldset class="local-only">
676
697
  <legend>Updates</legend>
677
698
  <label class="check"><input type="checkbox" name="updateCheck" /> Check for a new release twice a day</label>
package/public/machine.js CHANGED
@@ -8,6 +8,7 @@ const MACHINE = '__machine__';
8
8
  export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
9
9
  const dialog = $('#machine-dialog');
10
10
  let xterm = null;
11
+ let replaying = false; // writing the backlog (see onData)
11
12
  let fit = null;
12
13
  let conn = null;
13
14
  let current = null;
@@ -19,7 +20,8 @@ export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
19
20
  fit = new FitAddon();
20
21
  xterm.loadAddon(fit);
21
22
  xterm.open($('#machine-term'));
22
- xterm.onData((d) => conn?.send({ t: 'i', d }));
23
+ // Answers xterm gives to queries in the replayed backlog are stale; don't send them as typing.
24
+ xterm.onData((d) => !replaying && conn?.send({ t: 'i', d }));
23
25
  xterm.onResize(({ cols, rows }) => conn?.send({ t: 'r', cols, rows }));
24
26
  new ResizeObserver(() => dialog.open && fit.fit()).observe($('#machine-term'));
25
27
  }
@@ -45,12 +47,16 @@ export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
45
47
  xterm.focus();
46
48
  },
47
49
  onMessage: (msg) => {
48
- if (msg.t === 'hello' && msg.backlog) xterm.write(msg.backlog);
50
+ if (msg.t === 'hello' && msg.backlog) {
51
+ replaying = true;
52
+ xterm.write(msg.backlog, () => (replaying = false));
53
+ }
49
54
  else if (msg.t === 'o') xterm.write(msg.d);
50
55
  else if (msg.t === 'exit') xterm.write('\r\n\x1b[2m[terminal ended; reopen it with + Terminal]\x1b[0m\r\n');
51
56
  else if (msg.t === 'error') xterm.write(`\r\n\x1b[31m${msg.error}\x1b[0m\r\n`);
52
57
  },
53
58
  onClose: ({ reason }) => reason && xterm.write(`\r\n\x1b[2m[${reason}]\x1b[0m\r\n`),
59
+ onInputLost: () => xterm.write('\r\n\x1b[33m[some typing did not reach the computer (connection lost); check before going on]\x1b[0m\r\n'),
54
60
  },
55
61
  );
56
62
  xterm.focus();
package/public/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
@@ -1,14 +1,30 @@
1
- // Getting started: shown where an agent's details go while none is selected, until every step
2
- // is done or it is hidden. Steps open the dialog that does them.
1
+ // The home page, where an agent's details go while none is selected: Getting started until every
2
+ // step is done or it is hidden (steps open the dialog that does them), then what the app can do.
3
3
 
4
4
  const STEPS = [
5
5
  ['agent', 'Start your first agent', 'Give it a request and a repo or folder; it works in its own branch.', '#new-btn', 'New agents'],
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'],
9
10
  ['budget', 'Set a budget', 'An alert at 80% of an agent budget, and a daily limit.', '#spend-btn', 'Results'],
10
11
  ];
11
12
 
13
+ // What the app can do, each with where to find it: a button to click, a sidebar tab, or a link.
14
+ const FEATURES = [
15
+ ['Agents side by side', 'Each works on its own branch in its own folder; the list shows which need you.', { btn: '#new-btn', label: 'New agents' }],
16
+ ['Teams and races', 'A team (analyst, critics, implementer, tester, reviewer) solves one problem; a race tries variants and you keep the best.', { btn: '#new-btn', label: 'New agents' }],
17
+ ['Inbox', 'Every question, permission prompt, stuck agent and overlap between agents, in one place.', { tab: 'inbox', label: 'Inbox' }],
18
+ ['Code', "Every agent has a Code tab: its files in the editor from VS Code, with search and a diff against where it started. Or open the folder in VS Code or Cursor."],
19
+ ['Shells and browser', "+ Shell opens a terminal in an agent's folder; + Browser shows a page as this computer sees it (a dev server on localhost), from your phone too."],
20
+ ['Terminal', 'A shell on this computer in your home folder, from any device you are signed in on.', { btn: '#machine-btn', label: 'Terminal' }],
21
+ ['Review and land', "Review an agent's changes and send comments back; Land shows each pull request and what it needs to merge.", { tab: 'land', label: 'Land' }],
22
+ ['Backlog and assistant', 'Queue work to start as agents finish; the assistant and triggers (a GitHub label, a webhook) bring in new work.', { tab: 'backlog', label: 'Backlog' }],
23
+ ['Results', 'Merge rate, time to PR and cost per merged PR, with budgets and a daily limit.', { btn: '#spend-btn', label: 'Results' }],
24
+ ['From your phone', 'Answer agents, review and use the terminals from anywhere on your network.', { btn: '#remote-btn', label: 'Remote access' }],
25
+ ['Several machines', 'Link your computers to a hub to see every agent in one place, and move an agent to another machine to carry on there.', { href: 'https://github.com/jdmunta/ai-agentmanager-core/blob/main/docs/distributed.md', label: 'How it works' }],
26
+ ];
27
+
12
28
  export function initSetup({ api, esc, $ }) {
13
29
  const el = $('#placeholder');
14
30
  const plain = el.innerHTML;
@@ -21,30 +37,41 @@ export function initSetup({ api, esc, $ }) {
21
37
  }
22
38
  };
23
39
 
24
- function render() {
25
- if (!data || hidden()) {
26
- el.innerHTML = plain;
27
- return;
28
- }
40
+ // A feature's button, unless what it opens isn't available here (local-only, on a phone).
41
+ function action(f) {
42
+ if (f.href) return `<a href="${f.href}" target="_blank" rel="noopener">${f.label} ↗</a>`;
43
+ if (f.btn && document.body.classList.contains('remote') && document.querySelector(f.btn)?.classList.contains('local-only')) return '';
44
+ if (f.btn) return `<button class="small ghost" data-setup-open="${f.btn}">${f.label}</button>`;
45
+ if (f.tab) return `<button class="small ghost" data-setup-tab="${f.tab}">${f.label}</button>`;
46
+ return '';
47
+ }
48
+
49
+ function steps() {
50
+ if (!data || hidden()) return '';
29
51
  const done = STEPS.filter(([k]) => data.steps[k]).length;
30
- if (done === STEPS.length && !data.checks.length) {
31
- el.innerHTML = plain;
32
- return;
33
- }
34
- el.innerHTML = `<div class="setup">
35
- <div class="setup-head"><h3>Getting started</h3><span class="hint">${done} of ${STEPS.length} done</span><span class="spacer"></span><button class="link" data-setup-hide>Hide</button></div>
52
+ if (done === STEPS.length && !data.checks.length) return '';
53
+ return `<div class="setup-head"><h3>Getting started</h3><span class="hint">${done} of ${STEPS.length} done</span><span class="spacer"></span><button class="link" data-setup-hide>Hide</button></div>
36
54
  ${data.checks.length ? `<div class="setup-checks">${data.checks.map((c) => `<div class="setup-check ${c.status}"><b>${esc(c.name)}</b>: ${esc(c.detail)}${c.fix ? `<div class="hint">${esc(c.fix)}</div>` : ''}</div>`).join('')}</div>` : ''}
37
55
  <ol class="setup-steps">${STEPS.map(([k, title, why, btn, label]) => `<li class="${data.steps[k] ? 'done' : ''}">
38
56
  <span class="setup-mark">${data.steps[k] ? '✓' : ''}</span>
39
57
  <div><b>${title}</b><div class="hint">${why}</div></div>
40
- ${data.steps[k] ? '' : `<button class="small" data-setup-open="${btn}">${label}</button>`}</li>`).join('')}</ol>
58
+ ${data.steps[k] ? '' : `<button class="small" data-setup-open="${btn}">${label}</button>`}</li>`).join('')}</ol>`;
59
+ }
60
+
61
+ function render() {
62
+ el.innerHTML = `<div class="setup">
41
63
  <p class="hint">${plain}</p>
64
+ ${steps()}
65
+ <h3 class="setup-features-head">What you can do</h3>
66
+ <ul class="setup-features">${FEATURES.map(([title, why, f = {}]) => `<li><div><b>${title}</b><div class="hint">${why}</div></div>${action(f)}</li>`).join('')}</ul>
42
67
  </div>`;
43
68
  }
44
69
 
45
70
  el.addEventListener('click', (e) => {
46
71
  const open = e.target.closest('[data-setup-open]');
47
72
  if (open) return document.querySelector(open.dataset.setupOpen)?.click();
73
+ const tab = e.target.closest('[data-setup-tab]');
74
+ if (tab) return document.querySelector(`.side-tab[data-tab="${tab.dataset.setupTab}"]`)?.click();
48
75
  if (e.target.closest('[data-setup-hide]')) {
49
76
  try {
50
77
  localStorage.setItem('agentmgr.setupHidden', '1');
package/public/style.css CHANGED
@@ -207,7 +207,7 @@ body.term-max .layout { grid-template-columns: 1fr; }
207
207
 
208
208
  /* ---------- detail */
209
209
  .detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
210
- .placeholder { margin: auto; color: var(--muted); padding: 24px; text-align: center; }
210
+ .placeholder { margin: auto; color: var(--muted); padding: 24px; text-align: center; max-height: 100%; overflow-y: auto; box-sizing: border-box; min-height: 0; width: 100%; }
211
211
  #detail-body { padding: 16px 20px; overflow-y: auto; flex: 0 1 auto; max-height: 100%; }
212
212
  .detail.with-term #detail-body { max-height: var(--brief-h, 42%); border-bottom: 1px solid var(--border); }
213
213
  .detail.with-term.brief-sized #detail-body { height: var(--brief-h); flex: none; }
@@ -735,7 +735,7 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
735
735
  .pb-form { border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-top: 10px; }
736
736
  .memory-text { width: 100%; font: 12px/1.5 var(--mono); }
737
737
  #memory-dialog { width: min(760px, calc(100vw - 32px)); }
738
- .setup { max-width: 640px; margin: 24px auto; text-align: left; color: var(--text); }
738
+ .setup { max-width: 820px; margin: 24px auto; text-align: left; color: var(--text); }
739
739
  .setup-head { display: flex; gap: 10px; align-items: baseline; }
740
740
  .setup-head h3 { margin: 0; font-size: 16px; }
741
741
  .setup-steps { list-style: none; padding: 0; margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
@@ -743,6 +743,11 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
743
743
  .setup-steps li.done { opacity: .6; }
744
744
  .setup-mark { color: var(--green); font-weight: 700; }
745
745
  .setup-check { padding: 6px 10px; border-radius: 6px; margin: 6px 0; border-left: 3px solid var(--yellow); background: var(--panel-2); font-size: 13px; }
746
+ .setup > .hint:first-child { text-align: center; margin: 0 0 16px; }
747
+ .setup-features-head { margin: 22px 0 8px; font-size: 16px; }
748
+ .setup-features { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px; }
749
+ .setup-features li { display: flex; flex-direction: column; justify-content: space-between; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
750
+ .setup-features li > :last-child:not(div) { align-self: flex-start; }
746
751
  .setup-check.fail { border-left-color: var(--red); }
747
752
 
748
753
  /* ---------------------------------------------------------------- menu and settings */
@@ -809,3 +814,26 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
809
814
  .machine-dialog[open] { display: flex; flex-direction: column; }
810
815
  .machine-dialog .terminal { flex: 1; min-height: 0; }
811
816
  .machine-dialog #machine-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8b93a7; margin-left: 8px; }
817
+
818
+ /* Diagrams: big pictures, one under another, for a quick grasp. */
819
+ #doc-body.diagrams-view h2 { margin: 26px 0 8px; padding-top: 14px; border-top: 1px solid var(--border); }
820
+ #doc-body.diagrams-view h2:first-of-type { border-top: 0; padding-top: 0; }
821
+ #doc-body.diagrams-view .diagram { display: flex; justify-content: center; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); overflow-x: auto; }
822
+ #doc-body.diagrams-view .diagram svg { max-width: 100%; height: auto; }
823
+ @media (prefers-reduced-motion: reduce) { .diagram svg * { animation: none !important; } }
824
+
825
+ .row.intent-split { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; margin-bottom: 4px; }
826
+ .intent-split > span:first-child { font-weight: 600; }
827
+ .intent-split .check { margin: 0; }
828
+ .intent-split[hidden] { display: none; }
829
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
830
+ .hint.warn { display: block; margin-top: 4px; color: var(--yellow); }
831
+ .hint.warn[hidden] { display: none; }
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; }
@@ -14,7 +14,8 @@ export const post = (url, body) =>
14
14
 
15
15
  /**
16
16
  * Open the event stream (`{ events: true }`) or a terminal (`{ agentId, kind }`).
17
- * Handlers: onOpen(), onMessage(msg), onClose({ code, reason }) when it ends for good.
17
+ * Handlers: onOpen(), onMessage(msg), onClose({ code, reason }) when it ends for good, and
18
+ * onInputLost(text) when typing could not be delivered (remote only).
18
19
  * Returns { send(msg), close() } where msg is { t: 'i', d } (input) or { t: 'r', cols, rows } (resize).
19
20
  */
20
21
  export function openStream({ events = false, agentId, kind = 'claude' }, h) {
@@ -43,16 +44,31 @@ export function openStream({ events = false, agentId, kind = 'claude' }, h) {
43
44
  }
44
45
  };
45
46
 
46
- // Keystrokes: coalesced and sent strictly in order, one request at a time.
47
+ // Keystrokes: coalesced and sent strictly in order, one request at a time. A request that fails
48
+ // (a network blip, a phone waking up) is retried, not dropped: losing a line of a paste would run
49
+ // the rest without it.
47
50
  let queue = '';
48
51
  let inflight = false;
52
+ const deliver = async (d) => {
53
+ for (let attempt = 0; attempt < 6 && !closed; attempt++) {
54
+ try {
55
+ const res = await post(`/api/agents/${agentId}/term/input`, { d, kind });
56
+ // A refusal (signed out, terminal gone) won't change on a retry.
57
+ if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)) return res.ok;
58
+ } catch {
59
+ // Offline for a moment; try again.
60
+ }
61
+ await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
62
+ }
63
+ return false;
64
+ };
49
65
  const pump = async () => {
50
66
  if (inflight || !queue) return;
51
67
  inflight = true;
52
68
  const d = queue;
53
69
  queue = '';
54
70
  try {
55
- await post(`/api/agents/${agentId}/term/input`, { d, kind });
71
+ if (!(await deliver(d)) && !closed) h.onInputLost?.(d);
56
72
  } finally {
57
73
  inflight = false;
58
74
  if (queue) pump();
package/src/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
@@ -342,6 +342,150 @@ export function docPrompt(kind, { intent, status, digest, repo, briefMarkdown, a
342
342
  ].join('\n');
343
343
  }
344
344
 
345
+ // ------------------------------------------------------------------ Diagrams
346
+
347
+ const DIAGRAM_SET = [
348
+ ['The problem', 'a flowchart of the situation today and where it goes wrong (the root cause, from the analysis)'],
349
+ ['Architecture', 'a flowchart of the components and how they connect, grouped with subgraphs; mark the components this work adds or changes with class "changed"'],
350
+ ['Design', 'a classDiagram (or erDiagram for data) of the key modules, types or records and their relationships'],
351
+ ['Data flow', 'a flowchart LR of how data moves through the system, with the main path animated'],
352
+ ['Key interaction', 'a sequenceDiagram (with autonumber) of the most important exchange between components, services or the user'],
353
+ ['Plan', 'a flowchart of the delivery steps or a stateDiagram-v2 of the main lifecycle, whichever the work is about'],
354
+ ];
355
+
356
+ /**
357
+ * Diagrams only: the problem and its solution drawn for a quick grasp, from the documents
358
+ * already written for this work (analysis, design, flow, plan) and the agent's live context.
359
+ */
360
+ export function diagramsPrompt({ intent, status, digest, repo, briefMarkdown, documents, repoMap }) {
361
+ return [
362
+ "Draw diagrams that let a person grasp an AI coding agent's work at a glance: the problem and how it is being solved.",
363
+ 'Diagrams only. No paragraphs, bullets, captions, notes or explanations outside the diagrams.',
364
+ '',
365
+ 'Output Markdown in exactly this shape: a level-1 title "# Diagrams: <short name of the work>", then for each diagram a',
366
+ 'level-2 heading with its name followed by one ```mermaid code block. Nothing else.',
367
+ '',
368
+ 'The diagrams, in this order (leave one out only when the context has nothing for it):',
369
+ ...DIAGRAM_SET.map(([name, what], i) => `${i + 1}. "${name}": ${what}.`),
370
+ '',
371
+ 'Rules:',
372
+ '- Valid Mermaid syntax. At most about 15 nodes per diagram; labels of five words or fewer; quote labels with punctuation, e.g. A["Parse (JSON)"]. No HTML in labels.',
373
+ '- Animate the main path in every flowchart: give its edges ids and animate them, e.g.',
374
+ ' A e1@--> B',
375
+ ' B e2@-->|"saves"| C',
376
+ ' e1@{ animate: true }',
377
+ ' e2@{ animate: true }',
378
+ '- In the architecture diagram, style what this work adds or changes: `classDef changed stroke-width:3px,stroke:#22c55e` then `class X,Y changed`.',
379
+ '- Use the real names of files, modules, services and data from the context. Prefer what the documents below say; do not invent components.',
380
+ '- Output only the Markdown.',
381
+ '',
382
+ contextBlock({ intent, status, digest, repo, briefMarkdown }).join('\n'),
383
+ '<documents_already_written>',
384
+ documents || '(none yet: draw from the context above)',
385
+ '</documents_already_written>',
386
+ '<repository_layout>',
387
+ repoMap || '(no repository)',
388
+ '</repository_layout>',
389
+ ].join('\n');
390
+ }
391
+
392
+ /** Keep only the title, the diagram headings and the Mermaid blocks of a reply. */
393
+ export function diagramsOnly(markdown) {
394
+ const out = [];
395
+ let block = null;
396
+ for (const line of String(markdown ?? '').split('\n')) {
397
+ if (block) {
398
+ if (/^\s*```\s*$/.test(line)) {
399
+ if (block.length) out.push('```mermaid', ...block, '```', '');
400
+ block = null;
401
+ } else block.push(line);
402
+ } else if (/^\s*```\s*mermaid\s*$/i.test(line)) block = [];
403
+ else if (/^#{1,2} \S/.test(line)) out.push(line, '');
404
+ }
405
+ if (!out.some((l) => l === '```mermaid')) throw new Error('The reply had no diagrams');
406
+ // A heading left with no diagram under it says nothing.
407
+ return out
408
+ .filter((l, i) => !/^## /.test(l) || out.slice(i + 1).find((x) => x !== '') === '```mermaid')
409
+ .join('\n')
410
+ .replace(/\n{3,}/g, '\n\n')
411
+ .trim();
412
+ }
413
+
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
+
345
489
  // ------------------------------------------------------------------ Human vs AI
346
490
 
347
491
  export const contextBlock = ({ intent, status, digest, repo, briefMarkdown }) => [