ai-agentmanager 0.0.0-stage → 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +767 -2
  4. package/SECURITY.md +44 -0
  5. package/bin/agentmgr-hub.js +16 -0
  6. package/bin/agentmgr.js +4 -0
  7. package/docs/architecture.md +67 -0
  8. package/docs/distributed.md +147 -0
  9. package/docs/engines.md +177 -0
  10. package/docs/images/agents.jpg +0 -0
  11. package/docs/images/conflict-radar.jpg +0 -0
  12. package/docs/plugins.md +81 -0
  13. package/package.json +56 -4
  14. package/public/app.js +3083 -0
  15. package/public/hub/hub.css +230 -0
  16. package/public/hub/hub.js +766 -0
  17. package/public/hub/index.html +146 -0
  18. package/public/hub/term.js +147 -0
  19. package/public/index.html +342 -0
  20. package/public/share.html +34 -0
  21. package/public/share.js +41 -0
  22. package/public/style.css +541 -0
  23. package/public/touchscroll.js +73 -0
  24. package/public/transport.js +78 -0
  25. package/scripts/build-release.sh +58 -0
  26. package/scripts/fix-pty.js +15 -0
  27. package/src/backlog.js +413 -0
  28. package/src/brief.js +512 -0
  29. package/src/cli.js +1447 -0
  30. package/src/client.js +49 -0
  31. package/src/config.js +109 -0
  32. package/src/conflicts.js +105 -0
  33. package/src/engines/antigravity.js +296 -0
  34. package/src/engines/claude.js +98 -0
  35. package/src/engines/codex.js +331 -0
  36. package/src/engines/index.js +37 -0
  37. package/src/fleet.js +176 -0
  38. package/src/git.js +340 -0
  39. package/src/hub.js +857 -0
  40. package/src/hubactions.js +107 -0
  41. package/src/hubbacklog.js +373 -0
  42. package/src/hublink.js +437 -0
  43. package/src/inbox.js +94 -0
  44. package/src/judge.js +278 -0
  45. package/src/livebrowser.js +637 -0
  46. package/src/manager.js +2554 -0
  47. package/src/notify.js +284 -0
  48. package/src/plugins.js +119 -0
  49. package/src/preview.js +334 -0
  50. package/src/races.js +192 -0
  51. package/src/remote.js +284 -0
  52. package/src/server.js +759 -0
  53. package/src/service.js +150 -0
  54. package/src/sessions.js +160 -0
  55. package/src/store.js +104 -0
  56. package/src/stuck.js +160 -0
  57. package/src/tracker.js +311 -0
  58. package/src/trust.js +44 -0
package/public/app.js ADDED
@@ -0,0 +1,3083 @@
1
+ import { Terminal } from '/vendor/xterm/lib/xterm.mjs';
2
+ import { FitAddon } from '/vendor/xterm-addon-fit/lib/addon-fit.mjs';
3
+ import { marked } from '/vendor/marked/marked.esm.js';
4
+ import DOMPurify from '/vendor/dompurify/purify.es.mjs';
5
+ import { touchScroll } from '/touchscroll.js';
6
+ import { openStream, post, REMOTE } from '/transport.js';
7
+
8
+ const $ = (sel) => document.querySelector(sel);
9
+ const agents = new Map();
10
+ let backlogState = { items: [], projects: [], autoRun: true, defaultLimit: 2 };
11
+ let projectsState = [];
12
+ let sideTab = 'agents';
13
+ let showCompleted = false;
14
+ const races = new Map(); // race id -> scoreboard
15
+ let openRaceId = null;
16
+ let raceDiff = null; // { agentId, text }
17
+ // #<id> selects an agent; #<id>/term also opens its terminal.
18
+ const loginHash = location.hash.match(/(?:^#|&)login=([\w-]+)/)?.[1] ?? null;
19
+ // From the hub: #ticket=<one-time ticket>&go=<agent>/term
20
+ const ticketHash = location.hash.match(/(?:^#|&)ticket=([\w-]+)/)?.[1] ?? null;
21
+ const goHash = ticketHash ? decodeURIComponent(location.hash.match(/(?:^#|&)go=([^&]*)/)?.[1] ?? '') : '';
22
+ const [hashId, hashView] = loginHash ? [null, null] : (ticketHash ? goHash : location.hash.slice(1)).split('/');
23
+ let selectedId = hashId || null;
24
+ let openTermOnLoad = hashView === 'term' || isShell(hashView) || isBrowser(hashView);
25
+ let filterText = '';
26
+
27
+ // ------------------------------------------------------------------ helpers
28
+
29
+ // Terminal kinds: 'claude' (the agent's session) or 'shell:N' (shell N in its folder).
30
+ function isShell(kind) {
31
+ return /^shell(:\d+)?$/.test(kind ?? '');
32
+ }
33
+ const shellNum = (kind) => Number((kind ?? '').split(':')[1] || 1);
34
+ // 'browser:N' is browser tab N (a page this machine serves); 'browser:new' is the address prompt.
35
+ function isBrowser(kind) {
36
+ return /^browser:(\d+|new)$/.test(kind ?? '');
37
+ }
38
+
39
+ const esc = (s) =>
40
+ String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
41
+
42
+ async function api(method, route, body) {
43
+ const res = await fetch(`/api${route}`, {
44
+ method,
45
+ headers: { 'content-type': 'application/json', 'x-agentmgr': '1' },
46
+ body: body === undefined ? undefined : JSON.stringify(body),
47
+ });
48
+ const data = await res.json().catch(() => ({}));
49
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
50
+ return data;
51
+ }
52
+
53
+ function toast(msg, ms = 2600, action) {
54
+ const el = document.createElement('div');
55
+ el.className = 'toast';
56
+ el.textContent = msg;
57
+ if (action) {
58
+ const b = Object.assign(document.createElement('button'), { className: 'small primary', textContent: action.label });
59
+ b.onclick = () => {
60
+ el.remove();
61
+ action.run();
62
+ };
63
+ el.append(' ', b);
64
+ }
65
+ document.body.append(el);
66
+ setTimeout(() => el.remove(), ms);
67
+ }
68
+
69
+ async function act(fn, okMsg) {
70
+ try {
71
+ const res = await fn();
72
+ if (okMsg) toast(okMsg);
73
+ return res;
74
+ } catch (err) {
75
+ toast(`Error: ${err.message}`, 5000);
76
+ }
77
+ }
78
+
79
+ function statusOf(a) {
80
+ if (a.runtime.planning) return a.runtime.attention ? 'attention' : 'planning';
81
+ if (a.status === 'running') {
82
+ if (a.runtime.attention) return 'attention';
83
+ return a.runtime.busy ? 'working' : 'waiting';
84
+ }
85
+ return a.status;
86
+ }
87
+
88
+ /** 1234 -> "1.2k", 3456789 -> "3.5M" */
89
+ function fmtTokens(n) {
90
+ if (!n) return '0';
91
+ if (n < 1000) return String(n);
92
+ if (n < 1e6) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`;
93
+ return `${(n / 1e6).toFixed(n < 1e7 ? 2 : 1)}M`;
94
+ }
95
+
96
+ const ioOf = (u) => (u ? (u.io ?? u.input + u.output) : 0);
97
+
98
+ function usageTitle(u) {
99
+ return `I/O ${ioOf(u).toLocaleString()} (input ${u.input.toLocaleString()} + output ${u.output.toLocaleString()}) · cache write ${u.cacheCreation.toLocaleString()} · cache read ${u.cacheRead.toLocaleString()} · total incl. cache ${u.total.toLocaleString()} · ${u.requests} API requests`;
100
+ }
101
+
102
+ const tokensOpen = new Set(); // agents whose token breakdown is expanded
103
+
104
+ // ------------------------------------------------------------------ brief
105
+
106
+ // Agent-written Markdown: render, then sanitize (this page also drives terminals).
107
+ const md = (text) => DOMPurify.sanitize(marked.parse(text || '', { gfm: true, breaks: false }));
108
+
109
+ const CHECK_ICON = { passed: '✓', failed: '✗', running: '◔', pending: '○' };
110
+
111
+ function checksSummary(b) {
112
+ if (!b?.summary?.total) return '';
113
+ const { passed, failed, running, pending } = b.summary;
114
+ return [passed && `${passed} passed`, failed && `${failed} failed`, running && `${running} running`, pending && `${pending} pending`]
115
+ .filter(Boolean)
116
+ .join(' · ');
117
+ }
118
+
119
+ const diagrams = new Map(); // mermaid source -> rendered SVG (or an error)
120
+ const expanded = new Set(); // "<agent id>:<section>" the user opened with Show more
121
+
122
+ /** Long sections are clamped; Show more toggles them. */
123
+ const clamp = (a, key, html) =>
124
+ `<div class="clamp ${expanded.has(`${a.id}:${key}`) ? 'open' : ''}" data-clamp="${a.id}:${key}"><div class="md">${html}</div>` +
125
+ `<button class="link more" data-more="${a.id}:${key}"></button></div>`;
126
+
127
+ function markOverflow(root) {
128
+ for (const el of root.querySelectorAll('.clamp')) {
129
+ const inner = el.querySelector('.md');
130
+ el.classList.toggle('overflowing', el.classList.contains('open') || inner.scrollHeight > inner.clientHeight + 4);
131
+ el.querySelector('.more').textContent = el.classList.contains('open') ? 'Show less' : 'Show more';
132
+ }
133
+ }
134
+ let mermaidLib = null;
135
+
136
+ async function renderDiagrams(root) {
137
+ const slots = [...root.querySelectorAll('[data-mermaid]')];
138
+ if (!slots.length) return;
139
+ for (const slot of slots) {
140
+ const src = slot.dataset.mermaid;
141
+ if (!diagrams.has(src)) {
142
+ try {
143
+ if (!mermaidLib) {
144
+ mermaidLib = (await import('/vendor/mermaid/mermaid.esm.min.mjs')).default;
145
+ const dark = matchMedia('(prefers-color-scheme: dark)').matches;
146
+ mermaidLib.initialize({ startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
147
+ }
148
+ const { svg } = await mermaidLib.render(`mmd-${diagrams.size}-${Date.now()}`, src);
149
+ diagrams.set(src, { svg });
150
+ } catch (err) {
151
+ diagrams.set(src, { error: String(err?.message || err).split('\n')[0] });
152
+ }
153
+ }
154
+ const out = diagrams.get(src);
155
+ // The detail pane may have re-rendered while we were drawing; fill whichever slot is current.
156
+ for (const el of document.querySelectorAll('[data-mermaid]')) {
157
+ if (el.dataset.mermaid !== src || el.dataset.filled) continue;
158
+ el.dataset.filled = '1';
159
+ if (out.svg) el.innerHTML = out.svg;
160
+ else el.innerHTML = `<div class="hint">Diagram could not be drawn (${esc(out.error)}). Source:</div><pre>${esc(src)}</pre>`;
161
+ }
162
+ }
163
+ }
164
+
165
+ function explainBox(a) {
166
+ const ex = a.explanation;
167
+ const label = ex?.pending ? 'Explaining…' : ex?.markdown ? 'Explain again' : 'Explain';
168
+ const button = `<button class="small ${ex?.markdown ? '' : 'primary'}" data-act="explain" ${ex?.pending ? 'disabled' : ''}
169
+ title="What this agent is doing: the ask, where it stands, planned work, solution, conclusions and what it needs from you. Built from its live session by a separate one-off Claude call (does not interrupt the agent)">${label}</button>`;
170
+ if (!ex?.markdown && !ex?.error) return { button, box: '' };
171
+ const stale = ex.markdown && a.brief?.updatedAt && ex.briefUpdatedAt !== a.brief.updatedAt;
172
+ const box = `<div class="explain">
173
+ ${ex.error ? `<p class="error">Couldn't explain: ${esc(ex.error)}</p>` : ''}
174
+ ${ex.markdown
175
+ ? `${stale ? '<p class="hint">The brief has changed since this explanation; use <b>Explain again</b> to refresh it.</p>' : ''}
176
+ ${clamp(a, 'explain', md(ex.markdown))}
177
+ <p class="hint">Explained ${ago(ex.at)}.</p>`
178
+ : ''}
179
+ </div>`;
180
+ return { button, box };
181
+ }
182
+
183
+ const REVIEW_VERDICT = { complete: '✓ Complete', partial: '≈ Partly done', not_done: '✗ Not done' };
184
+ /** The AI judge's review of the work against the intent and brief: button, and the last result once handled. */
185
+ function reviewBox(a) {
186
+ if (a.kind === 'general') return { button: '', box: '' };
187
+ const r = a.aiReview;
188
+ const button = `<button class="small" data-act="review" ${r?.pending ? 'disabled' : ''}
189
+ title="Check the work against the intent and brief: what is met, missing or risky. A separate one-off model call; runs by itself when the agent says it is done">${r?.pending ? 'Reviewing…' : 'Review with AI'}</button>`;
190
+ // An unhandled review shows as a card above the brief; a handled one stays here, compact.
191
+ if (!r?.verdict || !r.seen) return { button, box: '' };
192
+ return {
193
+ button,
194
+ box: `<div class="explain ai-review"><p><span class="verdict ${esc(r.verdict)}">${REVIEW_VERDICT[r.verdict] ?? esc(r.verdict)}</span> ${esc(r.summary)}</p>
195
+ ${r.missing.length ? `<p class="hint">Missing: ${r.missing.map(esc).join('; ')}</p>` : ''}
196
+ <p class="hint">AI review by ${esc(r.by)} ${ago(r.at)}${r.outcome === 'finish' ? '; sent back to finish' : r.outcome === 'accept' ? '; you accepted it' : ''}.</p></div>`,
197
+ };
198
+ }
199
+
200
+ const INSIGHT_ICONS = { why: '🎯', what: '📦', how: '🛠', now: '📍' };
201
+
202
+ // ------------------------------------------------------------------ stakeholder documents
203
+
204
+ const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI' };
205
+ const DOC_TITLES = { ...DOCS, plan: 'Implementation plan' };
206
+ /** What the document dialog shows: a stakeholder document, or the agent's plan under review. */
207
+ const docOf = (a, kind) => (kind === 'plan' ? a?.planReview && { markdown: a.planReview.markdown, at: a.planReview.updatedAt } : a?.docs?.[kind]);
208
+
209
+ const VERDICT = { agree: '✓ Agrees', partly: '≈ Partly agrees', disagree: '✗ Disagrees', keep: '✓ Keep', ai: '🤖 AI could answer', reword: '✎ Reword' };
210
+ const CONFIDENCE = { high: 'High confidence', medium: 'Medium confidence', low: 'Low confidence' };
211
+
212
+ /** Human vs AI: your questions with answer boxes, the AI's answers, and the second model's checks. */
213
+ function humanAiHtml(d) {
214
+ const c = d.critique?.data;
215
+ const second = (v) =>
216
+ v ? `<p class="second ${esc(v.verdict)}"><span class="verdict">${VERDICT[v.verdict] ?? esc(v.verdict)}</span> <span class="hint">${esc(d.critique.label)}:</span> ${esc(v.note)}</p>` : '';
217
+ const evidence = (e) => (Array.isArray(e) ? e.join('; ') : e ?? '');
218
+ const human = (d.data.human ?? [])
219
+ .map(
220
+ (q, i) => `<section class="ha-card human">
221
+ <h4>${i + 1}. ${esc(q.question)}</h4>
222
+ ${q.why_human ? `<p><b>Why you:</b> ${esc(q.why_human)}</p>` : ''}
223
+ ${q.stakes ? `<p><b>Stakes:</b> ${esc(q.stakes)}</p>` : ''}
224
+ ${q.options?.length ? `<p class="ha-options">${q.options.map((o) => `<button type="button" class="small" data-ha-pick="${i}" data-text="${esc(o)}">${esc(o)}</button>`).join(' ')}</p>` : ''}
225
+ ${q.lean ? `<p class="hint">The work so far suggests: ${esc(q.lean)}</p>` : ''}
226
+ ${second(c?.human?.[i])}
227
+ <textarea data-ha="${i}" rows="2" placeholder="Your answer…">${esc(d.answers?.[i] ?? '')}</textarea>
228
+ </section>`,
229
+ )
230
+ .join('');
231
+ const ai = (d.data.ai ?? [])
232
+ .map(
233
+ (q, i) => `<section class="ha-card ai">
234
+ <h4>${i + 1}. ${esc(q.question)} <span class="tag conf-${esc(q.confidence)}">${CONFIDENCE[q.confidence] ?? esc(q.confidence)}</span></h4>
235
+ <div class="md">${md(q.answer ?? '')}</div>
236
+ ${q.evidence ? `<p class="hint"><b>Evidence:</b> ${esc(evidence(q.evidence))}</p>` : ''}
237
+ ${q.check ? `<p class="hint"><b>How to check:</b> ${esc(q.check)}</p>` : ''}
238
+ ${second(c?.ai?.[i])}
239
+ </section>`,
240
+ )
241
+ .join('');
242
+ const missing = c?.missing?.length
243
+ ? `<ul>${c.missing.map((m) => `<li><b>${m.for === 'human' ? 'For you' : 'For AI'}:</b> ${esc(m.question)} <span class="hint">${esc(m.why)}</span></li>`).join('')}</ul>`
244
+ : '';
245
+ const review = c
246
+ ? `<section class="ha-second"><h3>Second opinion · ${esc(d.critique.label)}</h3><p>${esc(c.summary ?? '')}</p>${missing ? `<p class="hint">Questions both lists missed:</p>${missing}` : ''}</section>`
247
+ : d.critique?.error
248
+ ? `<p class="error">Second opinion not available (${esc(d.critique.label)}): ${esc(d.critique.error)}</p>`
249
+ : '';
250
+ return `<h1>Human vs AI: ${esc(d.data.title ?? '')}</h1>
251
+ <h2>🧑 For you to decide</h2><p class="hint">Judgment calls the agent shouldn't make for you. Answer what you can and send them to the agent.</p>${human}
252
+ <h2>🤖 Answered by AI</h2><p class="hint">Reasoning and evaluation from the agent's session and changes, each checked by a second model.</p>${ai}
253
+ ${review}`;
254
+ }
255
+
256
+ /** Markdown with ```mermaid blocks drawn as diagrams (everything else sanitized). */
257
+ function docHtml(markdown) {
258
+ const blocks = [];
259
+ const text = (markdown ?? '').replace(/```mermaid\s*\n([\s\S]*?)```/g, (_, src) => {
260
+ blocks.push(src.trim());
261
+ return `\n\nMERMAIDBLOCK${blocks.length - 1}\n\n`;
262
+ });
263
+ return md(text).replace(/<p>MERMAIDBLOCK(\d+)<\/p>/g, (_, i) => `<div class="diagram" data-mermaid="${esc(blocks[Number(i)])}"></div>`);
264
+ }
265
+
266
+ function docButtons(a) {
267
+ const missing = Object.keys(DOCS).filter((k) => !a.docs?.[k]?.markdown && !a.docs?.[k]?.pending);
268
+ const all = missing.length
269
+ ? `<button class="small ghost" data-doc-all title="Write ${missing.map((k) => DOCS[k]).join(', ')} in the background; you'll be told when each is ready">Write all in background</button>`
270
+ : '';
271
+ return `<span class="doc-buttons" title="Documents to share with stakeholders">${all}${Object.entries(DOCS)
272
+ .map(([k, label]) => {
273
+ const d = a.docs?.[k];
274
+ const stale = d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
275
+ return `<button class="small doc-btn ${d?.markdown ? 'has' : ''}" data-doc="${k}" ${d?.pending ? 'disabled' : ''}
276
+ title="${d?.markdown ? `Open the ${label.toLowerCase()} document${stale ? ' (the brief changed since)' : ''}` : `Write a ${label.toLowerCase()} document for stakeholders`}">${d?.pending ? `${label}…` : label}${d?.markdown ? (stale ? ' •' : ' ✓') : ''}</button>`;
277
+ })
278
+ .join('')}</span>`;
279
+ }
280
+
281
+ let docView = null; // { agentId, kind }
282
+
283
+ function renderDocDialog() {
284
+ if (!docView) return;
285
+ const a = agents.get(docView.agentId);
286
+ const d = docOf(a, docView.kind);
287
+ const label = DOC_TITLES[docView.kind];
288
+ const plan = docView.kind === 'plan';
289
+ $('#doc-title').textContent = `${label} · ${a?.name ?? ''}`;
290
+ const body = $('#doc-body');
291
+ if (d?.pending) {
292
+ body.innerHTML = `<p class="hint">${docView.kind === 'humanai' ? 'Splitting the open questions and answering the AI ones, then asking a second model to check…' : `Writing the ${label.toLowerCase()} document from the agent's work so far…`}</p>`;
293
+ body.dataset.rendered = '';
294
+ } else if (d?.error) body.innerHTML = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
295
+ else if (plan && !d?.markdown) body.innerHTML = '<p class="hint">The agent is investigating and hasn\'t written its plan yet. You\'ll see it here (and in the inbox) when it has.</p>';
296
+ else if (d?.markdown) {
297
+ if (body.dataset.rendered !== d.at) {
298
+ body.innerHTML = d.data ? humanAiHtml(d) : docHtml(d.markdown);
299
+ body.dataset.rendered = d.at;
300
+ renderDiagrams(body);
301
+ }
302
+ }
303
+ const stale = !plan && d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
304
+ const by = d?.writer ? ` by ${d.writer}${d.critique ? `, checked by ${d.critique.label}` : ''}` : '';
305
+ $('#doc-meta').textContent = d?.markdown ? `${plan ? `Plan · ${PLAN_LABEL[a.planReview.status]} · updated` : 'Written'} ${ago(d.at)}${by}${stale ? ' · the brief has changed since; Regenerate for the latest' : ''}` : '';
306
+ for (const id of ['doc-copy', 'doc-download', 'doc-print']) $(`#${id}`).disabled = !d?.markdown;
307
+ $('#doc-print').hidden = plan;
308
+ $('#doc-regen').hidden = plan;
309
+ $('#doc-regen').disabled = Boolean(d?.pending);
310
+ $('#doc-bg').hidden = !d?.pending;
311
+ // Footer: send your answers (Human vs AI), or approve / send back the plan.
312
+ const foot = $('#doc-foot');
313
+ const reviewing = plan && ['review', 'changes'].includes(a?.planReview?.status) && d?.markdown;
314
+ foot.hidden = !(reviewing || (docView.kind === 'humanai' && d?.data));
315
+ if (foot.dataset.mode !== (plan ? 'plan' : 'humanai')) {
316
+ foot.dataset.mode = plan ? 'plan' : 'humanai';
317
+ foot.innerHTML = plan
318
+ ? `<textarea id="plan-note" rows="2" placeholder="Notes for the agent (required to request changes, optional when approving)"></textarea>
319
+ <div class="doc-foot-actions"><button type="button" class="small" data-plan="changes">Request changes</button><button type="button" class="small primary" data-plan="approve">Approve and implement</button></div>`
320
+ : `<span class="hint">Your answers are kept with the agent.</span><div class="doc-foot-actions"><button type="button" class="small" data-ha-act="save">Save</button><button type="button" class="small primary" data-ha-act="send">Send my answers to the agent</button></div>`;
321
+ }
322
+ }
323
+
324
+ function openDoc(agentId, kind) {
325
+ docView = { agentId, kind };
326
+ $('#doc-body').dataset.rendered = '';
327
+ $('#doc-dialog').showModal();
328
+ const a = agents.get(agentId);
329
+ if (kind !== 'plan' && !a.docs?.[kind]?.markdown && !a.docs?.[kind]?.pending) generateDoc();
330
+ renderDocDialog();
331
+ }
332
+
333
+ function generateDoc() {
334
+ const { agentId, kind } = docView;
335
+ act(async () => upsert(await api('POST', `/agents/${agentId}/docs/${kind}`)));
336
+ }
337
+
338
+ $('#doc-close').onclick = () => {
339
+ docView = null;
340
+ $('#doc-dialog').close();
341
+ };
342
+ $('#doc-bg').onclick = () => {
343
+ toast(`Writing ${DOCS[docView.kind].toLowerCase()} in the background; you'll be told when it's ready`, 3500);
344
+ docView = null;
345
+ $('#doc-dialog').close();
346
+ };
347
+
348
+ // Tell the user when a document finishes while they're not looking at it.
349
+ const docsSeen = new Map(); // "<agent>:<kind>" -> pending?
350
+ function watchDocs(a) {
351
+ for (const k of Object.keys(DOCS)) {
352
+ const key = `${a.id}:${k}`;
353
+ const d = a.docs?.[k];
354
+ const was = docsSeen.get(key);
355
+ docsSeen.set(key, Boolean(d?.pending));
356
+ if (!was || d?.pending) continue;
357
+ const viewing = docView && docView.agentId === a.id && docView.kind === k;
358
+ if (viewing) continue;
359
+ if (d?.markdown && !d.error) toast(`${DOCS[k]} for ${a.name} is ready`, 10000, { label: 'Open', run: () => openDoc(a.id, k) });
360
+ else if (d?.error) toast(`${DOCS[k]} for ${a.name} failed: ${d.error}`, 8000);
361
+ }
362
+ }
363
+ $('#doc-regen').onclick = () => generateDoc();
364
+ $('#doc-copy').onclick = async () => {
365
+ const d = docOf(agents.get(docView.agentId), docView.kind);
366
+ await navigator.clipboard.writeText(d.markdown).then(
367
+ () => toast('Markdown copied; paste into Confluence, Google Docs, Slack or a PR'),
368
+ () => toast('Copy failed: your browser blocked clipboard access'),
369
+ );
370
+ };
371
+ $('#doc-download').onclick = () => {
372
+ const a = agents.get(docView.agentId);
373
+ const blob = new Blob([docOf(a, docView.kind).markdown], { type: 'text/markdown' });
374
+ const link = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: `${a.slug}-${docView.kind}.md` });
375
+ link.click();
376
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
377
+ };
378
+ const haAnswers = () => Object.fromEntries([...document.querySelectorAll('#doc-body [data-ha]')].map((t) => [t.dataset.ha, t.value]));
379
+ $('#doc-body').addEventListener('click', (e) => {
380
+ const pick = e.target.closest('[data-ha-pick]');
381
+ if (!pick) return;
382
+ const box = $(`#doc-body [data-ha="${pick.dataset.haPick}"]`);
383
+ box.value = box.value.trim() ? `${box.value.trim()} ${pick.dataset.text}` : pick.dataset.text;
384
+ box.focus();
385
+ });
386
+ $('#doc-body').addEventListener('change', (e) => {
387
+ if (e.target.dataset.ha === undefined || !docView) return;
388
+ api('POST', `/agents/${docView.agentId}/humanai/answers`, { answers: haAnswers() }).then(upsert).catch(() => {});
389
+ });
390
+ $('#doc-foot').addEventListener('click', (e) => {
391
+ const b = e.target.closest('[data-ha-act], [data-plan]');
392
+ if (!b || !docView) return;
393
+ const id = docView.agentId;
394
+ if (b.dataset.haAct) {
395
+ const send = b.dataset.haAct === 'send';
396
+ return act(async () => upsert(await api('POST', `/agents/${id}/humanai/answers`, { answers: haAnswers(), send })), send ? 'Sent to the agent' : 'Saved');
397
+ }
398
+ const note = $('#plan-note').value;
399
+ act(async () => {
400
+ upsert(await api('POST', `/agents/${id}/plan/${b.dataset.plan}`, { note }));
401
+ $('#plan-note').value = '';
402
+ if (b.dataset.plan === 'approve') $('#doc-close').click();
403
+ }, b.dataset.plan === 'approve' ? 'Approved; the agent is implementing it' : 'Sent back to the agent');
404
+ });
405
+ function openPlan(agentId) {
406
+ docView = { agentId, kind: 'plan' };
407
+ $('#doc-body').dataset.rendered = '';
408
+ $('#doc-dialog').showModal();
409
+ renderDocDialog();
410
+ }
411
+
412
+ const PLAN_LABEL = { writing: 'being written', review: 'waiting for your review', changes: 'changes requested', approved: 'approved' };
413
+ /** The plan-review row in the brief card: status, open, approve; or the switch to turn review on. */
414
+ function planRow(a) {
415
+ if (a.kind === 'general') return '';
416
+ const p = a.planReview;
417
+ if (!p) {
418
+ return a.status === 'done' ? '' : `<div class="plan-row"><button class="small ghost" data-act="plan-on" title="The agent writes an implementation plan and waits for your approval before changing anything">Review its plan first</button></div>`;
419
+ }
420
+ return `<div class="plan-row ${p.status}"><b>Plan</b> <span class="tag plan-${p.status}">${PLAN_LABEL[p.status]}</span>
421
+ <button class="small ${p.status === 'review' ? 'primary' : ''}" data-act="plan-open" ${p.markdown ? '' : 'disabled'}>${p.status === 'review' ? 'Review plan' : 'Open plan'}</button>
422
+ ${p.status !== 'approved' ? '<button class="small ghost" data-act="plan-skip" title="Stop waiting for review; the agent goes ahead">Skip review</button>' : ''}</div>`;
423
+ }
424
+ $('#doc-print').onclick = () => window.open(`/share.html#${docView.agentId}/${docView.kind}`, '_blank', 'noopener');
425
+
426
+ /** Why / What / How / Now as four cards. */
427
+ function insightsBox(a) {
428
+ const ins = a.insights;
429
+ const button = `<button class="small ${ins?.markdown ? '' : 'primary'}" data-act="insights" ${ins?.pending ? 'disabled' : ''}
430
+ title="Why, What, How and Now in three memorable points each, from the agent's ask and live session (a separate one-off Claude call)">${ins?.pending ? 'Thinking…' : ins?.markdown ? 'Refresh insights' : 'Insights'}</button>`;
431
+ if (!ins?.markdown && !ins?.error) return { button, box: '' };
432
+ const sections = {};
433
+ for (const part of (ins.markdown ?? '').split(/^###\s+/m).slice(1)) {
434
+ const [head, ...rest] = part.split('\n');
435
+ const key = head.trim().toLowerCase().replace(/[^a-z]/g, '');
436
+ if (INSIGHT_ICONS[key]) sections[key] = rest.join('\n').trim();
437
+ }
438
+ const stale = ins.markdown && a.brief?.updatedAt && ins.briefUpdatedAt !== a.brief.updatedAt;
439
+ const box = `<div class="insights">
440
+ ${ins.error ? `<p class="error">Couldn't get insights: ${esc(ins.error)}</p>` : ''}
441
+ ${ins.markdown
442
+ ? Object.keys(sections).length
443
+ ? `<div class="insight-grid">${Object.keys(INSIGHT_ICONS)
444
+ .map((k) => `<section class="insight ${k}"><h4>${INSIGHT_ICONS[k]} ${k[0].toUpperCase()}${k.slice(1)}</h4><div class="md">${md(sections[k] || 'Unclear yet')}</div></section>`)
445
+ .join('')}</div>`
446
+ : `<div class="md">${md(ins.markdown)}</div>`
447
+ : ''}
448
+ ${ins.markdown ? `<p class="hint">${stale ? 'The brief has changed since; refresh for the latest. · ' : ''}From ${ago(ins.at)}.</p>` : ''}
449
+ </div>`;
450
+ return { button, box };
451
+ }
452
+
453
+ const OUTCOME = {
454
+ done: ['✅', 'Done'],
455
+ partial: ['🟡', 'Partial'],
456
+ blocked: ['⛔', 'Blocked'],
457
+ 'in progress': ['🔄', 'In progress'],
458
+ };
459
+
460
+ /** End result, status and blockers: first thing a reviewer reads. */
461
+ function resultsCard(a) {
462
+ const r = a.results;
463
+ if (!r) return '';
464
+ const [icon, label] = OUTCOME[r.outcome] ?? ['🔄', r.outcome];
465
+ const item = (x, cls) => `<li class="${cls}">${x.url ? `<a href="${esc(x.url)}" target="_blank" rel="noopener">${md(x.text).replace(/^<p>|<\/p>\s*$/g, '')}</a>` : md(x.text).replace(/^<p>|<\/p>\s*$/g, '')}</li>`;
466
+ return `<div class="results ${r.outcome.replace(' ', '-')}">
467
+ <h4>Results <span class="outcome">${icon} ${label}</span>${r.fromAgent ? '' : ' <span class="hint">(from what Agent Manager can see; the agent adds its own when it next updates its brief)</span>'}</h4>
468
+ ${r.blockers.length ? `<ul class="blockers">${r.blockers.map((x) => item(x, 'blocker')).join('')}</ul>` : ''}
469
+ ${r.delivered.length ? `<ul class="delivered">${r.delivered.map((x) => item(x, 'delivered')).join('')}</ul>` : '<p class="hint">Nothing delivered yet.</p>'}
470
+ ${r.notes ? `<div class="md">${md(r.notes)}</div>` : ''}
471
+ </div>`;
472
+ }
473
+
474
+ function briefPanel(a) {
475
+ if (a.kind === 'general') return '';
476
+ const b = a.brief;
477
+ const ex = explainBox(a);
478
+ const rv = reviewBox(a);
479
+ const ins = insightsBox(a);
480
+ if (!b) {
481
+ return `<div class="card brief">
482
+ <h3>Brief <span class="brief-actions">${ins.button} ${ex.button} ${rv.button}</span></h3>
483
+ ${planRow(a)}
484
+ <div class="share-row"><span class="hint">Share for review:</span> ${docButtons(a)}</div>
485
+ ${resultsCard(a)}
486
+ ${ins.box}
487
+ ${ex.box}
488
+ ${rv.box}
489
+ <p class="hint">Problem, solution, diagram and checks appear here once the agent writes its brief.
490
+ ${a.lastStartedAt && !a.runtime.running ? ' Agents started before this feature write one after their next resume.' : ''}</p>
491
+ </div>`;
492
+ }
493
+ const checks = b.checks.length
494
+ ? `<ul class="checks">${b.checks
495
+ .map((c) => `<li class="${c.state}"><span class="icon" title="${c.state}">${CHECK_ICON[c.state]}</span><span>${md(c.text).replace(/^<p>|<\/p>\s*$/g, '')}</span></li>`)
496
+ .join('')}</ul>`
497
+ : '<p class="hint">No checks yet.</p>';
498
+ return `<div class="card brief">
499
+ <h3>Brief <span class="brief-actions"><span title="${esc(b.updatedAt)}">updated ${ago(b.updatedAt)}</span> ${ins.button} ${ex.button} ${rv.button}</span></h3>
500
+ ${planRow(a)}
501
+ <div class="share-row"><span class="hint">Share for review:</span> ${docButtons(a)}</div>
502
+ ${resultsCard(a)}
503
+ ${ins.box}
504
+ ${ex.box}
505
+ ${rv.box}
506
+ <div class="brief-grid">
507
+ <section><h4>Problem</h4>${b.problem ? clamp(a, 'problem', md(b.problem)) : '<p class="hint">—</p>'}</section>
508
+ <section><h4>Solution</h4>${b.solution ? clamp(a, 'solution', md(b.solution)) : '<p class="hint">—</p>'}</section>
509
+ ${b.references?.length
510
+ ? `<section class="wide"><h4>References <span class="hint">from the linked issue</span></h4>
511
+ <ul class="refs">${b.references
512
+ .map(
513
+ (r) => `<li class="${r.key ? 'key' : ''} ${r.missing ? 'missing' : ''}">
514
+ <span class="ref-label">${esc(r.label)}</span>
515
+ ${r.url ? `<a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.title || r.url)}</a>` : ''}
516
+ ${r.highlight ? `<span class="ref-hl">${md(r.highlight).replace(/^<p>|<\/p>\s*$/g, '')}</span>` : ''}
517
+ </li>`,
518
+ )
519
+ .join('')}</ul>
520
+ </section>`
521
+ : ''}
522
+ <section class="wide"><h4>Design / flow</h4>
523
+ ${b.diagram ? `<div class="diagram" data-mermaid="${esc(b.diagram)}"></div>` : '<p class="hint">No diagram yet.</p>'}
524
+ ${b.diagramNotes ? `<div class="md">${md(b.diagramNotes)}</div>` : ''}
525
+ </section>
526
+ <section class="wide"><h4>Checks <span class="hint">${esc(checksSummary(b))}</span></h4>${checks}</section>
527
+ </div>
528
+ </div>`;
529
+ }
530
+
531
+ const pillText = (st) => (st === 'attention' ? 'needs input' : st);
532
+
533
+ /** "Cloning · Receiving objects 45% · 2.10 GiB · 2.40 MiB/s" */
534
+ function progressText(p) {
535
+ if (!p) return '';
536
+ const parts = [p.phase];
537
+ if (p.step) parts.push(`${p.step} ${p.percent}%`);
538
+ if (p.size) parts.push(p.size);
539
+ if (p.rate) parts.push(p.rate);
540
+ return parts.join(' · ');
541
+ }
542
+
543
+ function ago(iso) {
544
+ if (!iso) return '';
545
+ const s = Math.round((Date.now() - new Date(iso)) / 1000);
546
+ if (s < 60) return `${s}s ago`;
547
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
548
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
549
+ return `${Math.round(s / 86400)}d ago`;
550
+ }
551
+
552
+ const copyBtn = (value) => `<button class="link" data-copy="${esc(value)}" title="Copy">⧉</button>`;
553
+
554
+ // ------------------------------------------------------------------ list
555
+
556
+ /** Agents waiting on you first, then running ones, then the rest. */
557
+ const rank = (a) => (a.runtime.attention ? 0 : a.runtime.running ? 1 : 2);
558
+
559
+ // Projects the user collapsed in the tree (kept per browser).
560
+ const collapsed = new Set(
561
+ (() => {
562
+ try {
563
+ return JSON.parse(localStorage.getItem('agentmgr.collapsed') || '[]');
564
+ } catch {
565
+ return [];
566
+ }
567
+ })(),
568
+ );
569
+ function saveCollapsed() {
570
+ try {
571
+ localStorage.setItem('agentmgr.collapsed', JSON.stringify([...collapsed]));
572
+ } catch {}
573
+ }
574
+
575
+ /** P0 solid red, P1 red outline, P3 muted; P2 (the default) only shown where asked. */
576
+ const prioChip = (p, { always = false } = {}) =>
577
+ p === 2 && !always ? '' : `<span class="prio p${p}" title="Priority P${p}">P${p}</span>`;
578
+
579
+ function agentItem(a) {
580
+ const st = statusOf(a);
581
+ return `<li data-id="${a.id}" class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''}">
582
+ <div class="line1">${prioChip(a.priority ?? 2)}${a.results?.outcome === 'blocked' ? '<span class="outcome-mark" title="Blocked">⛔</span>' : a.results?.outcome === 'done' ? '<span class="outcome-mark" title="Done">✅</span>' : ''}<span class="name">${esc(a.name)}</span>${flags(a)}<span class="pill ${st}">${pillText(st)}</span></div>
583
+ <div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a.intent || 'General session')}</div>
584
+ ${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
585
+ <div class="meta">
586
+ <span class="mono">${a.id}</span>
587
+ ${a.git?.branch ? `<span class="branch" title="${esc(a.git.branch)}">⎇ ${esc(a.git.branch)}</span>` : ''}
588
+ ${a.prs.length ? `<span>${a.prs.length} PR</span>` : ''}
589
+ ${a.bugs.length ? `<span>${a.bugs.length} bug</span>` : ''}
590
+ ${a.usage?.total ? `<span title="${esc(usageTitle(a.usage))}">${fmtTokens(ioOf(a.usage))} tok</span>` : ''}
591
+ ${(a.brief?.references ?? []).filter((r) => r.key && r.url).map((r) => `<span class="tag key-ref" title="${esc(r.title || r.url)}">${esc(r.label)}</span>`).join('')}
592
+ ${a.brief?.summary?.total
593
+ ? `<span class="${a.brief.summary.failed ? 'fail' : ''}" title="${esc(checksSummary(a.brief))}">✓${a.brief.summary.passed}/${a.brief.summary.total}${a.brief.summary.failed ? ` ✗${a.brief.summary.failed}` : ''}</span>`
594
+ : ''}
595
+ ${a.runtime.clients ? `<span title="terminals attached">◉ ${a.runtime.clients}</span>` : ''}
596
+ </div>
597
+ </li>`;
598
+ }
599
+
600
+ /** Agents grouped into projects, busiest and most urgent first. */
601
+ function projectGroups(list) {
602
+ const groups = new Map();
603
+ for (const a of list) {
604
+ const p = a.projectInfo ?? { key: 'other', name: 'Other' };
605
+ if (!groups.has(p.key)) groups.set(p.key, { ...p, agents: [] });
606
+ if (p.shortcut) groups.get(p.key).shortcut = p.shortcut;
607
+ groups.get(p.key).agents.push(a);
608
+ }
609
+ const out = [...groups.values()];
610
+ for (const g of out) {
611
+ // Highest priority first, then agents needing you, then running ones, then newest.
612
+ g.agents.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2) || rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt));
613
+ g.top = Math.min(...g.agents.map((a) => a.priority ?? 2));
614
+ g.running = g.agents.filter((a) => a.runtime.running).length;
615
+ g.waiting = g.agents.filter((a) => a.runtime.attention).length;
616
+ g.tokens = g.agents.reduce((n, a) => n + (a.usage?.total ?? 0), 0);
617
+ }
618
+ // Two different repos with the same name: show org/name to tell them apart.
619
+ const byName = Map.groupBy ? Map.groupBy(out, (g) => g.name) : null;
620
+ for (const g of out) {
621
+ const same = byName ? byName.get(g.name).length : out.filter((x) => x.name === g.name).length;
622
+ g.label = same > 1 && g.full ? g.full : g.name;
623
+ }
624
+ const special = (g) => (g.key === 'general' ? 1 : g.key === 'other' ? 2 : 0);
625
+ return out.sort(
626
+ (a, b) => special(a) - special(b) || a.top - b.top || b.waiting - a.waiting || b.running - a.running || a.label.localeCompare(b.label),
627
+ );
628
+ }
629
+
630
+ function renderList() {
631
+ const waiting = [...agents.values()].filter((a) => a.runtime.attention).length;
632
+ document.title = waiting ? `(${waiting}) Agent Manager` : 'Agent Manager';
633
+ // "@cc" shows one project; any other text also matches shortcuts.
634
+ const at = filterText.startsWith('@') ? filterText.slice(1) : null;
635
+ const list = [...agents.values()].filter((a) =>
636
+ !filterText
637
+ ? true
638
+ : at !== null
639
+ ? (a.projectInfo?.shortcut ?? '') === at || (at && (a.projectInfo?.shortcut ?? '').startsWith(at))
640
+ : `${a.name} ${a.intent} ${a.repo ?? ''} ${a.workdir ?? ''} ${a.id} ${a.projectInfo?.name ?? ''} ${a.projectInfo?.shortcut ?? ''}`
641
+ .toLowerCase()
642
+ .includes(filterText),
643
+ );
644
+ $('#empty').hidden = agents.size > 0;
645
+
646
+ const all = [...agents.values()];
647
+ const projects = new Set(all.map((a) => a.projectInfo?.key ?? 'other')).size;
648
+ const running = all.filter((a) => a.runtime.running).length;
649
+ $('#counts').innerHTML = agents.size
650
+ ? `<span title="Projects">${projects} project${projects === 1 ? '' : 's'}</span><span>${agents.size} agents</span><span>${running} running</span>
651
+ ${waiting ? `<span class="fail">${waiting} need${waiting === 1 ? 's' : ''} input</span>` : ''}
652
+ <span class="spacer"></span><button class="link" id="import-btn" title="Continue Claude sessions you started in a terminal on this machine">Import</button><button class="link" id="flush-btn" title="Save session IDs, terminal scrollback and state to disk now (also automatic every 30s)">Save now</button>`
653
+ : '';
654
+
655
+ $('#agent-list').innerHTML = projectGroups(list)
656
+ .map((g) => {
657
+ // While filtering, show every match; otherwise respect collapsed projects.
658
+ const open = filterText || !collapsed.has(g.key) || g.agents.some((a) => a.id === selectedId);
659
+ return `<li class="project ${open ? 'open' : ''}">
660
+ <button class="project-head" data-toggle="${esc(g.key)}" aria-expanded="${open}" title="${esc(g.full || g.name)}">
661
+ <span class="caret">${open ? '▾' : '▸'}</span>
662
+ ${g.shortcut ? `<span class="shortcut" data-rename="${esc(g.shortcut)}" title="Shortcut: type @${esc(g.shortcut)} to filter, or use it as the project when adding agents (click to rename)">${esc(g.shortcut)}</span>` : ''}
663
+ <span class="pname">${esc(g.label)}</span>
664
+ <span class="pcounts">
665
+ ${g.top <= 1 ? prioChip(g.top) : ''}
666
+ ${g.waiting ? `<span class="pill attention" title="needs input">${g.waiting}</span>` : ''}
667
+ <span title="running / agents">${g.running}/${g.agents.length}</span>
668
+ </span>
669
+ </button>
670
+ ${open ? `<ul class="project-agents">${g.agents.map(agentItem).join('')}</ul>` : ''}
671
+ </li>`;
672
+ })
673
+ .join('');
674
+ }
675
+
676
+ // ------------------------------------------------------------------ backlog
677
+
678
+ const ITEM_ACTIONS = {
679
+ queued: [['up', '▲', 'Move up'], ['down', '▼', 'Move down'], ['start', 'Start now', 'Start now, even if the project is at its limit'], ['cancel', 'Cancel', '']],
680
+ active: [['open', 'Open', 'Open its agent'], ['accept', 'Done', 'Mark done now'], ['cancel', 'Cancel', '']],
681
+ review: [['accept', 'Accept', 'Accept the work: done'], ['reopen', 'Reopen', 'Send it back to the same agent with a note'], ['open', 'Open', 'Open its agent']],
682
+ blocked: [['start', 'Retry', 'Retry the agent'], ['open', 'Open', 'Open its agent'], ['cancel', 'Cancel', '']],
683
+ done: [['reopen', 'Reopen', ''], ['open', 'Open', ''], ['remove', '✕', 'Remove from the list']],
684
+ cancelled: [['remove', '✕', 'Remove from the list']],
685
+ };
686
+
687
+ function renderBacklog() {
688
+ const open = backlogState.items.filter((i) => ['queued', 'active', 'review', 'blocked'].includes(i.status));
689
+ const review = backlogState.items.filter((i) => i.status === 'review').length;
690
+ $('#backlog-count').textContent = open.length ? String(open.length) : '';
691
+ $('#backlog-count').className = `badge ${review ? 'attention' : ''}`;
692
+ if (sideTab !== 'backlog') return;
693
+
694
+ const items = backlogState.items.filter((i) => showCompleted || !['done', 'cancelled'].includes(i.status));
695
+ const groups = new Map();
696
+ for (const it of items) {
697
+ if (!groups.has(it.projectKey)) groups.set(it.projectKey, { key: it.projectKey, name: it.projectName, items: [] });
698
+ groups.get(it.projectKey).items.push(it);
699
+ }
700
+ const proj = Object.fromEntries(backlogState.projects.map((p) => [p.key, p]));
701
+ const count = (s) => backlogState.items.filter((i) => i.status === s).length;
702
+ $('#backlog-view').innerHTML = `
703
+ <div class="counts">
704
+ <span>${count('queued')} queued</span><span>${count('active')} active</span>
705
+ ${count('review') ? `<span class="fail">${count('review')} to review</span>` : '<span>0 to review</span>'}
706
+ <span class="spacer"></span>
707
+ <button class="link" data-bl="toggle-run" title="${backlogState.autoRun ? 'Stop starting new items (running ones continue)' : 'Start picking up queued items again'}">${backlogState.autoRun ? '⏸ Pause' : '▶ Resume'}</button>
708
+ <button class="link" data-bl="add">+ Add</button>
709
+ </div>
710
+ ${!backlogState.autoRun ? '<p class="hint bl-paused">Paused: queued items wait until you resume.</p>' : ''}
711
+ ${items.length
712
+ ? `<ul class="agent-list">${[...groups.values()]
713
+ .map((g) => {
714
+ const p = proj[g.key] ?? { limit: backlogState.defaultLimit };
715
+ return `<li class="project open">
716
+ <div class="project-head bl-head">
717
+ ${p.shortcut ? `<span class="shortcut">${esc(p.shortcut)}</span>` : ''}
718
+ <span class="pname">${esc(g.name)}</span>
719
+ <button class="link" data-bl="limit" data-project="${esc(g.key)}" data-name="${esc(g.name)}" title="How many items of this project agents work on at once">${p.limit} at a time ✎</button>
720
+ </div>
721
+ <ul class="project-agents">${g.items.map(itemRow).join('')}</ul>
722
+ </li>`;
723
+ })
724
+ .join('')}</ul>`
725
+ : `<div class="empty"><p>The backlog is empty.</p><p>Add asks with <b>+ Add</b> (or <b>+ New agents</b> → Queue in backlog). Agents pick them up one by one, ${backlogState.defaultLimit} at a time per project, until each is done.</p></div>`}
726
+ <p class="hint bl-foot"><label><input type="checkbox" data-bl="completed" ${showCompleted ? 'checked' : ''}/> Show done and cancelled</label></p>`;
727
+ }
728
+
729
+ function itemRow(it) {
730
+ const a = it.agent;
731
+ const st = it.status === 'active' && a?.attention ? 'attention' : it.status;
732
+ return `<li class="agent bl-item ${it.status}" data-item="${it.id}">
733
+ <div class="line1">
734
+ <button class="prio p${it.priority}" data-bl="prio" title="Priority (click to change)">P${it.priority}</button>
735
+ <span class="name">${esc(it.title)}</span>
736
+ <span class="pill ${st === 'review' ? 'waiting' : st === 'active' ? 'working' : st}">${st === 'attention' ? 'needs input' : st}</span>
737
+ </div>
738
+ <div class="meta">
739
+ <span class="mono">${it.id}</span>
740
+ ${a ? `<span>agent <a href="#${a.id}" data-bl="open">${esc(a.name)}</a>${a.briefStatus ? ` · brief: ${esc(a.briefStatus)}` : ''}</span>` : ''}
741
+ ${it.base ? `<span>⎇ from ${esc(it.base)}</span>` : ''}
742
+ ${it.doneAt ? `<span>done ${ago(it.doneAt)}</span>` : it.reviewAt ? `<span>ready ${ago(it.reviewAt)}</span>` : it.startedAt ? `<span>started ${ago(it.startedAt)}</span>` : `<span>added ${ago(it.createdAt)}</span>`}
743
+ </div>
744
+ ${it.status === 'queued' && it.aiHold ? `<p class="hint bl-ai">🤖 Waits for <b>${esc(it.aiHold.afterTitle)}</b>${it.aiHold.reason ? `: ${esc(it.aiHold.reason)}` : ''}</p>` : ''}
745
+ ${it.status === 'queued' && it.aiSplit ? `<div class="hint bl-ai">🤖 Could be split${it.aiSplit.reason ? ` (${esc(it.aiSplit.reason)})` : ''}:<ul>${it.aiSplit.parts.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
746
+ <button class="small" data-bl="split">Split into ${it.aiSplit.parts.length}</button> <button class="small ghost" data-bl="keep-whole">Keep whole</button></div>` : ''}
747
+ <div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).map(([act, label, title]) => `<button class="small" data-bl="${act}" title="${esc(title)}">${label}</button>`).join('')}</div>
748
+ </li>`;
749
+ }
750
+
751
+ function showTab(tab) {
752
+ sideTab = tab;
753
+ for (const b of document.querySelectorAll('.side-tab')) b.classList.toggle('active', b.dataset.tab === tab);
754
+ $('#agents-view').hidden = tab !== 'agents';
755
+ $('#backlog-view').hidden = tab !== 'backlog';
756
+ $('#inbox-view').hidden = tab !== 'inbox';
757
+ if (tab === 'inbox') loadJudge();
758
+ renderBacklog();
759
+ renderInbox();
760
+ }
761
+ for (const b of document.querySelectorAll('.side-tab')) b.onclick = () => showTab(b.dataset.tab);
762
+
763
+ $('#backlog-view').addEventListener('click', async (e) => {
764
+ const el = e.target.closest('[data-bl]');
765
+ if (!el) return;
766
+ const what = el.dataset.bl;
767
+ const id = el.closest('[data-item]')?.dataset.item;
768
+ const it = backlogState.items.find((x) => x.id === id);
769
+ if (what === 'completed') {
770
+ showCompleted = el.checked;
771
+ return renderBacklog();
772
+ }
773
+ e.preventDefault();
774
+ const blApi = (method, route, body) => act(() => api(method, route, body));
775
+ switch (what) {
776
+ case 'toggle-run':
777
+ return blApi('POST', '/backlog/settings', { autoRun: !backlogState.autoRun });
778
+ case 'add':
779
+ return openNewDialog('backlog');
780
+ case 'limit': {
781
+ const n = prompt(`How many "${el.dataset.name}" items should agents work on at once?`, el.textContent.split(' ')[0]);
782
+ if (n === null) return;
783
+ return blApi('POST', '/backlog/settings', { project: el.dataset.project, limit: Number(n) });
784
+ }
785
+ case 'prio':
786
+ return blApi('PATCH', `/backlog/${id}`, { priority: (it.priority + 1) % 4 });
787
+ case 'up':
788
+ case 'down':
789
+ return blApi('POST', `/backlog/${id}/move`, { where: what });
790
+ case 'start':
791
+ return blApi('POST', `/backlog/${id}/start`);
792
+ case 'accept':
793
+ return blApi('POST', `/backlog/${id}/accept`);
794
+ case 'reopen': {
795
+ const note = prompt('What still needs doing? (sent to the same agent)', '');
796
+ if (note === null) return;
797
+ return blApi('POST', `/backlog/${id}/reopen`, { note });
798
+ }
799
+ case 'cancel':
800
+ if (!confirm('Cancel this item? Its agent (if any) keeps its session; close or delete it separately.')) return;
801
+ return blApi('POST', `/backlog/${id}/cancel`);
802
+ case 'remove':
803
+ return blApi('DELETE', `/backlog/${id}`);
804
+ case 'split':
805
+ return blApi('POST', `/backlog/${id}/split`, { parts: it.aiSplit.parts });
806
+ case 'keep-whole':
807
+ return blApi('POST', `/backlog/${id}/keep-whole`);
808
+ case 'open': {
809
+ const agentId = it?.agent?.id;
810
+ if (!agentId) return;
811
+ showTab('agents');
812
+ return select(agentId);
813
+ }
814
+ }
815
+ });
816
+
817
+ // ------------------------------------------------------------------ needs you (inbox)
818
+
819
+ /** Small markers in the agent list: stuck, overlapping, asking, racing, forked. */
820
+ function flags(a) {
821
+ const q = (a.questions ?? []).filter((x) => !x.answer).length;
822
+ return [
823
+ a.stuck?.length ? `<span class="flag warn" title="May be stuck: ${esc(a.stuck[0].title)}">↻</span>` : '',
824
+ a.conflicts?.length ? `<span class="flag warn" title="Changes the same files as ${esc(a.conflicts.map((c) => c.agent.name).join(', '))}">⚔</span>` : '',
825
+ q ? `<span class="flag ask" title="${q} open question${q > 1 ? 's' : ''}">?${q > 1 ? q : ''}</span>` : '',
826
+ a.raceId ? `<span class="flag" title="Race attempt${a.variant?.label ? `: ${esc(a.variant.label)}` : ''}">🏁</span>` : '',
827
+ a.forkOf ? `<span class="flag" title="Fork of ${esc(a.forkOf.name)}">⑂</span>` : '',
828
+ ].join('');
829
+ }
830
+
831
+ const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
832
+ const NEED_ORDER = { prompt: 0, waiting: 1, plan: 1, question: 1, review: 2, stuck: 3, conflict: 4 };
833
+ /** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
834
+ function needsFor(a, { both = false } = {}) {
835
+ const items = [];
836
+ if (a.runtime.attention) items.push({ a, kind: 'prompt', key: `prompt:${a.id}` });
837
+ if (a.runtime.waiting) items.push({ a, kind: 'waiting', key: `waiting:${a.id}` });
838
+ if (a.planReview?.status === 'review') items.push({ a, kind: 'plan', key: `plan:${a.id}` });
839
+ if (a.aiReview?.verdict && !a.aiReview.seen) items.push({ a, kind: 'review', key: `review:${a.id}` });
840
+ for (const q of a.questions ?? []) if (!q.answer) items.push({ a, kind: 'question', key: `q:${a.id}:${q.id}`, q });
841
+ for (const f of a.stuck ?? []) items.push({ a, kind: 'stuck', key: `stuck:${a.id}:${f.key}`, f });
842
+ for (const g of a.conflicts ?? []) if (both || g.mergeFirst !== a.id) items.push({ a, kind: 'conflict', key: `conflict:${a.id}:${g.agent.id}`, g });
843
+ return items;
844
+ }
845
+ function inboxItems() {
846
+ return [...agents.values()]
847
+ .flatMap((a) => needsFor(a))
848
+ .sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
849
+ }
850
+
851
+ const NEED_LABEL = { prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
852
+ /** Why the AI judge thinks so, as a small tag. */
853
+ const aiTag = (confidence, what = 'AI') => `<span class="tag ai" title="Judged by a model from the agent's activity; your reactions tune later calls">🤖 ${what}${confidence != null ? ` ${Math.round(confidence * 100)}%` : ''}</span>`;
854
+ function needCard(it, { inDetail = false } = {}) {
855
+ const { a } = it;
856
+ const who = inDetail
857
+ ? ''
858
+ : `<a href="#${a.id}" class="need-agent" data-goto="${a.id}">${prioChip(a.priority ?? 2)}${esc(a.name)}</a>${a.projectInfo?.name ? ` <span class="hint">${esc(a.projectInfo.name)}</span>` : ''}`;
859
+ const head = `<div class="need-head"><span class="need-kind ${it.kind}">${NEED_LABEL[it.kind]}</span>${who}</div>`;
860
+ const draft = (k, fallback = '') => esc(needDrafts.get(`${it.key}:${k}`) ?? fallback);
861
+ let body = '';
862
+ if (it.kind === 'prompt') {
863
+ const p = a.runtime.prompt;
864
+ body = `<p class="need-title">${esc(p?.question || a.runtime.attention)}</p>
865
+ <div class="need-actions">
866
+ ${(p?.choices ?? []).map((c) => `<button class="small ${c.key === '1' ? 'primary' : ''}" data-ib="key" data-key="${c.key}">${c.key}. ${esc(c.label)}</button>`).join('')}
867
+ <button class="small" data-ib="key" data-key="esc" title="Escape">Esc</button>
868
+ <button class="small ghost" data-ib="terminal">Open terminal</button>
869
+ </div>`;
870
+ } else if (it.kind === 'waiting') {
871
+ const w = a.runtime.waiting;
872
+ body = `<p class="need-title">${esc(w.question || w.summary)} ${aiTag(w.confidence, w.state === 'error' ? 'looks like an error' : 'read from its screen')}</p>
873
+ ${w.question && w.summary !== w.question ? `<p class="hint">${esc(w.summary)}</p>` : ''}
874
+ ${w.choices?.length ? `<div class="need-actions">${w.choices.slice(0, 6).map((c) => `<button class="small" data-ib="waiting-choice" data-text="${esc(c)}">${esc(c)}</button>`).join('')}</div>` : ''}
875
+ <form class="need-reply" data-ib-form="waiting">
876
+ <input data-draft="${esc(it.key)}:text" placeholder="${w.state === 'error' ? 'Tell it what to do…' : 'Type an answer…'}" value="${draft('text')}" />
877
+ <button class="small primary">Send</button>
878
+ </form>
879
+ <div class="need-actions"><button class="small ghost" data-ib="terminal">Open terminal</button><button class="small ghost" data-ib="waiting-dismiss" title="It isn't waiting on you; the judge learns from this">Not waiting</button></div>`;
880
+ } else if (it.kind === 'review') {
881
+ const r = a.aiReview;
882
+ body = `<p class="need-title"><span class="verdict ${esc(r.verdict)}">${REVIEW_VERDICT[r.verdict] ?? esc(r.verdict)}</span> ${esc(r.summary)} ${aiTag(r.confidence, 'review')}</p>
883
+ ${r.met.length ? `<p class="hint">Met: ${r.met.map(esc).join('; ')}</p>` : ''}
884
+ ${r.missing.length ? `<ul class="need-files">${r.missing.map((m) => `<li>${esc(m)}</li>`).join('')}</ul>` : ''}
885
+ ${r.risks.length ? `<p class="hint">Risks: ${r.risks.map(esc).join('; ')}</p>` : ''}
886
+ <div class="need-actions">
887
+ ${r.missing.length ? '<button class="small primary" data-ib="review-finish" title="Send the missing items to the agent">Send back to finish</button>' : ''}
888
+ <button class="small ${r.missing.length ? '' : 'primary'}" data-ib="review-accept">Looks good</button>
889
+ </div>`;
890
+ } else if (it.kind === 'plan') {
891
+ const title = a.planReview.markdown?.match(/^#\s+(.+)$/m)?.[1] ?? 'Implementation plan';
892
+ body = `<p class="need-title">${esc(title)}</p>
893
+ <p class="hint">Written ${ago(a.planReview.updatedAt)}. The agent waits for your approval before changing anything.</p>
894
+ <div class="need-actions"><button class="small primary" data-ib="plan-open">Review plan</button><button class="small" data-ib="plan-approve">Approve as is</button></div>`;
895
+ } else if (it.kind === 'question') {
896
+ const q = it.q;
897
+ body = `<p class="need-title">${esc(q.question)}</p>
898
+ ${q.context ? `<p class="hint">${esc(q.context)}</p>` : ''}
899
+ ${q.default ? `<p class="hint">Proceeding with <b>${esc(q.default)}</b> until you answer.</p>` : ''}
900
+ <div class="need-actions">${q.options.map((o) => `<button class="small" data-ib="answer" data-text="${esc(o)}">${esc(o)}</button>`).join('')}</div>
901
+ <form class="need-reply" data-ib-form="answer" data-q="${q.id}">
902
+ <input data-draft="${esc(it.key)}:text" placeholder="Or type an answer…" value="${draft('text')}" />
903
+ <button class="small primary">Send</button>
904
+ </form>`;
905
+ } else if (it.kind === 'stuck') {
906
+ const f = it.f;
907
+ const judged = f.ai?.verdict === 'stuck' ? aiTag(f.ai.confidence, 'confirmed') : f.ai?.verdict === 'checking' ? '<span class="tag ai">🤖 checking…</span>' : '';
908
+ body = `<p class="need-title">${esc(f.title)} <span class="hint">since ${ago(f.since)}</span> ${judged}</p>
909
+ <p class="hint">${esc(f.detail)}</p>
910
+ ${f.ai?.rule && f.ai.rule !== f.title ? `<p class="hint">Flagged by: ${esc(f.ai.rule)}</p>` : ''}
911
+ <form class="need-reply col" data-ib-form="nudge" data-k="${esc(f.key)}">
912
+ <textarea rows="3" data-draft="${esc(it.key)}:text">${draft('text', f.nudge)}</textarea>
913
+ <div class="need-actions"><button class="small primary">Send nudge</button><button type="button" class="small ghost" data-ib="dismiss">Dismiss</button></div>
914
+ </form>`;
915
+ } else {
916
+ const g = it.g;
917
+ const lines = g.files.filter((f) => f.severity === 'lines').length;
918
+ const first = g.mergeFirst === a.id ? `${esc(a.name)} lands first; ${esc(g.agent.name)} rebases onto it` : `${esc(g.agent.name)} lands first; ${esc(a.name)} rebases onto it`;
919
+ const other = `<a href="#${g.agent.id}" data-goto="${g.agent.id}">${esc(g.agent.name)}</a>`;
920
+ const tag = g.ai?.checking ? '<span class="tag ai">🤖 checking…</span>' : g.ai?.reason ? aiTag(g.ai.confidence, g.ai.conflict ? 'collision' : 'no collision') : '';
921
+ body = `<p class="need-title">${g.files.length ? `Changes the same ${g.files.length === 1 ? 'file' : 'files'} as ${other}` : `May collide with ${other}`}${lines ? ` <span class="tag fail">${lines} on the same lines</span>` : ''} ${tag}</p>
922
+ ${g.ai?.reason ? `<p class="hint">${esc(g.ai.reason)}</p>` : ''}
923
+ ${g.files.length ? `<ul class="need-files">${g.files.slice(0, 8).map((f) => `<li><code>${esc(f.file)}</code>${f.severity === 'lines' ? ' <span class="hint">same lines</span>' : ''}</li>`).join('')}${g.files.length > 8 ? `<li class="hint">…and ${g.files.length - 8} more</li>` : ''}</ul>` : ''}
924
+ <p class="hint">Suggested order: ${first}.${g.ai?.orderReason ? ` ${esc(g.ai.orderReason)}` : ''}</p>
925
+ <div class="need-actions">
926
+ <button class="small" data-ib="warn" data-with="${g.agent.id}" title="Tell ${esc(a.name)} about the overlap">Tell ${esc(a.name)}</button>
927
+ <button class="small" data-ib="warn-both" data-with="${g.agent.id}" title="Tell both agents">Tell both</button>
928
+ ${g.ai && !g.ai.checking ? `<button class="small ghost" data-ib="clash-dismiss" data-with="${g.agent.id}" title="They don't collide; the judge learns from this">Not a clash</button>` : ''}
929
+ </div>`;
930
+ }
931
+ return `<div class="need ${it.kind}" data-need="${esc(it.key)}" data-agent="${a.id}">${head}${body}</div>`;
932
+ }
933
+
934
+ const needDrafts = new Map(); // typed text survives live re-renders
935
+ document.addEventListener('input', (e) => {
936
+ if (e.target.dataset?.draft) needDrafts.set(e.target.dataset.draft, e.target.value);
937
+ });
938
+
939
+ /** The AI judge's state for the line under the inbox: it spends calls on the user's own plan. */
940
+ let judgeStatus = null;
941
+ async function loadJudge() {
942
+ try {
943
+ judgeStatus = await api('GET', '/judge');
944
+ } catch {
945
+ judgeStatus = null;
946
+ }
947
+ renderInbox();
948
+ }
949
+
950
+ function judgeLine() {
951
+ const j = judgeStatus;
952
+ if (!j) return '';
953
+ const text = j.enabled
954
+ ? `🤖 AI judge on: ${j.calls} of ${j.cap} calls today, on your own Claude or Codex plan. It checks stuck agents, idle screens, finished work, overlaps, backlog order and alerts.`
955
+ : '🤖 AI judge off: stuck agents, idle screens and alerts use fixed rules only.';
956
+ return `<p class="judge-line hint">${esc(text)} <button class="small ghost" data-judge="${j.enabled ? 'off' : 'on'}">${j.enabled ? 'Turn off' : 'Turn on'}</button></p>`;
957
+ }
958
+
959
+ function renderInbox() {
960
+ const items = inboxItems();
961
+ const urgent = items.filter((i) => i.kind === 'prompt' || i.kind === 'question' || i.kind === 'waiting').length;
962
+ $('#inbox-count').textContent = items.length ? String(items.length) : '';
963
+ $('#inbox-count').className = `badge ${urgent ? 'attention' : ''}`;
964
+ if (sideTab !== 'inbox') return;
965
+ const view = $('#inbox-view');
966
+ if (view.contains(document.activeElement) && document.activeElement.dataset.draft) return; // don't yank the field being typed in
967
+ view.innerHTML = items.length
968
+ ? `<div class="counts"><span>${items.length} waiting on you</span></div><div class="needs inbox">${items.map((it) => needCard(it)).join('')}</div>`
969
+ : `<div class="empty"><p>Nothing needs you right now.</p><p>Prompts waiting for an answer, questions agents ask, agents that seem stuck and agents editing the same files show up here.</p></div>`;
970
+ view.insertAdjacentHTML('beforeend', judgeLine());
971
+ }
972
+
973
+ async function onNeed(e) {
974
+ const judge = e.target.closest('[data-judge]');
975
+ if (judge) {
976
+ e.preventDefault();
977
+ return act(async () => {
978
+ judgeStatus = await api('PUT', '/judge', { enabled: judge.dataset.judge === 'on' });
979
+ renderInbox();
980
+ }, judge.dataset.judge === 'on' ? 'AI judge on' : 'AI judge off');
981
+ }
982
+ const goto = e.target.closest('[data-goto]');
983
+ if (goto) {
984
+ e.preventDefault();
985
+ showTab('agents');
986
+ return select(goto.dataset.goto);
987
+ }
988
+ const el = e.target.closest('[data-ib]');
989
+ if (!el) return;
990
+ e.preventDefault();
991
+ const card = el.closest('[data-need]');
992
+ const id = card.dataset.agent;
993
+ const key = card.dataset.need;
994
+ switch (el.dataset.ib) {
995
+ case 'key':
996
+ return act(() => api('POST', `/agents/${id}/prompt-key`, { key: el.dataset.key }), 'Sent');
997
+ case 'terminal':
998
+ showTab('agents');
999
+ select(id);
1000
+ return term.open(id);
1001
+ case 'answer': {
1002
+ const q = key.split(':')[2];
1003
+ return act(async () => upsert(await api('POST', `/agents/${id}/answer`, { question: q, text: el.dataset.text })), 'Answer sent');
1004
+ }
1005
+ case 'plan-open':
1006
+ return openPlan(id);
1007
+ case 'plan-approve':
1008
+ return act(async () => upsert(await api('POST', `/agents/${id}/plan/approve`, {})), 'Approved; the agent is implementing it');
1009
+ case 'clash-dismiss':
1010
+ return act(async () => upsert(await api('POST', `/agents/${id}/conflicts/dismiss`, { with: el.dataset.with })));
1011
+ case 'waiting-choice':
1012
+ return act(async () => upsert(await api('POST', `/agents/${id}/waiting/answer`, { text: el.dataset.text })), 'Answer queued');
1013
+ case 'waiting-dismiss':
1014
+ return act(async () => upsert(await api('POST', `/agents/${id}/waiting/dismiss`)));
1015
+ case 'review-finish':
1016
+ return act(async () => upsert(await api('POST', `/agents/${id}/review/ack`, { action: 'finish' })), 'Sent back with the missing items');
1017
+ case 'review-accept':
1018
+ return act(async () => upsert(await api('POST', `/agents/${id}/review/ack`, { action: 'accept' })));
1019
+ case 'dismiss':
1020
+ return act(async () => upsert(await api('POST', `/agents/${id}/stuck/dismiss`, { key: card.querySelector('[data-k]').dataset.k })));
1021
+ case 'warn':
1022
+ return act(async () => upsert(await api('POST', `/agents/${id}/conflicts/warn`, { with: el.dataset.with })), 'Queued for the agent');
1023
+ case 'warn-both':
1024
+ return act(async () => {
1025
+ upsert(await api('POST', `/agents/${id}/conflicts/warn`, { with: el.dataset.with }));
1026
+ upsert(await api('POST', `/agents/${el.dataset.with}/conflicts/warn`, { with: id }));
1027
+ }, 'Queued for both agents');
1028
+ }
1029
+ }
1030
+ async function onNeedSubmit(e) {
1031
+ const form = e.target.closest('[data-ib-form]');
1032
+ if (!form) return;
1033
+ e.preventDefault();
1034
+ const card = form.closest('[data-need]');
1035
+ const id = card.dataset.agent;
1036
+ const field = form.querySelector('[data-draft]');
1037
+ const text = field.value.trim();
1038
+ if (!text) return;
1039
+ const done = () => needDrafts.delete(field.dataset.draft);
1040
+ if (form.dataset.ibForm === 'waiting') {
1041
+ await act(async () => upsert(await api('POST', `/agents/${id}/waiting/answer`, { text })), 'Answer queued');
1042
+ } else if (form.dataset.ibForm === 'answer') {
1043
+ await act(async () => upsert(await api('POST', `/agents/${id}/answer`, { question: form.dataset.q, text })), 'Answer sent');
1044
+ } else {
1045
+ await act(async () => upsert(await api('POST', `/agents/${id}/stuck/nudge`, { key: form.dataset.k, text })), 'Nudge queued');
1046
+ }
1047
+ done();
1048
+ }
1049
+ for (const root of [$('#inbox-view'), $('#detail-body')]) {
1050
+ root.addEventListener('click', onNeed);
1051
+ root.addEventListener('submit', onNeedSubmit);
1052
+ }
1053
+
1054
+ // ------------------------------------------------------------------ checkpoints and forks
1055
+
1056
+ function checkpointsCard(a) {
1057
+ if (a.kind === 'general' || !a.git?.branch) return '';
1058
+ const cps = [...(a.checkpoints ?? [])].reverse();
1059
+ const forks = [...agents.values()].filter((x) => x.forkOf?.agentId === a.id);
1060
+ return `<div class="card">
1061
+ <h3>Checkpoints <span>${cps.length || ''}</span> <span class="spacer"></span><button class="small" data-act="checkpoint" title="Save the code and conversation as they are now">Save now</button></h3>
1062
+ <p class="hint">Saved at the end of each turn: the code (including uncommitted work) and the conversation up to that point. Fork from one to try a different direction from there.</p>
1063
+ ${cps.length
1064
+ ? `<ul class="links checkpoints">${cps
1065
+ .slice(0, 12)
1066
+ .map((c) => `<li><span class="mono">#${c.n}</span><span class="grow" title="${esc(c.prompt ?? '')}">${esc(c.prompt ?? 'checkpoint')}</span><span class="hint">${ago(c.at)}</span><button class="small" data-fork-cp="${c.n}">Fork from here</button></li>`)
1067
+ .join('')}</ul>`
1068
+ : '<p class="none">None yet: the first one is saved when the agent finishes a turn.</p>'}
1069
+ ${forks.length ? `<p class="hint">Forks: ${forks.map((f) => `<a href="#${f.id}" data-goto="${f.id}">${esc(f.name)}</a>`).join(', ')}</p>` : ''}
1070
+ </div>`;
1071
+ }
1072
+
1073
+ $('#detail-body').addEventListener('click', (e) => {
1074
+ const cp = e.target.closest('[data-fork-cp]');
1075
+ if (cp) forkAgent(selectedId, Number(cp.dataset.forkCp));
1076
+ const race = e.target.closest('[data-race-open]');
1077
+ if (race) openRace(race.dataset.raceOpen);
1078
+ });
1079
+
1080
+ function forkAgent(id, checkpoint) {
1081
+ const how = prompt(
1082
+ `Fork ${agents.get(id)?.name}${checkpoint ? ` from checkpoint ${checkpoint}` : ''}: a new agent with the same code and conversation.\n\nWhat should the fork do differently? (optional, sent as its first message)`,
1083
+ '',
1084
+ );
1085
+ if (how === null) return;
1086
+ act(async () => {
1087
+ const fork = await api('POST', `/agents/${id}/fork`, { checkpoint, instructions: how || undefined });
1088
+ upsert(fork);
1089
+ select(fork.id);
1090
+ }, 'Fork created');
1091
+ }
1092
+
1093
+ // ------------------------------------------------------------------ races
1094
+
1095
+ function raceBanner(a) {
1096
+ if (!a.raceId) return '';
1097
+ const r = races.get(a.raceId);
1098
+ const n = r?.rows.length ?? 0;
1099
+ return `<div class="bl-banner race">🏁 Race attempt${a.variant?.label ? ` <b>${esc(a.variant.label)}</b>` : ''}${n ? ` · ${n} attempts` : ''}${r?.winner === a.id ? ' · <b>winner</b>' : ''}
1100
+ <span class="spacer"></span><button class="small primary" data-race-open="${a.raceId}">Compare</button></div>`;
1101
+ }
1102
+
1103
+ function openRace(id) {
1104
+ openRaceId = id;
1105
+ raceDiff = null;
1106
+ renderRace();
1107
+ if (!$('#race-dialog').open) $('#race-dialog').showModal();
1108
+ act(async () => races.set(id, await api('GET', `/races/${id}`)));
1109
+ }
1110
+
1111
+ const fmtCost = (c) => (c ? `$${c.toFixed(2)}` : '—');
1112
+ const testCell = (t) =>
1113
+ !t ? '<span class="hint">—</span>' : `<span class="test ${t.status}" title="${esc(t.output ?? '')}">${{ passed: '✓ passed', failed: '✗ failed', running: '… running', queued: 'queued', error: '⚠ error' }[t.status] ?? t.status}${t.seconds != null ? ` <span class="hint">${t.seconds}s</span>` : ''}</span>`;
1114
+
1115
+ function renderRace() {
1116
+ const dlg = $('#race-dialog');
1117
+ if (!dlg.open && !openRaceId) return;
1118
+ const r = races.get(openRaceId);
1119
+ if (!r) {
1120
+ $('#race-body').innerHTML = '<p class="hint">Loading…</p>';
1121
+ return;
1122
+ }
1123
+ if ($('#race-body').contains(document.activeElement) && document.activeElement.name === 'testCommand') return;
1124
+ $('#race-title').textContent = `Race: ${r.intent.split('\n')[0].slice(0, 80)}`;
1125
+ // Tag a row only when it is strictly the best of several.
1126
+ const uniqueMin = (vals) => {
1127
+ const v = vals.filter((x) => x != null && x > 0);
1128
+ const m = Math.min(...v);
1129
+ return v.length > 1 && v.filter((x) => x === m).length === 1 ? m : null;
1130
+ };
1131
+ const cheapest = uniqueMin(r.rows.map((x) => x.costUsd));
1132
+ const smallest = uniqueMin(r.rows.map((x) => x.added + x.removed));
1133
+ $('#race-body').innerHTML = `
1134
+ <form class="race-tests" id="race-tests">
1135
+ <label>Test command <input name="testCommand" value="${esc(r.testCommand ?? '')}" placeholder="npm test" /></label>
1136
+ <button class="small primary">Run in every attempt</button>
1137
+ </form>
1138
+ <div class="race-table-wrap"><table class="race-table">
1139
+ <tr><th>Attempt</th><th>Status</th><th>Outcome</th><th>Checks</th><th>Change</th><th>Tests</th><th>Cost</th><th>Time</th><th></th></tr>
1140
+ ${r.rows
1141
+ .map((x) => {
1142
+ const a = agents.get(x.id);
1143
+ const st = a ? statusOf(a) : x.status;
1144
+ return `<tr class="${r.winner === x.id ? 'winner' : ''} ${raceDiff?.agentId === x.id ? 'diffing' : ''}">
1145
+ <td><a href="#${x.id}" data-race-goto="${x.id}"><b>${esc(x.variant?.label ?? x.name)}</b></a>${x.variant?.model ? ` <span class="tag">${esc(x.variant.model)}</span>` : ''}${x.variant?.instructions ? `<div class="hint">${esc(x.variant.instructions)}</div>` : ''}</td>
1146
+ <td><span class="pill ${st}">${pillText(st)}</span></td>
1147
+ <td>${x.outcome ? esc(x.outcome) : '<span class="hint">—</span>'}</td>
1148
+ <td>${x.checks.total ? `✓${x.checks.passed}/${x.checks.total}${x.checks.failed ? ` <span class="fail">✗${x.checks.failed}</span>` : ''}` : '<span class="hint">—</span>'}</td>
1149
+ <td>${x.files ? `${x.files} file${x.files > 1 ? 's' : ''} <span class="add">+${x.added}</span> <span class="del">−${x.removed}</span>${x.added + x.removed === smallest ? ' <span class="tag">smallest</span>' : ''}` : '<span class="hint">no changes</span>'}</td>
1150
+ <td>${testCell(x.tests)}</td>
1151
+ <td>${fmtCost(x.costUsd)}${x.costUsd === cheapest ? ' <span class="tag">cheapest</span>' : ''}</td>
1152
+ <td>${x.minutes != null ? `${x.minutes} min` : '—'}</td>
1153
+ <td class="race-acts">
1154
+ <button class="small" data-race-diff="${x.id}">Diff</button>
1155
+ ${r.winner === x.id ? '<span class="tag open">winner</span>' : `<button class="small" data-race-pick="${x.id}">Pick</button>`}
1156
+ </td>
1157
+ </tr>`;
1158
+ })
1159
+ .join('')}
1160
+ </table></div>
1161
+ ${raceDiff
1162
+ ? `<div class="race-diff"><div class="need-head"><b>Changes in ${esc(r.rows.find((x) => x.id === raceDiff.agentId)?.variant?.label ?? '')}</b><span class="spacer"></span><button class="small ghost" data-race-diff-close>Close diff</button></div>
1163
+ <pre class="diff">${raceDiff.text ? diffHtml(raceDiff.text) : '<span class="hint">No changes yet.</span>'}</pre></div>`
1164
+ : '<p class="hint">Pick a winner when you have seen enough. You can delete the other attempts and their worktrees at the same time.</p>'}`;
1165
+ }
1166
+
1167
+ function diffHtml(text) {
1168
+ return text
1169
+ .split('\n')
1170
+ .map((l) => {
1171
+ const cls = l.startsWith('+++') || l.startsWith('---') || l.startsWith('diff ') ? 'meta' : l.startsWith('@@') ? 'hunk' : l.startsWith('+') ? 'add' : l.startsWith('-') ? 'del' : '';
1172
+ return `<span class="${cls}">${esc(l)}</span>`;
1173
+ })
1174
+ .join('\n');
1175
+ }
1176
+
1177
+ $('#race-close').onclick = () => {
1178
+ openRaceId = null;
1179
+ $('#race-dialog').close();
1180
+ };
1181
+ $('#race-delete').onclick = () => {
1182
+ if (!confirm('Forget this race? Its agents stay; only the comparison is removed.')) return;
1183
+ const id = openRaceId;
1184
+ act(async () => {
1185
+ await api('DELETE', `/races/${id}`);
1186
+ races.delete(id);
1187
+ openRaceId = null;
1188
+ $('#race-dialog').close();
1189
+ render();
1190
+ });
1191
+ };
1192
+ $('#race-body').addEventListener('submit', (e) => {
1193
+ e.preventDefault();
1194
+ const command = new FormData(e.target).get('testCommand');
1195
+ act(async () => races.set(openRaceId, await api('POST', `/races/${openRaceId}/tests`, { command })), 'Running tests');
1196
+ });
1197
+ $('#race-body').addEventListener('click', (e) => {
1198
+ const t = e.target;
1199
+ const go = t.closest('[data-race-goto]');
1200
+ if (go) {
1201
+ e.preventDefault();
1202
+ openRaceId = null;
1203
+ $('#race-dialog').close();
1204
+ showTab('agents');
1205
+ return select(go.dataset.raceGoto);
1206
+ }
1207
+ if (t.closest('[data-race-diff-close]')) {
1208
+ raceDiff = null;
1209
+ return renderRace();
1210
+ }
1211
+ const d = t.closest('[data-race-diff]');
1212
+ if (d) {
1213
+ return act(async () => {
1214
+ raceDiff = { agentId: d.dataset.raceDiff, text: (await api('GET', `/races/${openRaceId}/diff/${d.dataset.raceDiff}`)).diff };
1215
+ renderRace();
1216
+ });
1217
+ }
1218
+ const p = t.closest('[data-race-pick]');
1219
+ if (p) {
1220
+ const r = races.get(openRaceId);
1221
+ const label = r.rows.find((x) => x.id === p.dataset.racePick)?.variant?.label;
1222
+ if (!confirm(`Pick "${label}" as the winner?`)) return;
1223
+ const cleanup = r.rows.length > 1 && confirm('Also close and delete the other attempts, including their worktrees? (Cancel keeps them.)');
1224
+ return act(async () => races.set(openRaceId, await api('POST', `/races/${openRaceId}/pick`, { agent: p.dataset.racePick, cleanup })), 'Winner picked');
1225
+ }
1226
+ });
1227
+
1228
+ // ------------------------------------------------------------------ detail
1229
+
1230
+ function prRow(a, p) {
1231
+ const state = p.isDraft ? 'draft' : (p.state ?? '').toLowerCase();
1232
+ const label = p.number ? `#${p.number}` : 'PR';
1233
+ return `<li>
1234
+ ${state ? `<span class="tag ${state}">${state}</span>` : ''}
1235
+ <a class="grow" href="${esc(p.url)}" target="_blank" rel="noopener" title="${esc(p.url)}">${label} ${esc(p.title ?? p.repo ?? p.url)}</a>
1236
+ <span class="tag" title="How this was found">${esc(p.source)}</span>
1237
+ <button class="link" data-unlink="pr" data-key="${esc(p.url)}" title="Remove">✕</button>
1238
+ </li>`;
1239
+ }
1240
+
1241
+ function bugRow(a, b) {
1242
+ const inner = b.url ? `<a href="${esc(b.url)}" target="_blank" rel="noopener">${esc(b.key)}</a>` : esc(b.key);
1243
+ return `<li>
1244
+ <span class="grow">${inner}${b.title ? ` <span class="hint">${esc(b.title)}</span>` : ''}</span>
1245
+ <span class="tag">${esc(b.source)}</span>
1246
+ <button class="link" data-unlink="bug" data-key="${esc(b.key)}" title="Remove">✕</button>
1247
+ </li>`;
1248
+ }
1249
+
1250
+ function renderDetail() {
1251
+ const a = agents.get(selectedId);
1252
+ $('#placeholder').hidden = Boolean(a);
1253
+ const body = $('#detail-body');
1254
+ body.hidden = !a;
1255
+ if (!a) return;
1256
+
1257
+ // Keep whatever the user is typing across live re-renders.
1258
+ const active = document.activeElement;
1259
+ const keepAttr = active?.dataset?.field ? 'field' : active?.dataset?.draft ? 'draft' : null;
1260
+ const keep = body.contains(active) && keepAttr ? { attr: keepAttr, field: active.dataset[keepAttr], value: active.value, pos: active.selectionStart } : null;
1261
+ const drafts = Object.fromEntries([...body.querySelectorAll('[data-field]')].map((el) => [el.dataset.field, el.value]));
1262
+
1263
+ const st = statusOf(a);
1264
+ const running = a.runtime.running;
1265
+ const totalCost = a.costUsd ?? 0;
1266
+ const u = a.usage ?? { total: 0 };
1267
+ const current = a.sessions.find((s) => s.id === a.sessionId);
1268
+
1269
+ body.innerHTML = `
1270
+ <div class="d-head">
1271
+ <div class="d-title">
1272
+ <h2>
1273
+ <select class="prio-select p${a.priority ?? 2}" data-priority="${a.id}" title="Priority: P0 most urgent … P3 lowest">
1274
+ ${[0, 1, 2, 3].map((p) => `<option value="${p}" ${p === (a.priority ?? 2) ? 'selected' : ''}>P${p}</option>`).join('')}
1275
+ </select>
1276
+ <span class="d-name">${esc(a.name)}</span> <span class="pill ${st}">${pillText(st)}</span>${a.kind === 'general' ? ' <span class="tag">general</span>' : ''}</h2>
1277
+ <p class="intent">${esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : ''))}</p>
1278
+ </div>
1279
+ <div class="d-actions">
1280
+ ${a.status === 'error' && !a.workdir
1281
+ ? `<button class="primary" data-act="start" title="Run the clone / worktree setup again">Retry setup</button>
1282
+ ${running ? '<button data-act="terminal">Open terminal</button>' : ''}`
1283
+ : a.status === 'preparing' && !running
1284
+ ? `<button class="primary" data-act="plan" title="Talk the task through with Claude while the workspace is prepared">Plan while it clones</button>`
1285
+ : `<button class="primary" data-act="terminal">${running ? 'Open terminal' : 'Resume & open terminal'}</button>`}
1286
+ ${running
1287
+ ? `<button data-act="stop" title="Close the claude process; the session ID is kept">Close session</button>`
1288
+ : a.status === 'error' && !a.workdir
1289
+ ? ''
1290
+ : `<button data-act="start" ${a.status === 'preparing' ? 'disabled' : ''}>Resume in background</button>`}
1291
+ <button data-act="fresh" ${running || a.status === 'preparing' ? 'disabled' : ''} title="Start over with a new session ID (old ones are kept)">New session</button>
1292
+ <button data-act="shell" ${a.workdir && a.status !== 'preparing' ? '' : 'disabled'} title="A separate terminal in this agent's folder for running commands by hand">Open shell</button>
1293
+ <button data-act="fork" ${a.workdir && a.git?.branch ? '' : 'disabled'} title="A new agent with this one's code and conversation, in its own worktree, to try another approach">Fork</button>
1294
+ <button data-act="refresh" title="Re-scan git, PRs and transcripts">Refresh</button>
1295
+ <button class="danger" data-act="delete">Delete</button>
1296
+ </div>
1297
+ </div>
1298
+ ${(() => {
1299
+ const it = a.backlogItem && backlogState.items.find((x) => x.id === a.backlogItem);
1300
+ if (!it) return '';
1301
+ return `<div class="bl-banner ${it.status}">Backlog item <code>${it.id}</code>: <b>${esc(it.status)}</b>
1302
+ ${it.status === 'review' ? `<span class="spacer"></span><button class="small primary" data-backlog-accept="${it.id}">Accept</button> <button class="small" data-backlog-reopen="${it.id}">Reopen</button>` : ''}</div>`;
1303
+ })()}
1304
+ ${a.error ? `<div class="error-box">${esc(a.error)}</div>` : ''}
1305
+ ${a.status === 'preparing'
1306
+ ? `<div class="progress-box">
1307
+ <div>${esc(progressText(a.progress) || 'Preparing workspace…')}</div>
1308
+ ${a.progress?.step ? `<div class="bar"><span style="width:${a.progress.percent}%"></span></div>` : ''}
1309
+ <div class="hint">${a.runtime.planning
1310
+ ? 'A planning session is running in the terminal. It moves into the repo, with the whole conversation, once the workspace is ready.'
1311
+ : 'Claude starts as soon as the workspace is ready. Use <b>Plan while it clones</b> to clarify the task with Claude meanwhile. Deleting the agent cancels setup.'}</div>
1312
+ </div>`
1313
+ : ''}
1314
+ ${a.runtime.planning && a.workdir && a.status !== 'preparing'
1315
+ ? `<div class="ready-box">
1316
+ <span>The workspace is ready. The planning session moves into the repo when you leave the terminal, or now:</span>
1317
+ <button class="primary small" data-act="handoff">Continue in repo</button>
1318
+ </div>`
1319
+ : ''}
1320
+ ${a.runtime.attention ? `<div class="error-box">${esc(a.runtime.attention)}: the agent is waiting for an answer. Open its terminal to respond.</div>` : ''}
1321
+ ${raceBanner(a)}
1322
+ ${a.forkOf ? `<div class="bl-banner">⑂ Forked from <a href="#${a.forkOf.agentId}" data-goto="${a.forkOf.agentId}">${esc(a.forkOf.name)}</a>${a.forkOf.checkpoint ? ` at checkpoint ${a.forkOf.checkpoint}` : ''} ${ago(a.forkOf.at)}</div>` : ''}
1323
+ ${(() => {
1324
+ const items = needsFor(a, { both: true });
1325
+ return items.length ? `<div class="needs">${items.map((it) => needCard(it, { inDetail: true })).join('')}</div>` : '';
1326
+ })()}
1327
+ ${a.outbox ? `<div class="hint outbox-note">✉ ${a.outbox} message${a.outbox > 1 ? 's' : ''} waiting to be typed into the session when the agent is idle</div>` : ''}
1328
+
1329
+ ${briefPanel(a)}
1330
+
1331
+ <div class="grid">
1332
+ <div class="card">
1333
+ <h3>Session</h3>
1334
+ <dl class="kv">
1335
+ <dt>Session ID</dt><dd><code>${esc(a.sessionId)}</code>${copyBtn(a.sessionId)}</dd>
1336
+ <dt>Title</dt><dd>${esc(current?.title ?? '—')}</dd>
1337
+ <dt>Agent ID</dt><dd><code>${a.id}</code></dd>
1338
+ <dt>Last output</dt><dd>${a.runtime.lastOutputAt ? ago(a.runtime.lastOutputAt) : current?.lastActivity ? ago(current.lastActivity) : '—'}</dd>
1339
+ <dt>Tokens</dt><dd class="tokens">${u.total
1340
+ ? `<button class="link tok-toggle" data-tokens="${a.id}" title="Input + output tokens. Click for the full breakdown.">${fmtTokens(ioOf(u))} I/O ${tokensOpen.has(a.id) ? '▾' : '▸'}</button>
1341
+ ${tokensOpen.has(a.id)
1342
+ ? `<div class="tok-detail">${fmtTokens(u.input)} in · ${fmtTokens(u.output)} out · ${fmtTokens(u.cacheRead)} cache read · ${fmtTokens(u.cacheCreation)} cache write
1343
+ <br>${fmtTokens(u.total)} total incl. cache · ${u.requests} requests</div>`
1344
+ : ''}`
1345
+ : '—'}</dd>
1346
+ <dt>Cost</dt><dd>${totalCost ? `$${totalCost.toFixed(2)} <span class="hint">(reported by Claude Code)</span>` : '—'}</dd>
1347
+ <dt>Mode</dt><dd>${esc(a.permissionMode)}${a.model ? ` · ${esc(a.model)}` : ''}</dd>
1348
+ </dl>
1349
+ </div>
1350
+
1351
+ <div class="card">
1352
+ <h3>Workspace</h3>
1353
+ <dl class="kv">
1354
+ <dt>Folder</dt><dd><code>${esc(a.workdir ?? (a.status === 'preparing' ? 'preparing…' : '—'))}</code>${a.workdir ? copyBtn(a.workdir) : ''}</dd>
1355
+ <dt>Project</dt><dd>${esc(a.projectInfo?.full || a.projectInfo?.name || '—')} <button class="link" data-act="project" title="Group this agent under a different project name">✎</button></dd>
1356
+ <dt>Repo</dt><dd>${a.repo ? `<code>${esc(a.repo)}</code>` : '—'}</dd>
1357
+ <dt>Branch</dt><dd>${a.git?.branch ? `<code>${esc(a.git.branch)}</code>` : '—'}${a.git?.dirtyFiles ? ` <span class="tag">${a.git.dirtyFiles} changed</span>` : ''}</dd>
1358
+ <dt>Head</dt><dd>${a.git?.head ? `<code>${esc(a.git.head.sha)}</code> ${esc(a.git.head.subject)}` : '—'}</dd>
1359
+ ${a.baseRef ? `<dt>Branched from</dt><dd><code>${esc(a.baseRef)}</code></dd>` : ''}
1360
+ ${a.worktree ? `<dt>Worktree of</dt><dd><code>${esc(a.sourceDir)}</code></dd>` : ''}
1361
+ </dl>
1362
+ </div>
1363
+
1364
+ <div class="card">
1365
+ <h3>Pull requests <span>${a.prs.length || ''}</span></h3>
1366
+ <ul class="links">${a.prs.length ? a.prs.map((p) => prRow(a, p)).join('') : '<li class="none">None detected yet</li>'}</ul>
1367
+ <form class="add-link" data-link="pr">
1368
+ <input data-field="pr" placeholder="Link a PR URL" value="${esc(drafts.pr ?? '')}" />
1369
+ <button class="small">Add</button>
1370
+ </form>
1371
+ </div>
1372
+
1373
+ <div class="card">
1374
+ <h3>Bugs <span>${a.bugs.length || ''}</span></h3>
1375
+ <ul class="links">${a.bugs.length ? a.bugs.map((b) => bugRow(a, b)).join('') : '<li class="none">None detected yet</li>'}</ul>
1376
+ <form class="add-link" data-link="bug">
1377
+ <input data-field="bug" placeholder="Bug key or URL, e.g. PROJ-123" value="${esc(drafts.bug ?? '')}" />
1378
+ <button class="small">Add</button>
1379
+ </form>
1380
+ </div>
1381
+
1382
+ <div class="card">
1383
+ <h3>Session history <span>${a.sessions.length}</span></h3>
1384
+ <table class="sessions">
1385
+ <tr><th>ID</th><th>Title</th><th title="Input + output tokens">I/O tokens</th><th>Active</th><th></th></tr>
1386
+ ${[...a.sessions]
1387
+ .reverse()
1388
+ .map(
1389
+ (s) => `<tr class="${s.id === a.sessionId ? 'current' : ''}">
1390
+ <td><code title="${esc(s.id)}">${s.id.slice(0, 8)}</code>${copyBtn(s.id)}</td>
1391
+ <td>${esc(s.title ?? '')}</td>
1392
+ <td title="${s.usage ? esc(usageTitle(s.usage)) : ''}">${s.usage ? fmtTokens(ioOf(s.usage)) : ''}</td>
1393
+ <td>${ago(s.lastActivity ?? s.lastStartedAt)}</td>
1394
+ <td>${s.id === a.sessionId ? 'current' : running ? '' : `<button class="small" data-resume-session="${s.id}">Resume</button>`}</td>
1395
+ </tr>`,
1396
+ )
1397
+ .join('')}
1398
+ </table>
1399
+ </div>
1400
+
1401
+ ${checkpointsCard(a)}
1402
+
1403
+ <div class="card">
1404
+ <h3>Notes</h3>
1405
+ <textarea class="notes" data-field="notes" placeholder="Anything worth remembering about this agent">${esc(drafts.notes ?? a.notes ?? '')}</textarea>
1406
+ </div>
1407
+ </div>`;
1408
+
1409
+ renderDiagrams(body);
1410
+ markOverflow(body);
1411
+
1412
+ if (keep) {
1413
+ const el = body.querySelector(`[data-${keep.attr}="${CSS.escape(keep.field)}"]`);
1414
+ if (el) {
1415
+ el.value = keep.value;
1416
+ el.focus();
1417
+ el.setSelectionRange?.(keep.pos, keep.pos);
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ function render() {
1423
+ renderList();
1424
+ renderBacklog();
1425
+ renderInbox();
1426
+ renderRace();
1427
+ renderDocDialog();
1428
+ renderDetail();
1429
+ term.syncOverlay();
1430
+ }
1431
+
1432
+ function select(id) {
1433
+ selectedId = id;
1434
+ history.replaceState(null, '', id ? `#${id}` : '#');
1435
+ if (term.agentId && term.agentId !== id) term.close();
1436
+ render();
1437
+ }
1438
+
1439
+ // ------------------------------------------------------------------ terminal
1440
+
1441
+
1442
+ const term = {
1443
+ agentId: null,
1444
+ ws: null,
1445
+ xterm: null,
1446
+ fit: null,
1447
+
1448
+ ensure() {
1449
+ if (this.xterm) return;
1450
+ this.xterm = new Terminal({
1451
+ cursorBlink: true,
1452
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
1453
+ fontSize: 13,
1454
+ scrollback: 10000,
1455
+ theme: { background: '#0f1117' },
1456
+ allowProposedApi: true,
1457
+ });
1458
+ this.fit = new FitAddon();
1459
+ this.xterm.loadAddon(this.fit);
1460
+ this.xterm.open($('#terminal'));
1461
+ this.xterm.onData((d) => {
1462
+ lastTyped = Date.now();
1463
+ this.send({ t: 'i', d });
1464
+ });
1465
+ this.xterm.onResize(({ cols, rows }) => this.send({ t: 'r', cols, rows }));
1466
+ new ResizeObserver(() => this.refit()).observe($('#terminal'));
1467
+ touchScroll($('#terminal'), this.xterm);
1468
+ },
1469
+
1470
+ send(msg) {
1471
+ this.conn?.send(msg);
1472
+ },
1473
+
1474
+ refit() {
1475
+ if (!this.agentId) return;
1476
+ try {
1477
+ this.fit.fit();
1478
+ } catch {}
1479
+ },
1480
+
1481
+ kind: 'claude', // 'claude' = the agent's session, 'shell:N' = shell N in its folder
1482
+ shellExited: false,
1483
+
1484
+ open(id, kind = 'claude') {
1485
+ if (isBrowser(kind)) return this.openBrowser(id, kind);
1486
+ liveView.close();
1487
+ $('#term-wrap').classList.remove('browsing');
1488
+ if (isShell(kind)) kind = `shell:${shellNum(kind)}`;
1489
+ history.replaceState(null, '', `#${id}/${isShell(kind) ? kind : 'term'}`);
1490
+ $('#term-wrap').hidden = false;
1491
+ $('#detail').classList.add('with-term');
1492
+ this.ensure();
1493
+ if (this.agentId === id && this.kind === kind && this.conn) {
1494
+ this.xterm.focus();
1495
+ return;
1496
+ }
1497
+ this.disconnect();
1498
+ this.agentId = id;
1499
+ this.kind = kind;
1500
+ this.shellExited = false;
1501
+ this.xterm.reset();
1502
+ this.syncOverlay();
1503
+ requestAnimationFrame(() => this.refit());
1504
+
1505
+ const conn = openStream(
1506
+ { agentId: id, kind },
1507
+ {
1508
+ onOpen: () => {
1509
+ this.refit();
1510
+ this.send({ t: 'r', cols: this.xterm.cols, rows: this.xterm.rows });
1511
+ },
1512
+ onMessage: (msg) => this.conn === conn && this.onMessage(msg),
1513
+ onClose: (ev) => {
1514
+ if (this.conn !== conn) return;
1515
+ this.conn = null;
1516
+ if (ev.code === 1008) toast(`Terminal unavailable: ${ev.reason}`, 5000);
1517
+ },
1518
+ },
1519
+ );
1520
+ this.conn = conn;
1521
+ },
1522
+
1523
+ onMessage(msg) {
1524
+ {
1525
+ if (msg.t === 'hello') {
1526
+ // Also sent again after a reconnect: start from a clean screen and replay.
1527
+ this.xterm.reset();
1528
+ this.xterm.write(msg.backlog);
1529
+ if (!matchMedia('(pointer: coarse)').matches) this.xterm.focus();
1530
+ } else if (msg.t === 'error') {
1531
+ toast(`Terminal unavailable: ${msg.error}`, 5000);
1532
+ } else if (msg.t === 'o') {
1533
+ this.xterm.write(msg.d);
1534
+ } else if (msg.t === 'exit') {
1535
+ if (isShell(this.kind)) this.shellExited = true;
1536
+ else this.xterm.write(`\r\n\x1b[2m[claude exited with code ${msg.code}; session kept]\x1b[0m\r\n`);
1537
+ }
1538
+ this.syncOverlay();
1539
+ }
1540
+ },
1541
+
1542
+ syncOverlay() {
1543
+ if (isBrowser(this.kind)) return this.renderTabs();
1544
+ const a = this.agentId && agents.get(this.agentId);
1545
+ const shell = isShell(this.kind);
1546
+ const closed = shell ? this.shellExited : a && !a.runtime.running && a.status !== 'preparing';
1547
+ $('#term-overlay').hidden = !a || !closed;
1548
+ $('#term-overlay-msg').textContent = shell ? 'The shell has exited.' : "This agent's session is closed.";
1549
+ $('#term-resume').textContent = shell ? 'Start a new shell' : 'Resume session';
1550
+ this.renderTabs();
1551
+ if (a) {
1552
+ $('#term-title').textContent = shell ? `shell ${shellNum(this.kind)} · ${a.workdir}` : `${a.name} · session ${a.sessionId}`;
1553
+ }
1554
+ },
1555
+
1556
+ /** One tab per running shell, plus the one being viewed even if it has exited. */
1557
+ renderTabs() {
1558
+ renderSignIn();
1559
+ const a = this.agentId && agents.get(this.agentId);
1560
+ const nums = new Set(a?.runtime.shells ?? []);
1561
+ if (isShell(this.kind)) nums.add(shellNum(this.kind));
1562
+ $('#shell-tabs').innerHTML = [...nums]
1563
+ .sort((x, y) => x - y)
1564
+ .map((n) => `<button class="term-tab" data-kind="shell:${n}" role="tab" title="Shell ${n} in the agent's folder">Shell ${n}<span class="x" data-close="${n}" title="Close this shell">×</span></button>`)
1565
+ .join('');
1566
+ $('#browser-tabs').innerHTML = (a?.browsers ?? [])
1567
+ .map((t) => `<button class="term-tab" data-kind="browser:${t.n}" role="tab" title="${esc(t.url)}">🌐 ${esc(browserLabel(t.url))}<span class="x" data-drop-browser="${t.n}" title="Close this browser tab">×</span></button>`)
1568
+ .join('');
1569
+ for (const tab of document.querySelectorAll('.term-tab[data-kind]')) tab.classList.toggle('active', tab.dataset.kind === this.kind);
1570
+ $('#browser-new').classList.toggle('active', this.kind === 'browser:new');
1571
+ $('#shell-new').disabled = !a?.workdir || a.status === 'preparing';
1572
+ if (isBrowser(this.kind) && this.kind !== 'browser:new' && !a?.browsers?.some((t) => `browser:${t.n}` === this.kind)) {
1573
+ // Closed on another device, or a popup that closed itself: back to the page that opened it.
1574
+ const back = liveView.opener && a?.browsers?.find((t) => t.n === liveView.opener);
1575
+ liveView.opener = null;
1576
+ this.kind = null;
1577
+ this.open(this.agentId, back ? `browser:${back.n}` : 'claude');
1578
+ }
1579
+ },
1580
+
1581
+ // Browser tabs: one iframe per tab, kept while the panel is open so switching back doesn't reload it.
1582
+ frames: new Map(), // "<agent>:<n>" -> iframe
1583
+
1584
+ openBrowser(id, kind) {
1585
+ history.replaceState(null, '', `#${id}/${kind}`);
1586
+ $('#term-wrap').hidden = false;
1587
+ $('#detail').classList.add('with-term');
1588
+ $('#term-wrap').classList.add('browsing');
1589
+ this.ensure();
1590
+ if (this.agentId !== id) this.dropFrames();
1591
+ this.disconnect();
1592
+ this.agentId = id;
1593
+ this.kind = kind;
1594
+ $('#term-overlay').hidden = true;
1595
+ this.renderTabs();
1596
+ const tab = agents.get(id)?.browsers?.find((t) => `browser:${t.n}` === kind);
1597
+ const mode = tab ? modeOf(tab) : null;
1598
+ $('#term-wrap').classList.toggle('live-mode', mode === 'live');
1599
+ Object.assign($('#browser-mode'), {
1600
+ hidden: !tab || !liveAvailable,
1601
+ textContent: mode === 'live' ? 'Live' : 'Lite',
1602
+ title: mode === 'live'
1603
+ ? "Live: a real browser on this machine, streamed here. Sign-ins and localhost work from any device. Click for Lite (the page loads in this browser, faster but some sites refuse)"
1604
+ : 'Lite: the page loads in this browser (relayed from other devices). Click for Live: a real browser on this machine, streamed here',
1605
+ });
1606
+ for (const [key, f] of this.frames) f.hidden = mode === 'live' || key !== `${id}:${tab?.n}`;
1607
+ $('#browser-start').hidden = Boolean(tab);
1608
+ if (mode === 'live') {
1609
+ $('#browser-cert').hidden = true;
1610
+ $('#browser-hint').hidden = true;
1611
+ $('#browser-pop').hidden = true;
1612
+ if (document.activeElement !== $('#browser-url')) $('#browser-url').value = tab.url;
1613
+ $('#term-title').textContent = tab.url;
1614
+ return liveView.open(id, tab);
1615
+ }
1616
+ liveView.close();
1617
+ $('#browser-cert').hidden = true;
1618
+ const open = tab && this.frames.get(`${id}:${tab.n}`);
1619
+ Object.assign($('#browser-pop'), { href: open?.dataset.src ?? '#', hidden: !open });
1620
+ // Here the tab loads the site itself, so its own "don't frame me" header applies; the relay
1621
+ // used from other devices drops that header.
1622
+ $('#browser-hint').hidden = REMOTE || !tab || /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])[:/]/.test(tab.url);
1623
+ $('#browser-url').value = tab?.url ?? '';
1624
+ $('#term-title').textContent = tab ? tab.url : 'Open a page as this machine sees it';
1625
+ if (!tab) {
1626
+ $('#browser-pop').hidden = true;
1627
+ this.suggest(id);
1628
+ requestAnimationFrame(() => $('#browser-url').focus());
1629
+ return;
1630
+ }
1631
+ this.loadFrame(id, tab);
1632
+ },
1633
+
1634
+ async loadFrame(id, tab, force = false) {
1635
+ const key = `${id}:${tab.n}`;
1636
+ let frame = this.frames.get(key);
1637
+ if (frame && frame.dataset.url === tab.url && !force) return;
1638
+ let src;
1639
+ try {
1640
+ ({ src } = await api('POST', '/preview', { url: tab.url }));
1641
+ } catch (err) {
1642
+ return toast(`Can't open ${tab.url}: ${err.message}`, 5000);
1643
+ }
1644
+ const viewing = () => this.kind === `browser:${tab.n}` && this.agentId === id && modeOf(tab) !== 'live';
1645
+ if (viewing()) Object.assign($('#browser-pop'), { href: src, hidden: false });
1646
+ // From another device the tab goes through its own relay port. A browser accepts a
1647
+ // self-signed certificate per port and can't ask inside a frame, so check first and
1648
+ // offer to accept it in a tab of its own instead of showing a blank page.
1649
+ if (REMOTE && !(await reachable(src))) {
1650
+ frame?.remove();
1651
+ this.frames.delete(key);
1652
+ if (viewing()) {
1653
+ $('#browser-cert-open').href = src;
1654
+ $('#browser-cert').hidden = false;
1655
+ }
1656
+ return;
1657
+ }
1658
+ if (viewing()) $('#browser-cert').hidden = true;
1659
+ if (!frame) {
1660
+ frame = Object.assign(document.createElement('iframe'), { className: 'browser-frame', title: tab.url });
1661
+ frame.setAttribute('allow', 'clipboard-read; clipboard-write; fullscreen');
1662
+ $('#browser-frames').append(frame);
1663
+ this.frames.set(key, frame);
1664
+ }
1665
+ frame.dataset.url = tab.url;
1666
+ frame.dataset.src = src;
1667
+ frame.hidden = !viewing();
1668
+ frame.src = src;
1669
+ },
1670
+
1671
+ dropFrames(key) {
1672
+ for (const [k, f] of this.frames) {
1673
+ if (key && k !== key) continue;
1674
+ f.remove();
1675
+ this.frames.delete(k);
1676
+ }
1677
+ },
1678
+
1679
+ async suggest(id) {
1680
+ const urls = await api('GET', `/agents/${id}/local-urls`).catch(() => []);
1681
+ if (this.agentId !== id || this.kind !== 'browser:new') return;
1682
+ $('#browser-suggest').innerHTML = urls.length
1683
+ ? `<span class="muted">Seen in this agent's terminals:</span> ${urls.map((u) => `<button type="button" class="small" data-url="${esc(u)}">${esc(u)}</button>`).join(' ')}`
1684
+ : '<span class="muted">Type a port (5173) or an address (localhost:3000/app, example.com). Local addresses the agent prints, like a dev server\'s, show up here.</span>';
1685
+ },
1686
+
1687
+ /** Upload files to the daemon and paste their paths, the way a native terminal pastes a dropped file. */
1688
+ async attachFiles(files) {
1689
+ const id = this.agentId;
1690
+ if (!id || !files.length) return;
1691
+ const saved = [];
1692
+ for (const file of files) {
1693
+ const name = file.name || `pasted-${Date.now()}.${(file.type.split('/')[1] || 'bin').replace(/\W/g, '')}`;
1694
+ try {
1695
+ const res = await fetch(`/api/agents/${id}/upload?name=${encodeURIComponent(name)}`, {
1696
+ method: 'POST',
1697
+ credentials: 'same-origin',
1698
+ headers: { 'content-type': 'application/octet-stream', 'x-agentmgr': '1' },
1699
+ body: file,
1700
+ });
1701
+ const data = await res.json().catch(() => ({}));
1702
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
1703
+ saved.push(data.path);
1704
+ } catch (err) {
1705
+ toast(`Could not attach ${name}: ${err.message}`, 5000);
1706
+ }
1707
+ }
1708
+ if (!saved.length || this.agentId !== id) return;
1709
+ // Backslash-escape like a terminal drop, so Claude Code (and shells) read each as one path.
1710
+ this.xterm.paste(saved.map((p) => p.replace(/([\s'"\\()&;|<>$`!*?[\]{}#~])/g, '\\$1')).join(' ') + ' ');
1711
+ this.xterm.focus();
1712
+ toast(saved.length === 1 ? 'Attached 1 file' : `Attached ${saved.length} files`);
1713
+ },
1714
+
1715
+ disconnect() {
1716
+ this.conn?.close();
1717
+ this.conn = null;
1718
+ },
1719
+
1720
+ close() {
1721
+ if (this.agentId) history.replaceState(null, '', `#${this.agentId}`);
1722
+ this.disconnect();
1723
+ liveView.close();
1724
+ this.dropFrames();
1725
+ this.agentId = null;
1726
+ $('#term-wrap').hidden = true;
1727
+ $('#detail').classList.remove('with-term');
1728
+ },
1729
+ };
1730
+
1731
+ $('#term-close').onclick = () => term.close();
1732
+ $('#term-fit').onclick = () => term.refit();
1733
+ $('#term-resume').onclick = () => {
1734
+ const id = term.agentId;
1735
+ if (isShell(term.kind)) {
1736
+ term.disconnect();
1737
+ return term.open(id, term.kind);
1738
+ }
1739
+ act(async () => {
1740
+ upsert(await api('POST', `/agents/${id}/start`, {}));
1741
+ term.xterm.focus();
1742
+ });
1743
+ };
1744
+ $('.term-tabs').addEventListener('click', async (e) => {
1745
+ const id = term.agentId;
1746
+ if (!id) return;
1747
+ const dropBrowser = e.target.closest('[data-drop-browser]');
1748
+ if (dropBrowser) {
1749
+ e.stopPropagation();
1750
+ const n = Number(dropBrowser.dataset.dropBrowser);
1751
+ return act(async () => {
1752
+ await api('DELETE', `/agents/${id}/browsers/${n}`);
1753
+ term.dropFrames(`${id}:${n}`);
1754
+ if (term.kind === `browser:${n}`) {
1755
+ term.kind = null;
1756
+ term.open(id, 'claude');
1757
+ }
1758
+ });
1759
+ }
1760
+ if (e.target.closest('#browser-new')) return term.open(id, 'browser:new');
1761
+ const close = e.target.closest('[data-close]');
1762
+ if (close) {
1763
+ e.stopPropagation();
1764
+ const n = Number(close.dataset.close);
1765
+ const viewing = term.kind === `shell:${n}`;
1766
+ await act(async () => {
1767
+ await api('POST', `/agents/${id}/shell/drop`, { n });
1768
+ if (viewing) {
1769
+ const rest = (agents.get(id)?.runtime.shells ?? []).filter((x) => x !== n);
1770
+ term.kind = null; // force a reconnect
1771
+ term.open(id, rest.length ? `shell:${rest[0]}` : 'claude');
1772
+ } else term.renderTabs();
1773
+ });
1774
+ return;
1775
+ }
1776
+ if (e.target.closest('#shell-new')) {
1777
+ return act(async () => term.open(id, `shell:${(await api('POST', `/agents/${id}/shell/new`)).n}`));
1778
+ }
1779
+ const tab = e.target.closest('.term-tab[data-kind]');
1780
+ if (!tab) return;
1781
+ const a = agents.get(id);
1782
+ if (tab.dataset.kind === 'claude' && a && !a.runtime.running) {
1783
+ await act(async () => upsert(await api('POST', `/agents/${id}/start`, {})));
1784
+ }
1785
+ term.open(id, tab.dataset.kind);
1786
+ });
1787
+
1788
+ const browserLabel = (url) => {
1789
+ const u = new URL(url);
1790
+ return u.host + (u.pathname === '/' ? '' : u.pathname);
1791
+ };
1792
+ async function openAddress(url) {
1793
+ const id = term.agentId;
1794
+ if (!id || !url.trim()) return;
1795
+ // A CLI's sign-in link can't work inside a page: open it in a window and watch for its callback.
1796
+ if (SIGNIN_LINK.test(url) && !liveAvailable) {
1797
+ window.open(url.trim(), '_blank');
1798
+ return act(async () => upsert(await api('POST', `/agents/${id}/signin`, { url: url.trim() })), 'Sign-in opened in a new window');
1799
+ }
1800
+ // The callback address pasted here from another device: hand it to the CLI on this machine.
1801
+ const s = agents.get(id)?.signIn;
1802
+ if (REMOTE && s && url.trim().startsWith(s.callback)) return finishSignIn(id, url.trim());
1803
+ await act(async () => {
1804
+ if (term.kind === 'browser:new') {
1805
+ const tab = await api('POST', `/agents/${id}/browsers`, { url, mode: liveAvailable ? 'live' : 'lite' });
1806
+ upsertBrowsers(id, tab.n, tab.url, tab.mode);
1807
+ const n = tab.n;
1808
+ term.open(id, `browser:${n}`);
1809
+ } else {
1810
+ const n = Number(term.kind.split(':')[1]);
1811
+ const tab = await api('PATCH', `/agents/${id}/browsers/${n}`, { url });
1812
+ upsertBrowsers(id, n, tab.url);
1813
+ term.open(id, term.kind);
1814
+ }
1815
+ });
1816
+ }
1817
+ // ------------------------------------------------------------------ CLI sign-in links
1818
+
1819
+ const SIGNIN_LINK = /^https?:\/\/[^\s]+[?&][^=&]*(redirect|callback|return)[^=&]*=https?(%3A|:)(%2F|\/){2}(127\.0\.0\.1|localhost|%5B%3A%3A1%5D)/i;
1820
+ let lastTyped = 0; // the person's last keystroke in a terminal: a link printed right after may open by itself
1821
+ const autoOpened = new Set();
1822
+ const signInHidden = new Set();
1823
+
1824
+ /** Above the terminal: the sign-in link a CLI printed, and (from another device) finishing its callback. */
1825
+ function renderSignIn() {
1826
+ const bar = $('#signin-bar');
1827
+ const s = agents.get(term.agentId)?.signIn;
1828
+ const wasHidden = bar.hidden;
1829
+ // The terminal gives up or takes back the bar's height.
1830
+ const refit = () => wasHidden !== bar.hidden && term.xterm && requestAnimationFrame(() => term.refit());
1831
+ if (!s || signInHidden.has(s.at)) {
1832
+ bar.hidden = true;
1833
+ return refit();
1834
+ }
1835
+ if (!autoOpened.has(s.at) && !s.finished) {
1836
+ autoOpened.add(s.at);
1837
+ if (document.visibilityState === 'visible' && Date.now() - lastTyped < 15_000) {
1838
+ // Live: in a browser on that machine, where the callback reaches the CLI. Otherwise a window
1839
+ // here (browsers allow one shortly after a keystroke; else the button below does it).
1840
+ if (liveAvailable) return openSignInLive(term.agentId, s.url);
1841
+ window.open(s.url, '_blank');
1842
+ }
1843
+ }
1844
+ if (bar.contains(document.activeElement)) return; // keep what is being typed
1845
+ let host = '';
1846
+ try {
1847
+ host = new URL(s.url).host;
1848
+ } catch {}
1849
+ const f = s.finished;
1850
+ bar.hidden = false;
1851
+ const pasteForm = `<form class="signin-finish">After you approve, your browser goes to <code>${esc(s.callback)}…</code>, which can't load on this device. Paste that address here:
1852
+ <input name="url" placeholder="${esc(s.callback)}?code=…" autocomplete="off" />
1853
+ <button class="small primary">Finish sign-in</button></form>`;
1854
+ const here = `<a class="button small ${liveAvailable ? 'ghost' : 'primary'}" href="${esc(s.url)}" target="_blank" rel="noopener" title="Open it in a window on this device">${liveAvailable ? 'On this device ↗' : 'Open sign-in ↗'}</a>`;
1855
+ bar.innerHTML = `<div class="signin-head">🔑 <b>${esc(s.terminal)}</b> printed a sign-in link (${esc(host)})
1856
+ ${liveAvailable ? '<button class="small primary" data-signin="live" title="In a live browser tab on that machine: the sign-in finishes by itself">Open sign-in</button>' : ''}
1857
+ ${here}
1858
+ <button class="small ghost" data-signin="copy">Copy link</button>
1859
+ <button class="small ghost" data-signin="dismiss" title="Hide">✕</button></div>
1860
+ ${f?.ok
1861
+ ? `<p class="signin-ok">✓ Sent to the CLI on this machine${f.text ? `: ${esc(f.text)}` : ''}. Check the terminal.</p>`
1862
+ : `${liveAvailable ? '<p class="hint"><b>Open sign-in</b> uses a live browser tab on that machine, so the CLI gets the sign-in by itself. Sign in to the provider there once; it is remembered.</p>' : ''}
1863
+ ${REMOTE ? `${liveAvailable ? '<details class="signin-other"><summary>Signed in on this device instead?</summary>' : ''}${pasteForm}${liveAvailable ? '</details>' : ''}` : liveAvailable ? '' : '<p class="hint">After you approve, the browser returns to the CLI by itself.</p>'}
1864
+ ${f ? `<p class="error">${esc(f.text || `HTTP ${f.status}`)}</p>` : ''}`}`;
1865
+ refit();
1866
+ }
1867
+
1868
+ /** Open a sign-in link in a live browser tab: its callback lands on the machine the CLI runs on. */
1869
+ function openSignInLive(id, url) {
1870
+ const s = agents.get(id)?.signIn;
1871
+ if (s) signInHidden.add(s.at);
1872
+ return act(async () => {
1873
+ const tab = await api('POST', `/agents/${id}/browsers`, { url, mode: 'live' });
1874
+ upsertBrowsers(id, tab.n, tab.url, 'live');
1875
+ term.open(id, `browser:${tab.n}`);
1876
+ api('POST', `/agents/${id}/signin/dismiss`).catch(() => {});
1877
+ });
1878
+ }
1879
+
1880
+ async function finishSignIn(id, url) {
1881
+ await act(async () => {
1882
+ const a = await api('POST', `/agents/${id}/signin/finish`, { url });
1883
+ upsert(a);
1884
+ if (!a.signIn?.finished?.ok) throw new Error(a.signIn?.finished?.text || 'The CLI did not accept it');
1885
+ }, 'Signed in: the CLI got the callback');
1886
+ }
1887
+
1888
+ $('#signin-bar').addEventListener('click', (e) => {
1889
+ const el = e.target.closest('[data-signin]');
1890
+ if (!el) return;
1891
+ const s = agents.get(term.agentId)?.signIn;
1892
+ if (!s) return;
1893
+ if (el.dataset.signin === 'copy') return navigator.clipboard?.writeText(s.url).then(() => toast('Link copied'));
1894
+ if (el.dataset.signin === 'live') return openSignInLive(term.agentId, s.url);
1895
+ signInHidden.add(s.at);
1896
+ act(async () => upsert(await api('POST', `/agents/${term.agentId}/signin/dismiss`)));
1897
+ });
1898
+ $('#signin-bar').addEventListener('submit', (e) => {
1899
+ e.preventDefault();
1900
+ const url = e.target.querySelector('input').value.trim();
1901
+ if (url) finishSignIn(term.agentId, url);
1902
+ });
1903
+
1904
+ // Don't wait for the change event to show a tab that was just opened.
1905
+ function upsertBrowsers(id, n, url, mode) {
1906
+ const a = agents.get(id);
1907
+ if (!a) return;
1908
+ const old = (a.browsers ?? []).find((t) => t.n === n);
1909
+ const tabs = (a.browsers ?? []).filter((t) => t.n !== n);
1910
+ a.browsers = [...tabs, { ...old, n, url, ...(mode ? { mode } : {}) }].sort((x, y) => x.n - y.n);
1911
+ }
1912
+ $('#browser-bar').addEventListener('submit', (e) => {
1913
+ e.preventDefault();
1914
+ openAddress($('#browser-url').value);
1915
+ });
1916
+ $('#browser-suggest').addEventListener('click', (e) => {
1917
+ const b = e.target.closest('[data-url]');
1918
+ if (b) openAddress(b.dataset.url);
1919
+ });
1920
+ /** Whether this device can load `src` at all (a no-cors probe fails on an untrusted certificate). */
1921
+ async function reachable(src) {
1922
+ const ctl = new AbortController();
1923
+ const timer = setTimeout(() => ctl.abort(), 8000);
1924
+ try {
1925
+ await fetch(src, { mode: 'no-cors', credentials: 'include', cache: 'no-store', signal: ctl.signal });
1926
+ return true;
1927
+ } catch {
1928
+ return false;
1929
+ } finally {
1930
+ clearTimeout(timer);
1931
+ }
1932
+ }
1933
+ $('#browser-cert-retry').onclick = () => $('#browser-reload').click();
1934
+ const currentTab = () => agents.get(term.agentId)?.browsers?.find((t) => `browser:${t.n}` === term.kind);
1935
+ $('#browser-reload').onclick = () => {
1936
+ const tab = currentTab();
1937
+ if (!tab) return;
1938
+ if (modeOf(tab) === 'live') return liveView.go('reload');
1939
+ term.loadFrame(term.agentId, tab, true);
1940
+ };
1941
+ $('#browser-back').onclick = () => liveView.go('back');
1942
+ $('#browser-fwd').onclick = () => liveView.go('forward');
1943
+ $('#browser-mode').onclick = () => {
1944
+ const tab = currentTab();
1945
+ if (!tab) return;
1946
+ const mode = modeOf(tab) === 'live' ? 'lite' : 'live';
1947
+ act(async () => {
1948
+ const t = await api('PATCH', `/agents/${term.agentId}/browsers/${tab.n}`, { mode });
1949
+ upsertBrowsers(term.agentId, t.n, t.url, t.mode);
1950
+ term.dropFrames(`${term.agentId}:${t.n}`);
1951
+ term.open(term.agentId, `browser:${t.n}`);
1952
+ });
1953
+ };
1954
+
1955
+ // ------------------------------------------------------------------ importing terminal sessions
1956
+
1957
+ let importSessions = [];
1958
+ const importPicked = new Set();
1959
+ async function openImport() {
1960
+ importPicked.clear();
1961
+ $('#import-filter').value = '';
1962
+ $('#import-dialog').showModal();
1963
+ await loadImport();
1964
+ }
1965
+ async function loadImport() {
1966
+ $('#import-list').innerHTML = '<p class="hint">Looking for sessions…</p>';
1967
+ try {
1968
+ importSessions = await api('GET', `/sessions/scan?days=${$('#import-days').value}`);
1969
+ } catch (err) {
1970
+ $('#import-list').innerHTML = `<p class="error">${esc(err.message)}</p>`;
1971
+ return;
1972
+ }
1973
+ renderImport();
1974
+ }
1975
+ function renderImport() {
1976
+ const q = $('#import-filter').value.trim().toLowerCase();
1977
+ const home = /^\/(Users|home)\/[^/]+/;
1978
+ const shown = importSessions.filter((s) => !q || `${s.cwd} ${s.title ?? ''} ${s.firstAsk} ${s.lastAsk ?? ''}`.toLowerCase().includes(q));
1979
+ const byDir = new Map();
1980
+ for (const s of shown) (byDir.get(s.cwd) ?? byDir.set(s.cwd, []).get(s.cwd)).push(s);
1981
+ $('#import-list').innerHTML = shown.length
1982
+ ? [...byDir]
1983
+ .map(
1984
+ ([dir, list]) => `<div class="import-dir"><h4><code>${esc(dir.replace(home, '~'))}</code>${list[0].exists ? '' : ' <span class="tag fail">folder gone</span>'}</h4>
1985
+ ${list
1986
+ .map(
1987
+ (s) => `<label class="import-row ${s.exists ? '' : 'off'}">
1988
+ <input type="checkbox" data-sid="${s.sessionId}" ${importPicked.has(s.sessionId) ? 'checked' : ''} ${s.exists ? '' : 'disabled'}>
1989
+ <span class="import-main"><b>${esc(s.title || s.firstAsk.slice(0, 80))}</b>
1990
+ ${s.inUse ? `<span class="tag warn" title="${s.inUse === 'open' ? 'A claude process in a terminal has this session open' : 'A claude process runs in this folder and the session was active minutes ago'}">${s.inUse === 'open' ? 'open in a terminal' : 'maybe open in a terminal'}</span>` : ''}
1991
+ <span class="hint">${s.title ? `${esc(s.firstAsk.slice(0, 120))}${s.lastAsk ? ` … last: ${esc(s.lastAsk.slice(0, 80))}` : ''}` : s.lastAsk ? `last: ${esc(s.lastAsk.slice(0, 100))}` : ''}</span>
1992
+ <span class="hint">${ago(s.lastActivity)}${s.branch ? ` · ⎇ ${esc(s.branch)}` : ''} · ${Math.max(1, Math.round(s.bytes / 1024))} KB · <code>${s.sessionId.slice(0, 8)}</code></span>
1993
+ </span></label>`,
1994
+ )
1995
+ .join('')}</div>`,
1996
+ )
1997
+ .join('')
1998
+ : `<p class="hint">${importSessions.length ? 'Nothing matches.' : 'No Claude sessions started in a terminal in this period (Agent Manager\'s own agents are left out).'}</p>`;
1999
+ const picked = importSessions.filter((s) => importPicked.has(s.sessionId));
2000
+ const open = picked.filter((s) => s.inUse).length;
2001
+ Object.assign($('#import-go'), {
2002
+ disabled: !picked.length,
2003
+ textContent: picked.length ? `Continue ${picked.length} session${picked.length > 1 ? 's' : ''}${open ? ` (${open} as a copy)` : ''}` : 'Continue',
2004
+ title: open ? 'Sessions still open in a terminal continue as a copy, so the terminal keeps the original' : '',
2005
+ });
2006
+ }
2007
+ $('#import-list').addEventListener('change', (e) => {
2008
+ const sid = e.target.dataset?.sid;
2009
+ if (!sid) return;
2010
+ if (e.target.checked) importPicked.add(sid);
2011
+ else importPicked.delete(sid);
2012
+ renderImport();
2013
+ });
2014
+ $('#import-filter').addEventListener('input', renderImport);
2015
+ $('#import-days').addEventListener('change', loadImport);
2016
+ $('#import-close').onclick = () => $('#import-dialog').close();
2017
+ $('#import-go').onclick = async () => {
2018
+ const picked = importSessions.filter((s) => importPicked.has(s.sessionId));
2019
+ const plain = picked.filter((s) => !s.inUse).map((s) => s.sessionId);
2020
+ const copies = picked.filter((s) => s.inUse).map((s) => s.sessionId);
2021
+ await act(async () => {
2022
+ const start = $('#import-start').checked;
2023
+ const res = [
2024
+ ...(plain.length ? await api('POST', '/sessions/import', { sessions: plain, start }) : []),
2025
+ ...(copies.length ? await api('POST', '/sessions/import', { sessions: copies, start, copy: true }) : []),
2026
+ ];
2027
+ const ok = res.filter((r) => r.agentId);
2028
+ const bad = res.filter((r) => r.error);
2029
+ $('#import-dialog').close();
2030
+ if (ok.length) {
2031
+ showTab('agents');
2032
+ select(ok[0].agentId);
2033
+ }
2034
+ toast(`${ok.length} session${ok.length === 1 ? '' : 's'} continued here${bad.length ? `; ${bad.length} not: ${bad[0].error}` : ''}`, bad.length ? 7000 : 3000);
2035
+ });
2036
+ };
2037
+
2038
+ // ------------------------------------------------------------------ live browser tabs
2039
+
2040
+ let liveAvailable = false;
2041
+ api('GET', '/browser')
2042
+ .then((b) => {
2043
+ liveAvailable = b.available;
2044
+ // A browser tab opened before we knew: show it the way it should be.
2045
+ if (liveAvailable && term.agentId && isBrowser(term.kind)) {
2046
+ term.dropFrames();
2047
+ term.open(term.agentId, term.kind);
2048
+ }
2049
+ })
2050
+ .catch(() => {});
2051
+ /** Live (a real browser on the daemon's machine, streamed) unless the tab was set to Lite or none is installed. */
2052
+ const modeOf = (tab) => (liveAvailable ? tab.mode ?? 'live' : 'lite');
2053
+ const MODS = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
2054
+ const SPECIAL_KEYS = new Set(['Enter', 'Backspace', 'Tab', 'Escape', 'Delete', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown']);
2055
+
2056
+ /** The stream of one live tab, and the person's mouse, touch, keys and paste going back. */
2057
+ /**
2058
+ * The page's sound: 16-bit stereo PCM chunks, scheduled back to back a little ahead (about
2059
+ * 0.3 s) so they play smoothly; if they fall behind by more than 1.5 s, skip ahead.
2060
+ */
2061
+ const liveSound = {
2062
+ on: false,
2063
+ ctx: null,
2064
+ next: 0,
2065
+
2066
+ toggle() {
2067
+ this.on = !this.on;
2068
+ if (this.on) {
2069
+ // Browsers (iPhones especially) start sound only from a tap: create and resume it here.
2070
+ this.ctx ??= new (window.AudioContext || window.webkitAudioContext)();
2071
+ this.ctx.resume();
2072
+ this.next = 0;
2073
+ } else this.ctx?.suspend();
2074
+ this.button();
2075
+ // Ask for the sound (or stop it) on the stream.
2076
+ if (liveView.id) {
2077
+ const tab = agents.get(liveView.id)?.browsers?.find((t) => t.n === liveView.n);
2078
+ liveView.close();
2079
+ if (tab) liveView.open(term.agentId, tab);
2080
+ }
2081
+ },
2082
+
2083
+ button() {
2084
+ Object.assign($('#browser-sound'), {
2085
+ textContent: this.on ? '🔊' : '🔇',
2086
+ title: this.on ? 'Sound on: playing what this page plays on that machine. Click to mute' : 'Hear the page: sound from videos and audio playing on that machine',
2087
+ });
2088
+ },
2089
+
2090
+ play(b64, rate) {
2091
+ if (!this.on || !this.ctx) return;
2092
+ const bin = atob(b64);
2093
+ const frames = bin.length >> 2;
2094
+ if (!frames) return;
2095
+ const buf = this.ctx.createBuffer(2, frames, rate);
2096
+ const l = buf.getChannelData(0);
2097
+ const r = buf.getChannelData(1);
2098
+ for (let i = 0, j = 0; i < frames; i++, j += 4) {
2099
+ const a = (bin.charCodeAt(j) | (bin.charCodeAt(j + 1) << 8)) << 16 >> 16;
2100
+ const b = (bin.charCodeAt(j + 2) | (bin.charCodeAt(j + 3) << 8)) << 16 >> 16;
2101
+ l[i] = a / 32768;
2102
+ r[i] = b / 32768;
2103
+ }
2104
+ const now = this.ctx.currentTime;
2105
+ if (this.next < now + 0.05 || this.next > now + 1.5) this.next = now + 0.3;
2106
+ const src = this.ctx.createBufferSource();
2107
+ src.buffer = buf;
2108
+ src.connect(this.ctx.destination);
2109
+ src.start(this.next);
2110
+ this.next += buf.duration;
2111
+ },
2112
+ };
2113
+
2114
+ const liveView = {
2115
+ es: null,
2116
+ key: null,
2117
+ frame: { w: 1280, h: 800 },
2118
+ queue: [],
2119
+ inflight: false,
2120
+
2121
+ open(id, tab) {
2122
+ const key = `${id}:${tab.n}`;
2123
+ if (this.key === key && this.es) return void ($('#browser-live').hidden = false);
2124
+ this.close();
2125
+ $('#browser-live').hidden = false;
2126
+ this.key = key;
2127
+ this.id = id;
2128
+ this.n = tab.n;
2129
+ $('#browser-live-img').removeAttribute('src');
2130
+ this.message('Starting the browser on this machine…');
2131
+ const el = $('#browser-live');
2132
+ const size = new URLSearchParams({ width: el.clientWidth || 1280, height: el.clientHeight || 800, mobile: this.mobile(el) ? '1' : '', scale: devicePixelRatio || 1, audio: liveSound.on ? '1' : '' });
2133
+ const es = new EventSource(`/api/agents/${id}/browsers/${tab.n}/live/stream?${size}`);
2134
+ this.es = es;
2135
+ es.onmessage = (ev) => this.onMessage(JSON.parse(ev.data));
2136
+ es.onerror = () => es.readyState === EventSource.CLOSED && this.es === es && this.message('Disconnected from the browser. Reopen the tab to reconnect.');
2137
+ this.resize(true);
2138
+ },
2139
+
2140
+ close() {
2141
+ this.es?.close();
2142
+ this.es = null;
2143
+ this.key = null;
2144
+ $('#browser-live').hidden = true;
2145
+ },
2146
+
2147
+ img: () => $('#browser-live-img'),
2148
+
2149
+ message(text) {
2150
+ const el = $('#browser-live-msg');
2151
+ el.hidden = !text;
2152
+ el.textContent = text ?? '';
2153
+ },
2154
+
2155
+ onMessage(m) {
2156
+ if (m.t === 'frame') {
2157
+ // A frame of the old size is about to be replaced; showing it invites clicks in the wrong place.
2158
+ if (this.sized && Date.now() - this.sized.at < 1500 && Math.abs(m.w - this.sized.w) > 2 && this.img()?.src) return;
2159
+ this.frame = { w: m.w, h: m.h };
2160
+ $('#browser-live-img').src = `data:image/jpeg;base64,${m.d}`;
2161
+ this.message(null);
2162
+ } else if (m.t === 'state') {
2163
+ if (m.url && document.activeElement !== $('#browser-url')) $('#browser-url').value = m.url;
2164
+ $('#term-title').textContent = m.title && m.title !== m.url ? `${m.title} · ${m.url}` : m.url ?? '';
2165
+ if (m.url) upsertBrowsers(this.id, this.n, m.url);
2166
+ } else if (m.t === 'popup') {
2167
+ // A sign-in window and the like: follow it; it comes back here when it closes.
2168
+ this.opener = this.n;
2169
+ const id = this.id;
2170
+ setTimeout(() => term.open(id, `browser:${m.n}`), 150);
2171
+ } else if (m.t === 'closed') {
2172
+ const id = this.id;
2173
+ const back = this.opener;
2174
+ setTimeout(() => {
2175
+ const tabs = agents.get(id)?.browsers ?? [];
2176
+ if (term.agentId !== id || tabs.some((t) => `browser:${t.n}` === term.kind)) return;
2177
+ const to = tabs.find((t) => t.n === back) ?? tabs[0];
2178
+ term.open(id, to ? `browser:${to.n}` : 'browser:new');
2179
+ }, 400);
2180
+ } else if (m.t === 'audio') {
2181
+ liveSound.play(m.d, m.rate);
2182
+ } else if (m.t === 'download') {
2183
+ toast(`Downloaded ${m.name} to ${m.dir} on that machine`, 6000);
2184
+ } else if (m.t === 'error') {
2185
+ this.message(m.error);
2186
+ }
2187
+ },
2188
+
2189
+ /** Page CSS pixels for a point on the shown image (it is letterboxed to keep its shape). */
2190
+ point(e) {
2191
+ const r = $('#browser-live-img').getBoundingClientRect();
2192
+ const k = Math.min(r.width / this.frame.w, r.height / this.frame.h) || 1;
2193
+ const ox = (r.width - this.frame.w * k) / 2;
2194
+ const oy = (r.height - this.frame.h * k) / 2;
2195
+ return { x: Math.round((e.clientX - r.left - ox) / k), y: Math.round((e.clientY - r.top - oy) / k) };
2196
+ },
2197
+
2198
+ /** Events go out in order, a batch at a time; mouse moves and wheel turns are merged. */
2199
+ send(ev) {
2200
+ if (!this.es) return;
2201
+ const last = this.queue.at(-1);
2202
+ if (ev.type === 'mouse' && ev.action === 'move' && last?.type === 'mouse' && last.action === 'move') this.queue[this.queue.length - 1] = ev;
2203
+ else if (ev.type === 'wheel' && last?.type === 'wheel') Object.assign(last, { dx: last.dx + ev.dx, dy: last.dy + ev.dy, x: ev.x, y: ev.y });
2204
+ else this.queue.push(ev);
2205
+ setTimeout(() => this.pump(), 16);
2206
+ },
2207
+
2208
+ async pump() {
2209
+ if (this.inflight || !this.queue.length || !this.id) return;
2210
+ this.inflight = true;
2211
+ const events = this.queue.splice(0);
2212
+ const touch = this.touchTap;
2213
+ this.touchTap = false;
2214
+ try {
2215
+ const r = await api('POST', `/agents/${this.id}/browsers/${this.n}/live/input`, { events });
2216
+ // The tap opened the keyboard already (phones only allow that during the tap itself);
2217
+ // put it away again if the tap wasn't on a text field.
2218
+ if (touch && !r.editable && !this.keyboardPinned) $('#browser-live-keys').blur();
2219
+ } catch {
2220
+ } finally {
2221
+ this.inflight = false;
2222
+ if (this.queue.length) this.pump();
2223
+ }
2224
+ },
2225
+
2226
+ go(where) {
2227
+ if (this.id) act(() => api('POST', `/agents/${this.id}/browsers/${this.n}/live/go`, { where }));
2228
+ },
2229
+
2230
+ mobile: (el) => matchMedia('(pointer: coarse)').matches && el.clientWidth < 700,
2231
+
2232
+ resize(now) {
2233
+ clearTimeout(this.resizeTimer);
2234
+ this.resizeTimer = setTimeout(() => {
2235
+ const el = $('#browser-live');
2236
+ if (!this.id || el.hidden || !el.clientWidth) return;
2237
+ api('POST', `/agents/${this.id}/browsers/${this.n}/live/resize`, {
2238
+ width: el.clientWidth,
2239
+ height: el.clientHeight,
2240
+ mobile: this.mobile(el),
2241
+ scale: devicePixelRatio || 1,
2242
+ }).catch(() => {});
2243
+ this.sized = { w: el.clientWidth, h: el.clientHeight, at: Date.now() };
2244
+ }, now ? 0 : 200);
2245
+ },
2246
+ };
2247
+
2248
+ {
2249
+ const el = $('#browser-live');
2250
+ const keys = $('#browser-live-keys');
2251
+ new ResizeObserver(() => liveView.resize()).observe(el);
2252
+ let touch = null; // { x, y, scrolled }
2253
+ el.addEventListener('contextmenu', (e) => e.preventDefault());
2254
+ el.addEventListener('pointerdown', (e) => {
2255
+ if (e.target === keys) return;
2256
+ e.preventDefault();
2257
+ if (e.pointerType === 'touch') {
2258
+ touch = { x: e.clientX, y: e.clientY, scrolled: false };
2259
+ return;
2260
+ }
2261
+ keys.focus({ preventScroll: true });
2262
+ el.setPointerCapture(e.pointerId);
2263
+ liveView.send({ type: 'mouse', action: 'down', ...liveView.point(e), button: ['left', 'middle', 'right'][e.button] ?? 'left', clicks: e.detail || 1, modifiers: MODS(e) });
2264
+ });
2265
+ // Phones show the keyboard only for a focus during the tap's own touchend.
2266
+ el.addEventListener('touchend', () => {
2267
+ if (Date.now() - (liveView.tapAt ?? 0) < 400) keys.focus({ preventScroll: true });
2268
+ });
2269
+ $('#browser-sound').addEventListener('click', () => liveSound.toggle());
2270
+ $('#browser-keys').addEventListener('click', () => {
2271
+ liveView.keyboardPinned = document.activeElement !== keys;
2272
+ if (liveView.keyboardPinned) keys.focus({ preventScroll: true });
2273
+ else keys.blur();
2274
+ });
2275
+ keys.addEventListener('blur', () => (liveView.keyboardPinned = false));
2276
+ el.addEventListener('pointermove', (e) => {
2277
+ if (e.pointerType === 'touch') {
2278
+ if (!touch) return;
2279
+ const dx = touch.x - e.clientX;
2280
+ const dy = touch.y - e.clientY;
2281
+ if (!touch.scrolled && Math.hypot(dx, dy) < 8) return;
2282
+ touch.scrolled = true;
2283
+ const k = liveView.frame.w / el.clientWidth || 1;
2284
+ liveView.send({ type: 'wheel', ...liveView.point(e), dx: dx * k, dy: dy * k });
2285
+ Object.assign(touch, { x: e.clientX, y: e.clientY });
2286
+ return;
2287
+ }
2288
+ liveView.send({ type: 'mouse', action: 'move', ...liveView.point(e), button: e.buttons & 1 ? 'left' : 'none', buttons: e.buttons, modifiers: MODS(e) });
2289
+ });
2290
+ el.addEventListener('pointerup', (e) => {
2291
+ if (e.pointerType === 'touch') {
2292
+ if (touch && !touch.scrolled) {
2293
+ const p = liveView.point(e);
2294
+ liveView.touchTap = true;
2295
+ liveView.tapAt = Date.now();
2296
+ liveView.send({ type: 'mouse', action: 'down', ...p, button: 'left', clicks: 1 });
2297
+ liveView.send({ type: 'mouse', action: 'up', ...p, button: 'left', clicks: 1 });
2298
+ }
2299
+ touch = null;
2300
+ return;
2301
+ }
2302
+ liveView.send({ type: 'mouse', action: 'up', ...liveView.point(e), button: ['left', 'middle', 'right'][e.button] ?? 'left', clicks: e.detail || 1, modifiers: MODS(e) });
2303
+ });
2304
+ el.addEventListener(
2305
+ 'wheel',
2306
+ (e) => {
2307
+ e.preventDefault();
2308
+ const line = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1;
2309
+ liveView.send({ type: 'wheel', ...liveView.point(e), dx: e.deltaX * line, dy: e.deltaY * line, modifiers: MODS(e) });
2310
+ },
2311
+ { passive: false },
2312
+ );
2313
+ // Keys: special keys and shortcuts here; text comes through beforeinput (works with phone keyboards and IMEs).
2314
+ keys.addEventListener('keydown', (e) => {
2315
+ if (e.isComposing) return;
2316
+ const mods = MODS(e);
2317
+ if (e.metaKey || e.ctrlKey) {
2318
+ const k = e.key.toLowerCase();
2319
+ if (k === 'v') return; // the paste event brings the text
2320
+ if (k === 'c' || k === 'x') {
2321
+ e.preventDefault();
2322
+ api('GET', `/agents/${liveView.id}/browsers/${liveView.n}/live/selection`)
2323
+ .then((r) => r.text && navigator.clipboard?.writeText(r.text))
2324
+ .catch(() => {});
2325
+ if (k === 'x') liveView.send({ type: 'key', key: 'Delete' });
2326
+ return;
2327
+ }
2328
+ if (e.key.length === 1) {
2329
+ e.preventDefault();
2330
+ liveView.send({ type: 'key', key: e.key, modifiers: mods });
2331
+ }
2332
+ return;
2333
+ }
2334
+ if (SPECIAL_KEYS.has(e.key)) {
2335
+ e.preventDefault();
2336
+ liveView.send({ type: 'key', key: e.key, modifiers: mods });
2337
+ }
2338
+ });
2339
+ keys.addEventListener('beforeinput', (e) => {
2340
+ e.preventDefault();
2341
+ if (e.inputType === 'insertText' || e.inputType === 'insertReplacementText') {
2342
+ if (e.data) liveView.send({ type: [...e.data].length === 1 && e.inputType === 'insertText' ? 'char' : 'text', text: e.data });
2343
+ } else if (e.inputType === 'deleteContentBackward') liveView.send({ type: 'key', key: 'Backspace' });
2344
+ else if (e.inputType === 'insertLineBreak' || e.inputType === 'insertParagraph') liveView.send({ type: 'key', key: 'Enter' });
2345
+ });
2346
+ keys.addEventListener('compositionend', (e) => {
2347
+ if (e.data) liveView.send({ type: 'text', text: e.data });
2348
+ keys.value = '';
2349
+ });
2350
+ keys.addEventListener('paste', (e) => {
2351
+ e.preventDefault();
2352
+ const text = e.clipboardData?.getData('text/plain');
2353
+ if (text) liveView.send({ type: 'text', text });
2354
+ });
2355
+ }
2356
+
2357
+ // Attachments: the 📎 button, dropping files on the terminal, or pasting a file / screenshot.
2358
+ $('#term-attach').onclick = () => $('#term-file').click();
2359
+ $('#term-file').onchange = (e) => {
2360
+ term.attachFiles([...e.target.files]);
2361
+ e.target.value = '';
2362
+ };
2363
+ {
2364
+ const wrap = $('#term-wrap');
2365
+ let depth = 0;
2366
+ const hasFiles = (e) => [...(e.dataTransfer?.types ?? [])].includes('Files');
2367
+ wrap.addEventListener('dragenter', (e) => {
2368
+ if (!hasFiles(e)) return;
2369
+ e.preventDefault();
2370
+ depth++;
2371
+ $('#term-drop').hidden = false;
2372
+ });
2373
+ wrap.addEventListener('dragover', (e) => {
2374
+ if (!hasFiles(e)) return;
2375
+ e.preventDefault();
2376
+ e.dataTransfer.dropEffect = 'copy';
2377
+ });
2378
+ wrap.addEventListener('dragleave', () => {
2379
+ if (--depth <= 0) {
2380
+ depth = 0;
2381
+ $('#term-drop').hidden = true;
2382
+ }
2383
+ });
2384
+ wrap.addEventListener('drop', (e) => {
2385
+ if (!hasFiles(e)) return;
2386
+ e.preventDefault();
2387
+ depth = 0;
2388
+ $('#term-drop').hidden = true;
2389
+ term.attachFiles([...e.dataTransfer.files]);
2390
+ });
2391
+ // Capture phase, so it runs before xterm turns the paste into (empty) text.
2392
+ $('#terminal').addEventListener(
2393
+ 'paste',
2394
+ (e) => {
2395
+ const files = [...(e.clipboardData?.files ?? [])];
2396
+ if (!files.length) return;
2397
+ e.preventDefault();
2398
+ e.stopImmediatePropagation();
2399
+ term.attachFiles(files);
2400
+ },
2401
+ true,
2402
+ );
2403
+ }
2404
+
2405
+ // ------------------------------------------------------------------ events
2406
+
2407
+ function upsert(a) {
2408
+ agents.set(a.id, a);
2409
+ render();
2410
+ if (a.id === term.agentId) term.renderTabs();
2411
+ }
2412
+
2413
+ $('#counts').addEventListener('click', (e) => {
2414
+ if (e.target.id === 'import-btn') return openImport();
2415
+ if (e.target.id !== 'flush-btn') return;
2416
+ act(async () => {
2417
+ const r = await api('POST', '/flush');
2418
+ toast(`Saved ${r.agents} agents · scrollback for ${r.scrollback}`);
2419
+ });
2420
+ });
2421
+
2422
+ $('#agent-list').addEventListener('click', (e) => {
2423
+ const rename = e.target.closest('[data-rename]');
2424
+ if (rename) {
2425
+ e.stopPropagation();
2426
+ const old = rename.dataset.rename;
2427
+ const next = prompt(`New shortcut for this project (lowercase letters, digits, "-"):`, old);
2428
+ if (!next || next === old) return;
2429
+ return act(() => api('PATCH', `/projects/${encodeURIComponent(old)}`, { shortcut: next.trim().toLowerCase() }), `Shortcut is now ${next}`);
2430
+ }
2431
+ const head = e.target.closest('[data-toggle]');
2432
+ if (head) {
2433
+ const key = head.dataset.toggle;
2434
+ if (collapsed.has(key)) collapsed.delete(key);
2435
+ else collapsed.add(key);
2436
+ saveCollapsed();
2437
+ return renderList();
2438
+ }
2439
+ const li = e.target.closest('li[data-id]');
2440
+ if (li) select(li.dataset.id);
2441
+ });
2442
+
2443
+ $('#detail-body').addEventListener('click', async (e) => {
2444
+ const a = agents.get(selectedId);
2445
+ if (!a) return;
2446
+ const btn = e.target.closest('button');
2447
+ if (!btn) return;
2448
+
2449
+ if (btn.dataset.backlogAccept) return act(() => api('POST', `/backlog/${btn.dataset.backlogAccept}/accept`), 'Accepted: done');
2450
+ if (btn.dataset.backlogReopen) {
2451
+ const note = prompt('What still needs doing? (sent to this agent)', '');
2452
+ if (note === null) return;
2453
+ return act(() => api('POST', `/backlog/${btn.dataset.backlogReopen}/reopen`, { note }), 'Reopened');
2454
+ }
2455
+ if (btn.dataset.doc) return openDoc(a.id, btn.dataset.doc);
2456
+ if ('docAll' in btn.dataset) {
2457
+ return act(async () => {
2458
+ upsert(await api('POST', `/agents/${a.id}/docs/all?background=1`));
2459
+ toast(`Writing the documents for ${a.name} in the background (2 at a time); you'll be told as each is ready`, 4500);
2460
+ });
2461
+ }
2462
+ if (btn.dataset.tokens) {
2463
+ const id = btn.dataset.tokens;
2464
+ if (tokensOpen.has(id)) tokensOpen.delete(id);
2465
+ else tokensOpen.add(id);
2466
+ return renderDetail();
2467
+ }
2468
+ if (btn.dataset.more) {
2469
+ const key = btn.dataset.more;
2470
+ if (expanded.has(key)) expanded.delete(key);
2471
+ else expanded.add(key);
2472
+ return renderDetail();
2473
+ }
2474
+ if (btn.dataset.copy) {
2475
+ await navigator.clipboard.writeText(btn.dataset.copy).catch(() => {});
2476
+ return toast('Copied');
2477
+ }
2478
+ if (btn.dataset.unlink) {
2479
+ return act(async () =>
2480
+ upsert(await api('DELETE', `/agents/${a.id}/links?type=${btn.dataset.unlink}&key=${encodeURIComponent(btn.dataset.key)}`)),
2481
+ );
2482
+ }
2483
+ if (btn.dataset.resumeSession) {
2484
+ return act(async () => {
2485
+ upsert(await api('POST', `/agents/${a.id}/start`, { sessionId: btn.dataset.resumeSession }));
2486
+ term.open(a.id);
2487
+ });
2488
+ }
2489
+
2490
+ switch (btn.dataset.act) {
2491
+ case 'terminal':
2492
+ if (!a.runtime.running) await act(async () => upsert(await api('POST', `/agents/${a.id}/start`, {})));
2493
+ term.open(a.id);
2494
+ break;
2495
+ case 'plan':
2496
+ act(async () => {
2497
+ upsert(await api('POST', `/agents/${a.id}/plan`));
2498
+ term.open(a.id);
2499
+ });
2500
+ break;
2501
+ case 'handoff':
2502
+ act(async () => {
2503
+ upsert(await api('POST', `/agents/${a.id}/handoff`));
2504
+ term.open(a.id);
2505
+ }, 'Moved into the repository');
2506
+ break;
2507
+ case 'project': {
2508
+ const name = prompt('Project name for this agent (leave empty to use its repo):', a.project ?? a.projectInfo?.name ?? '');
2509
+ if (name === null) return;
2510
+ act(async () => upsert(await api('PATCH', `/agents/${a.id}`, { project: name })));
2511
+ break;
2512
+ }
2513
+ case 'plan-open':
2514
+ return openPlan(a.id);
2515
+ case 'plan-on':
2516
+ return act(async () => upsert(await api('POST', `/agents/${a.id}/plan/review`, { on: true })), 'The agent will write a plan and wait for your review');
2517
+ case 'plan-skip':
2518
+ return act(async () => upsert(await api('POST', `/agents/${a.id}/plan/review`, { on: false })), 'Review skipped; the agent goes ahead');
2519
+ case 'insights':
2520
+ btn.disabled = true;
2521
+ btn.textContent = 'Thinking…';
2522
+ act(async () => upsert(await api('POST', `/agents/${a.id}/insights`)));
2523
+ break;
2524
+ case 'review':
2525
+ btn.disabled = true;
2526
+ btn.textContent = 'Reviewing…';
2527
+ act(async () => upsert(await api('POST', `/agents/${a.id}/review`)));
2528
+ break;
2529
+ case 'explain':
2530
+ btn.disabled = true;
2531
+ btn.textContent = 'Explaining…';
2532
+ act(async () => upsert(await api('POST', `/agents/${a.id}/explain`)));
2533
+ break;
2534
+ case 'shell':
2535
+ term.open(a.id, 'shell:1');
2536
+ break;
2537
+ case 'fork':
2538
+ forkAgent(a.id);
2539
+ break;
2540
+ case 'checkpoint':
2541
+ act(async () => upsert(await api('POST', `/agents/${a.id}/checkpoint`)), 'Checkpoint saved');
2542
+ break;
2543
+ case 'start':
2544
+ act(
2545
+ async () => upsert(await api('POST', `/agents/${a.id}/start`, {})),
2546
+ a.workdir ? 'Resumed in the background' : 'Retrying setup',
2547
+ );
2548
+ break;
2549
+ case 'fresh':
2550
+ if (!confirm('Start a new claude session for this agent? Earlier session IDs are kept and can be resumed.')) return;
2551
+ act(async () => {
2552
+ upsert(await api('POST', `/agents/${a.id}/start`, { fresh: true }));
2553
+ term.open(a.id);
2554
+ });
2555
+ break;
2556
+ case 'stop':
2557
+ btn.disabled = true;
2558
+ act(async () => upsert(await api('POST', `/agents/${a.id}/stop`)), 'Session closed; the ID is kept for resuming');
2559
+ break;
2560
+ case 'refresh':
2561
+ act(async () => upsert(await api('POST', `/agents/${a.id}/refresh`)), 'Refreshed');
2562
+ break;
2563
+ case 'delete': {
2564
+ const cancelNote = a.status === 'preparing' ? '\n\nThis also cancels the workspace setup (clone / checkout) in progress.' : '';
2565
+ if (!confirm(`Delete agent "${a.name}"? Its session IDs will be forgotten by Agent Manager.${cancelNote}`)) return;
2566
+ const del = a.worktree && confirm('Also remove the git worktree Agent Manager created for it?\n(Uncommitted changes there will be lost.)');
2567
+ act(async () => {
2568
+ const res = await api('DELETE', `/agents/${a.id}${del ? '?deleteWorkspace=1' : ''}`);
2569
+ if (res.warnings?.length) toast(res.warnings.join('; '), 6000);
2570
+ });
2571
+ break;
2572
+ }
2573
+ }
2574
+ });
2575
+
2576
+ $('#detail-body').addEventListener('submit', (e) => {
2577
+ e.preventDefault();
2578
+ const form = e.target.closest('form[data-link]');
2579
+ const input = form.querySelector('input');
2580
+ const value = input.value.trim();
2581
+ if (!value) return;
2582
+ act(async () => {
2583
+ upsert(await api('POST', `/agents/${selectedId}/links`, { type: form.dataset.link, value }));
2584
+ form.querySelector('input').value = '';
2585
+ renderDetail();
2586
+ });
2587
+ });
2588
+
2589
+ let notesTimer;
2590
+ $('#detail-body').addEventListener('change', (e) => {
2591
+ const id = e.target.dataset.priority;
2592
+ if (!id) return;
2593
+ act(async () => upsert(await api('PATCH', `/agents/${id}`, { priority: Number(e.target.value) })), `Priority set to P${e.target.value}`);
2594
+ });
2595
+
2596
+ $('#detail-body').addEventListener('input', (e) => {
2597
+ if (e.target.dataset.field !== 'notes') return;
2598
+ const id = selectedId;
2599
+ const notes = e.target.value;
2600
+ clearTimeout(notesTimer);
2601
+ notesTimer = setTimeout(() => act(() => api('PATCH', `/agents/${id}`, { notes })), 600);
2602
+ });
2603
+
2604
+ $('#filter').addEventListener('input', (e) => {
2605
+ filterText = e.target.value.trim().toLowerCase();
2606
+ renderList();
2607
+ });
2608
+
2609
+ // ------------------------------------------------------------------ new agents dialog
2610
+
2611
+ const dialog = $('#new-dialog');
2612
+ function renderProjectOptions() {
2613
+ $('#project-options').innerHTML = projectsState
2614
+ .map((p) => `<option value="${esc(p.shortcut)}">${esc(p.name)}${p.repo ? ` · ${esc(p.repo)}` : p.dir ? ` · ${esc(p.dir)}` : ''}</option>`)
2615
+ .join('');
2616
+ }
2617
+
2618
+ function openNewDialog(mode = 'now') {
2619
+ renderProjectOptions();
2620
+ $('#new-error').hidden = true;
2621
+ dialog.querySelector(`input[name="when"][value="${mode}"]`).checked = true;
2622
+ syncWhen();
2623
+ dialog.showModal();
2624
+ dialog.querySelector('textarea').focus();
2625
+ }
2626
+ function syncWhen() {
2627
+ const when = dialog.querySelector('input[name="when"]:checked').value;
2628
+ dialog.querySelector('.now-only').hidden = when !== 'now';
2629
+ dialog.querySelector('.race-only').hidden = when !== 'race';
2630
+ dialog.querySelector('button[type=submit]').textContent = when === 'backlog' ? 'Add to backlog' : when === 'race' ? 'Start race' : 'Create agents';
2631
+ }
2632
+ for (const r of dialog.querySelectorAll('input[name="when"]')) r.addEventListener('change', syncWhen);
2633
+ $('#new-btn').onclick = () => openNewDialog('now');
2634
+ $('#new-cancel').onclick = () => dialog.close();
2635
+ $('#new-form').addEventListener('submit', async (e) => {
2636
+ e.preventDefault();
2637
+ const f = new FormData(e.target);
2638
+ const intents = String(f.get('intents'))
2639
+ .split('\n')
2640
+ .map((s) => s.trim())
2641
+ .filter(Boolean);
2642
+ if (f.get('when') === 'race') {
2643
+ // "model", "model | approach" or "| approach", one attempt per line.
2644
+ const variants = String(f.get('variants') || '')
2645
+ .split('\n')
2646
+ .map((l) => l.trim())
2647
+ .filter(Boolean)
2648
+ .map((l) => {
2649
+ const [model, ...rest] = l.split('|');
2650
+ return { model: model.trim() || undefined, instructions: rest.join('|').trim() || undefined };
2651
+ });
2652
+ try {
2653
+ const race = await api('POST', '/races', {
2654
+ intent: intents.join('\n'),
2655
+ variants,
2656
+ testCommand: f.get('testCommand') || undefined,
2657
+ repo: f.get('repo') || undefined,
2658
+ dir: f.get('dir') || undefined,
2659
+ base: f.get('base') || undefined,
2660
+ project: f.get('project') || undefined,
2661
+ permissionMode: f.get('permissionMode') || undefined,
2662
+ model: f.get('model') || undefined,
2663
+ priority: Number(f.get('priority')),
2664
+ });
2665
+ races.set(race.id, race);
2666
+ dialog.close();
2667
+ e.target.reset();
2668
+ syncWhen();
2669
+ openRace(race.id);
2670
+ toast(`Race started: ${race.rows.length} attempts`);
2671
+ } catch (err) {
2672
+ $('#new-error').textContent = err.message;
2673
+ $('#new-error').hidden = false;
2674
+ }
2675
+ return;
2676
+ }
2677
+ if (f.get('when') === 'backlog') {
2678
+ try {
2679
+ const added = await api('POST', '/backlog', {
2680
+ items: intents,
2681
+ repo: f.get('repo') || undefined,
2682
+ dir: f.get('dir') || undefined,
2683
+ base: f.get('base') || undefined,
2684
+ project: f.get('project') || undefined,
2685
+ permissionMode: f.get('permissionMode') || undefined,
2686
+ model: f.get('model') || undefined,
2687
+ engine: f.get('engine') || undefined,
2688
+ worktree: f.get('worktree') ? true : undefined,
2689
+ plan: Boolean(f.get('plan')),
2690
+ planReview: Boolean(f.get('planReview')),
2691
+ priority: Number(f.get('priority')),
2692
+ });
2693
+ dialog.close();
2694
+ e.target.reset();
2695
+ showTab('backlog');
2696
+ toast(`Queued ${added.length} item${added.length > 1 ? 's' : ''}`);
2697
+ } catch (err) {
2698
+ $('#new-error').textContent = err.message;
2699
+ $('#new-error').hidden = false;
2700
+ }
2701
+ return;
2702
+ }
2703
+ try {
2704
+ const created = await api('POST', '/agents', {
2705
+ priority: Number(f.get('priority')),
2706
+ intents,
2707
+ repo: f.get('repo') || undefined,
2708
+ dir: f.get('dir') || undefined,
2709
+ permissionMode: f.get('permissionMode') || undefined,
2710
+ model: f.get('model') || undefined,
2711
+ engine: f.get('engine') || undefined,
2712
+ worktree: f.get('worktree') ? true : undefined,
2713
+ base: f.get('base') || undefined,
2714
+ project: f.get('project') || undefined,
2715
+ plan: Boolean(f.get('plan')),
2716
+ planReview: Boolean(f.get('planReview')),
2717
+ start: Boolean(f.get('start')),
2718
+ });
2719
+ created.forEach((a) => agents.set(a.id, a));
2720
+ dialog.close();
2721
+ e.target.reset();
2722
+ select(created[0].id);
2723
+ toast(`Created ${created.length} agent${created.length > 1 ? 's' : ''}`);
2724
+ if (created.length === 1 && created[0].runtime.planning) term.open(created[0].id);
2725
+ } catch (err) {
2726
+ $('#new-error').textContent = err.message;
2727
+ $('#new-error').hidden = false;
2728
+ }
2729
+ });
2730
+
2731
+ // ------------------------------------------------------------------ ask (general session)
2732
+
2733
+ const askDialog = $('#ask-dialog');
2734
+ $('#ask-btn').onclick = () => {
2735
+ $('#ask-error').hidden = true;
2736
+ askDialog.showModal();
2737
+ askDialog.querySelector('textarea').focus();
2738
+ };
2739
+ $('#ask-cancel').onclick = () => askDialog.close();
2740
+ $('#ask-form').addEventListener('submit', async (e) => {
2741
+ e.preventDefault();
2742
+ const f = new FormData(e.target);
2743
+ try {
2744
+ const a = await api('POST', '/general', {
2745
+ question: String(f.get('question') || '').trim() || undefined,
2746
+ name: String(f.get('name') || '').trim() || undefined,
2747
+ });
2748
+ agents.set(a.id, a);
2749
+ askDialog.close();
2750
+ e.target.reset();
2751
+ select(a.id);
2752
+ // The shared general folder is ready almost immediately; open the terminal once it runs.
2753
+ const t0 = Date.now();
2754
+ const wait = setInterval(() => {
2755
+ const cur = agents.get(a.id);
2756
+ if (cur?.runtime.running || Date.now() - t0 > 10000) {
2757
+ clearInterval(wait);
2758
+ if (cur?.runtime.running && selectedId === a.id) term.open(a.id);
2759
+ }
2760
+ }, 150);
2761
+ } catch (err) {
2762
+ $('#ask-error').textContent = err.message;
2763
+ $('#ask-error').hidden = false;
2764
+ }
2765
+ });
2766
+ $('#ask-form textarea').addEventListener('keydown', (e) => {
2767
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) $('#ask-form').requestSubmit();
2768
+ });
2769
+
2770
+ // ------------------------------------------------------------------ live updates
2771
+
2772
+ function handleEvent(msg) {
2773
+ if (msg.t === 'snapshot') {
2774
+ agents.clear();
2775
+ msg.agents.forEach((a) => agents.set(a.id, a));
2776
+ if (msg.backlog) backlogState = msg.backlog;
2777
+ if (msg.projects) projectsState = msg.projects;
2778
+ races.clear();
2779
+ for (const r of msg.races ?? []) races.set(r.id, r);
2780
+ msg.agents.forEach(watchDocs);
2781
+ if (selectedId && !agents.has(selectedId)) selectedId = null;
2782
+ if (openTermOnLoad && selectedId) {
2783
+ if (isShell(hashView) || isBrowser(hashView)) term.open(selectedId, hashView);
2784
+ else if (agents.get(selectedId).runtime.running) term.open(selectedId);
2785
+ }
2786
+ openTermOnLoad = false;
2787
+ } else if (msg.t === 'agent') {
2788
+ agents.set(msg.agent.id, msg.agent);
2789
+ watchDocs(msg.agent);
2790
+ if (msg.agent.id === term.agentId) term.renderTabs();
2791
+ } else if (msg.t === 'removed') {
2792
+ agents.delete(msg.id);
2793
+ if (selectedId === msg.id) select(null);
2794
+ } else if (msg.t === 'race') {
2795
+ races.set(msg.race.id, msg.race);
2796
+ } else if (msg.t === 'race-removed') {
2797
+ races.delete(msg.id);
2798
+ } else if (msg.t === 'backlog') {
2799
+ backlogState = msg.backlog;
2800
+ } else if (msg.t === 'projects') {
2801
+ projectsState = msg.projects;
2802
+ renderProjectOptions();
2803
+ }
2804
+ render();
2805
+ }
2806
+
2807
+ function connectEvents() {
2808
+ openStream(
2809
+ { events: true },
2810
+ {
2811
+ onOpen: () => {
2812
+ $('#conn').textContent = REMOTE ? 'connected (remote)' : 'daemon connected';
2813
+ $('#conn').className = 'conn ok';
2814
+ },
2815
+ onMessage: handleEvent,
2816
+ onClose: async () => {
2817
+ $('#conn').textContent = 'daemon offline, retrying…';
2818
+ $('#conn').className = 'conn bad';
2819
+ if (REMOTE && !(await sessionOk())) return showLogin();
2820
+ setTimeout(connectEvents, 2000);
2821
+ },
2822
+ },
2823
+ );
2824
+ }
2825
+
2826
+ // ------------------------------------------------------------------ phone keys and reply box
2827
+
2828
+ const KEYS = {
2829
+ esc: '\x1b', tab: '\t', stab: '\x1b[Z', up: '\x1b[A', down: '\x1b[B', right: '\x1b[C', left: '\x1b[D',
2830
+ enter: '\r', ctrlc: '\x03', 1: '1', 2: '2', 3: '3', 4: '4',
2831
+ };
2832
+ $('#keybar').addEventListener('click', (e) => {
2833
+ const key = e.target.closest('[data-key]')?.dataset.key;
2834
+ if (key) term.send({ t: 'i', d: KEYS[key] });
2835
+ });
2836
+ // Keep the on-screen keyboard from popping up when tapping keybar buttons.
2837
+ $('#keybar').addEventListener('mousedown', (e) => e.preventDefault());
2838
+
2839
+ $('#compose').addEventListener('submit', (e) => {
2840
+ e.preventDefault();
2841
+ const box = $('#compose-text');
2842
+ const text = box.value;
2843
+ if (!text.trim()) return;
2844
+ term.send({ t: 'i', d: text });
2845
+ // Enter separately, so Claude reads the text as typed input and then submits it.
2846
+ setTimeout(() => term.send({ t: 'i', d: '\r' }), 150);
2847
+ box.value = '';
2848
+ box.style.height = '';
2849
+ });
2850
+ $('#compose-text').addEventListener('keydown', (e) => {
2851
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
2852
+ e.preventDefault();
2853
+ $('#compose').requestSubmit();
2854
+ }
2855
+ });
2856
+ $('#compose-text').addEventListener('input', (e) => {
2857
+ e.target.style.height = '';
2858
+ e.target.style.height = `${Math.min(e.target.scrollHeight, 140)}px`;
2859
+ });
2860
+
2861
+ // ------------------------------------------------------------------ remote access (from this computer)
2862
+
2863
+ async function renderRemote() {
2864
+ const box = $('#remote-content');
2865
+ let st;
2866
+ try {
2867
+ st = await api('GET', '/remote');
2868
+ } catch (err) {
2869
+ box.textContent = err.message;
2870
+ return;
2871
+ }
2872
+ const phoneUrls = st.urls.filter((u) => !u.vpn);
2873
+ const vpnUrls = st.urls.filter((u) => u.vpn);
2874
+ box.innerHTML = `
2875
+ <p class="remote-state">
2876
+ ${st.enabled && st.running ? `<span class="pill working">on</span> HTTPS on port <b>${st.port}</b>` : '<span class="pill">off</span> Not reachable from other devices.'}
2877
+ <button id="remote-toggle" class="${st.enabled ? '' : 'primary'}">${st.enabled ? 'Turn off' : 'Turn on'}</button>
2878
+ </p>
2879
+ ${st.enabled && st.running
2880
+ ? `${phoneUrls.length
2881
+ ? phoneUrls
2882
+ .map(
2883
+ (u) => `<div class="remote-url">
2884
+ <div class="qr">${u.qr}</div>
2885
+ <div>
2886
+ <div><b>Scan with your phone's camera</b> to open and sign in.</div>
2887
+ <code>${esc(u.url)}</code> <span class="hint">(${esc(u.iface)})</span>
2888
+ <div><button class="small" data-copy="${esc(u.loginUrl)}">Copy sign-in link</button></div>
2889
+ </div>
2890
+ </div>`,
2891
+ )
2892
+ .join('')
2893
+ : '<p class="error">This computer has no network address a phone can reach. Join Wi-Fi or your phone\'s Personal Hotspot.</p>'}
2894
+ ${vpnUrls.length ? `<p class="hint">Over the VPN only (a phone usually can't reach these): ${vpnUrls.map((u) => `<code>${esc(u.url)}</code>`).join(' ')}</p>` : ''}
2895
+ <ul class="remote-notes hint">
2896
+ <li>The phone must be on the <b>same network</b> as this computer: the same Wi-Fi, or this computer joined to the phone's <b>Personal Hotspot</b>. Office Wi-Fi often blocks device-to-device traffic.</li>
2897
+ <li>The first visit warns about the certificate (it's self-signed). Check the fingerprint below, then choose <i>Show Details → visit this website</i> (Safari) or <i>Advanced → Proceed</i> (Chrome).</li>
2898
+ <li>If macOS asks whether <b>node</b> may accept incoming connections, choose Allow.</li>
2899
+ </ul>
2900
+ <p class="hint">Certificate SHA-256: <code class="fp">${esc(st.fingerprint ?? '')}</code></p>
2901
+ <h3>Signed-in devices</h3>
2902
+ ${st.devices.length
2903
+ ? `<table class="devices"><tr><th>Device</th><th>Address</th><th>Last seen</th><th></th></tr>${st.devices
2904
+ .map(
2905
+ (d) => `<tr><td title="${esc(d.ua)}">${esc(deviceName(d.ua))}</td><td>${esc(d.ip ?? '')}</td><td>${ago(d.lastSeen)}</td>
2906
+ <td><button class="small danger" data-revoke="${esc(d.id)}">Sign out</button></td></tr>`,
2907
+ )
2908
+ .join('')}</table>`
2909
+ : '<p class="hint">None yet.</p>'}
2910
+ <p><button id="remote-rotate" class="danger">New access token</button> <span class="hint">Signs out every device; scan the new QR code to sign in again.</span></p>`
2911
+ : ''}`;
2912
+ }
2913
+
2914
+ function deviceName(ua = '') {
2915
+ const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : 'Device';
2916
+ const browser = /CriOS|Chrome/.test(ua) ? 'Chrome' : /FxiOS|Firefox/.test(ua) ? 'Firefox' : /Safari/.test(ua) ? 'Safari' : '';
2917
+ return [os, browser].filter(Boolean).join(' · ');
2918
+ }
2919
+
2920
+ $('#remote-btn').onclick = () => {
2921
+ $('#remote-dialog').showModal();
2922
+ renderRemote();
2923
+ };
2924
+ $('#remote-close').onclick = () => $('#remote-dialog').close();
2925
+ $('#remote-content').addEventListener('click', async (e) => {
2926
+ const btn = e.target.closest('button');
2927
+ if (!btn) return;
2928
+ if (btn.dataset.copy) {
2929
+ await navigator.clipboard.writeText(btn.dataset.copy).catch(() => {});
2930
+ return toast('Sign-in link copied. It contains the access token; share it only with your own devices.', 4000);
2931
+ }
2932
+ if (btn.id === 'remote-toggle') {
2933
+ const on = btn.textContent === 'Turn on';
2934
+ await act(() => api('POST', on ? '/remote/enable' : '/remote/disable'), on ? 'Remote access on' : 'Remote access off');
2935
+ } else if (btn.id === 'remote-rotate') {
2936
+ if (!confirm('Create a new access token? Every signed-in device is signed out.')) return;
2937
+ await act(() => api('POST', '/remote/rotate'), 'New token created; all devices signed out');
2938
+ } else if (btn.dataset.revoke) {
2939
+ await act(() => api('DELETE', `/remote/devices/${btn.dataset.revoke}`), 'Device signed out');
2940
+ }
2941
+ renderRemote();
2942
+ });
2943
+
2944
+ // ------------------------------------------------------------------ Slack alerts (from this computer)
2945
+
2946
+ async function loadAlerts() {
2947
+ const n = await api('GET', '/notify');
2948
+ const f = $('#alerts-form');
2949
+ f.webhookUrl.value = '';
2950
+ f.botToken.value = '';
2951
+ f.channel.value = n.slack.channel ?? '';
2952
+ f.webhookUrl.placeholder = n.slack.webhookUrl ?? 'https://hooks.slack.com/services/…';
2953
+ f.botToken.placeholder = n.slack.botToken ?? 'xoxb-…';
2954
+ for (const k of Object.keys(n.events)) if (f[`ev_${k}`]) f[`ev_${k}`].checked = n.events[k];
2955
+ f.needsInputDelaySec.value = n.needsInputDelaySec;
2956
+ f.waitingDelaySec.value = n.waitingDelaySec;
2957
+ f.skipWhenWatching.checked = n.skipWhenWatching;
2958
+ f.includeIntent.checked = n.includeIntent;
2959
+ f.enabled.checked = n.enabled;
2960
+ $('#alerts-current').textContent = n.configured
2961
+ ? `Configured: ${n.slack.channel ? `bot token → ${n.slack.channel}` : `webhook ${n.slack.webhookUrl}`}. Leave the fields empty to keep it.`
2962
+ : 'Not configured yet.';
2963
+ $('#alerts-recent').innerHTML = n.recent.length
2964
+ ? `Recent: ${n.recent.slice(0, 5).map((r) => `${esc(r.kind)} · ${esc(r.agent)} · ${r.error ? `<span class="error">${esc(r.error)}</span>` : TRIAGED[r.urgency] ?? 'sent'} · ${ago(r.at)}`).join('<br>')}`
2965
+ : '';
2966
+ $('#alerts-recent').innerHTML += n.judge
2967
+ ? `<p>🤖 The AI judge decides which stuck, overlap, idle, blocked and review alerts go out now, wait for the digest (every ${n.judge.digestMin} min${n.judge.pending ? `; ${n.judge.pending} waiting` : ''}) or are dropped. Prompts, questions and errors always go out at once.</p>`
2968
+ : '';
2969
+ $('#alerts-error').hidden = true;
2970
+ }
2971
+
2972
+ $('#alerts-btn').onclick = async () => {
2973
+ $('#alerts-dialog').showModal();
2974
+ await loadAlerts().catch((err) => toast(err.message));
2975
+ };
2976
+ $('#alerts-close').onclick = () => $('#alerts-dialog').close();
2977
+ $('#alerts-form').addEventListener('submit', async (e) => {
2978
+ e.preventDefault();
2979
+ const f = e.target;
2980
+ const body = {
2981
+ events: Object.fromEntries([...f.querySelectorAll('input[name^="ev_"]')].map((el) => [el.name.slice(3), el.checked])),
2982
+ needsInputDelaySec: Number(f.needsInputDelaySec.value),
2983
+ waitingDelaySec: Number(f.waitingDelaySec.value),
2984
+ skipWhenWatching: f.skipWhenWatching.checked,
2985
+ includeIntent: f.includeIntent.checked,
2986
+ enabled: f.enabled.checked,
2987
+ channel: f.channel.value.trim() || undefined,
2988
+ };
2989
+ if (f.webhookUrl.value.trim()) body.webhookUrl = f.webhookUrl.value.trim();
2990
+ if (f.botToken.value.trim()) body.botToken = f.botToken.value.trim();
2991
+ try {
2992
+ await api('POST', '/notify', body);
2993
+ toast('Alert settings saved');
2994
+ await loadAlerts();
2995
+ } catch (err) {
2996
+ $('#alerts-error').textContent = err.message;
2997
+ $('#alerts-error').hidden = false;
2998
+ }
2999
+ });
3000
+ $('#alerts-test').onclick = async () => {
3001
+ try {
3002
+ await api('POST', '/notify/test');
3003
+ toast('Test message sent to Slack');
3004
+ } catch (err) {
3005
+ $('#alerts-error').textContent = err.message;
3006
+ $('#alerts-error').hidden = false;
3007
+ }
3008
+ };
3009
+
3010
+ // ------------------------------------------------------------------ remote sign-in
3011
+
3012
+ async function sessionOk() {
3013
+ try {
3014
+ return (await (await fetch('/api/session', { credentials: 'same-origin' })).json()).authenticated;
3015
+ } catch {
3016
+ return true; // offline, not signed out: keep retrying
3017
+ }
3018
+ }
3019
+
3020
+ async function login(token, kind = 'token') {
3021
+ const res = await post('/api/login', { [kind]: token });
3022
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
3023
+ }
3024
+
3025
+ function showLogin(message) {
3026
+ const dlg = $('#login-dialog');
3027
+ $('#login-error').hidden = !message;
3028
+ $('#login-error').textContent = message ?? '';
3029
+ if (!dlg.open) dlg.showModal();
3030
+ }
3031
+
3032
+ $('#login-form').addEventListener('submit', async (e) => {
3033
+ e.preventDefault();
3034
+ const token = new FormData(e.target).get('token').trim().replace(/^.*#login=/, '');
3035
+ try {
3036
+ await login(token);
3037
+ $('#login-dialog').close();
3038
+ connectEvents();
3039
+ } catch (err) {
3040
+ showLogin(err.message);
3041
+ }
3042
+ });
3043
+
3044
+ // Engines this machine can run; the New agent form offers a choice once there's more than Claude.
3045
+ const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity' };
3046
+ async function loadEngines() {
3047
+ try {
3048
+ const list = (await api('GET', '/engines')).filter((e) => e.installed && e.id !== 'claude');
3049
+ const sel = document.querySelector('#engine-field select');
3050
+ for (const e of list) sel.append(Object.assign(document.createElement('option'), { value: e.id, textContent: e.label }));
3051
+ $('#engine-field').hidden = !list.length;
3052
+ } catch {}
3053
+ }
3054
+
3055
+ async function boot() {
3056
+ if (REMOTE) {
3057
+ document.body.classList.add('remote');
3058
+ if (loginHash) {
3059
+ history.replaceState(null, '', location.pathname); // drop the token from the address bar
3060
+ try {
3061
+ await login(loginHash);
3062
+ } catch (err) {
3063
+ return showLogin(err.message);
3064
+ }
3065
+ }
3066
+ if (ticketHash) {
3067
+ history.replaceState(null, '', location.pathname + (goHash ? `#${goHash}` : '')); // the ticket is single-use anyway
3068
+ if (!(await sessionOk())) {
3069
+ try {
3070
+ await login(ticketHash, 'ticket');
3071
+ } catch (err) {
3072
+ return showLogin(err.message);
3073
+ }
3074
+ }
3075
+ }
3076
+ if (!(await sessionOk())) return showLogin();
3077
+ }
3078
+ connectEvents();
3079
+ loadEngines();
3080
+ }
3081
+
3082
+ boot();
3083
+ setInterval(renderDetail, 30_000); // keep "x ago" labels fresh