ai-agentmanager 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ // Open Terminal: shells on this computer, in your home folder, not tied to any agent. Works from
2
+ // any signed-in device (over remote access it streams like an agent's shell). The shells keep
3
+ // running when the window is hidden; × ends one.
4
+ import { openStream } from '/transport.js';
5
+
6
+ const MACHINE = '__machine__';
7
+
8
+ export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
9
+ const dialog = $('#machine-dialog');
10
+ let xterm = null;
11
+ let replaying = false; // writing the backlog (see onData)
12
+ let fit = null;
13
+ let conn = null;
14
+ let current = null;
15
+ let shells = [];
16
+
17
+ function ensure() {
18
+ if (xterm) return;
19
+ xterm = new Terminal({ cursorBlink: true, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13, scrollback: 10000, theme: { background: '#0f1117' } });
20
+ fit = new FitAddon();
21
+ xterm.loadAddon(fit);
22
+ xterm.open($('#machine-term'));
23
+ // Answers xterm gives to queries in the replayed backlog are stale; don't send them as typing.
24
+ xterm.onData((d) => !replaying && conn?.send({ t: 'i', d }));
25
+ xterm.onResize(({ cols, rows }) => conn?.send({ t: 'r', cols, rows }));
26
+ new ResizeObserver(() => dialog.open && fit.fit()).observe($('#machine-term'));
27
+ }
28
+
29
+ function renderTabs() {
30
+ const nums = [...new Set([...shells, current].filter(Boolean))].sort((a, b) => a - b);
31
+ $('#machine-tabs').innerHTML = nums
32
+ .map((n) => `<button class="term-tab ${n === current ? 'active' : ''}" data-n="${n}" role="tab">Terminal ${n}<span class="x" data-drop="${n}" title="End this terminal">×</span></button>`)
33
+ .join('');
34
+ }
35
+
36
+ function connect(n) {
37
+ conn?.close();
38
+ current = n;
39
+ xterm.reset();
40
+ renderTabs();
41
+ conn = openStream(
42
+ { agentId: MACHINE, kind: `shell:${n}` },
43
+ {
44
+ onOpen: () => {
45
+ fit.fit();
46
+ conn.send({ t: 'r', cols: xterm.cols, rows: xterm.rows });
47
+ xterm.focus();
48
+ },
49
+ onMessage: (msg) => {
50
+ if (msg.t === 'hello' && msg.backlog) {
51
+ replaying = true;
52
+ xterm.write(msg.backlog, () => (replaying = false));
53
+ }
54
+ else if (msg.t === 'o') xterm.write(msg.d);
55
+ else if (msg.t === 'exit') xterm.write('\r\n\x1b[2m[terminal ended; reopen it with + Terminal]\x1b[0m\r\n');
56
+ else if (msg.t === 'error') xterm.write(`\r\n\x1b[31m${msg.error}\x1b[0m\r\n`);
57
+ },
58
+ onClose: ({ reason }) => reason && xterm.write(`\r\n\x1b[2m[${reason}]\x1b[0m\r\n`),
59
+ onInputLost: () => xterm.write('\r\n\x1b[33m[some typing did not reach the computer (connection lost); check before going on]\x1b[0m\r\n'),
60
+ },
61
+ );
62
+ xterm.focus();
63
+ }
64
+
65
+ async function refresh() {
66
+ const r = await api('GET', '/machine/shells');
67
+ shells = r.shells;
68
+ $('#machine-title').textContent = r.home;
69
+ return r;
70
+ }
71
+
72
+ $('#machine-btn').onclick = async () => {
73
+ try {
74
+ dialog.showModal();
75
+ ensure();
76
+ await refresh();
77
+ const n = current && shells.includes(current) ? current : shells[0] ?? (await api('POST', '/machine/shell/new')).n;
78
+ requestAnimationFrame(() => connect(n));
79
+ } catch (err) {
80
+ dialog.close();
81
+ toast(`Error: ${err.message}`, 5000);
82
+ }
83
+ };
84
+ $('#machine-new').onclick = async () => {
85
+ const { n } = await api('POST', '/machine/shell/new');
86
+ shells = [...new Set([...shells, n])];
87
+ connect(n);
88
+ };
89
+ $('#machine-close').onclick = () => {
90
+ conn?.close();
91
+ conn = null;
92
+ dialog.close();
93
+ };
94
+ dialog.addEventListener('close', () => {
95
+ conn?.close();
96
+ conn = null;
97
+ });
98
+ $('#machine-tabs').addEventListener('click', async (e) => {
99
+ const drop = e.target.closest('[data-drop]');
100
+ if (drop) {
101
+ e.stopPropagation();
102
+ const n = Number(drop.dataset.drop);
103
+ await api('POST', '/machine/shell/drop', { n });
104
+ await refresh();
105
+ shells = shells.filter((x) => x !== n);
106
+ if (n === current) {
107
+ if (shells.length) connect(shells[0]);
108
+ else {
109
+ current = null;
110
+ conn?.close();
111
+ dialog.close();
112
+ }
113
+ } else renderTabs();
114
+ return;
115
+ }
116
+ const tab = e.target.closest('[data-n]');
117
+ if (tab && Number(tab.dataset.n) !== current) connect(Number(tab.dataset.n));
118
+ });
119
+ return {};
120
+ }
package/public/menu.js ADDED
@@ -0,0 +1,92 @@
1
+ // The menu at the right of the top bar: who this runs as (Claude account, git identity, computer)
2
+ // and the settings and preferences: Settings…, Clean up, Alerts, Remote access, About.
3
+
4
+ export function initMenu({ api, esc, toast, $ }) {
5
+ const btn = $('#menu-btn');
6
+ const menu = $('#menu');
7
+ const remote = () => document.body.classList.contains('remote');
8
+
9
+ const open = (on) => {
10
+ menu.hidden = !on;
11
+ btn.setAttribute('aria-expanded', String(on));
12
+ };
13
+ btn.onclick = (e) => {
14
+ e.stopPropagation();
15
+ open(menu.hidden);
16
+ };
17
+ document.addEventListener('click', (e) => {
18
+ if (!menu.hidden && !menu.contains(e.target)) open(false);
19
+ });
20
+ document.addEventListener('keydown', (e) => {
21
+ if (e.key === 'Escape' && !menu.hidden) open(false);
22
+ });
23
+ // Any item closes the menu; its own handler does the rest.
24
+ menu.addEventListener('click', (e) => {
25
+ if (e.target.closest('button')) open(false);
26
+ });
27
+ $('#menu-about').onclick = () => $('#about-btn').click();
28
+
29
+ async function loadMe() {
30
+ try {
31
+ const me = await api('GET', '/me');
32
+ const initials = me.display
33
+ .split(/[\s@._-]+/)
34
+ .filter(Boolean)
35
+ .slice(0, 2)
36
+ .map((w) => w[0].toUpperCase())
37
+ .join('');
38
+ $('#me-avatar').textContent = initials || '·';
39
+ $('#me-name').textContent = me.display;
40
+ $('#me-display').textContent = me.display;
41
+ $('#me-email').textContent = me.email ?? '';
42
+ $('#me-email').hidden = !me.email;
43
+ const where = [me.claude ? `Claude account${me.claude.organization && !me.claude.organization.includes(me.claude.email) ? ` · ${me.claude.organization}` : ''}` : null, me.user && me.host ? `${me.user} on ${me.host}` : null, me.remote ? 'signed in from this device' : null].filter(Boolean);
44
+ $('#me-where').innerHTML = where.map(esc).join('<br>');
45
+ btn.title = `${me.display}${me.email ? ` (${me.email})` : ''}: settings and preferences`;
46
+ } catch {
47
+ // An older daemon has no /me: the menu still works.
48
+ $('#me-display').textContent = 'Settings';
49
+ }
50
+ }
51
+
52
+ // ------------------------------------------------------------ Settings dialog
53
+ const dialog = $('#settings-dialog');
54
+ const form = $('#settings-form');
55
+ $('#settings-btn').onclick = async () => {
56
+ $('#settings-error').hidden = true;
57
+ try {
58
+ const c = await api('GET', '/cleanup');
59
+ form.maxRunning.value = c.maxRunning ?? '';
60
+ $('#settings-running').textContent = `Open now: ${c.running}.`;
61
+ if (!remote()) {
62
+ const u = await api('GET', '/update').catch(() => null);
63
+ form.updateCheck.checked = u?.check !== false;
64
+ }
65
+ dialog.showModal();
66
+ } catch (err) {
67
+ toast(`Error: ${err.message}`, 5000);
68
+ }
69
+ };
70
+ $('#settings-cancel').onclick = () => dialog.close();
71
+ dialog.addEventListener('click', (e) => {
72
+ const go = e.target.closest('[data-open]');
73
+ if (!go) return;
74
+ dialog.close();
75
+ $(`#${go.dataset.open}`).click();
76
+ });
77
+ form.addEventListener('submit', async (e) => {
78
+ e.preventDefault();
79
+ try {
80
+ const c = await api('PUT', '/cleanup', { maxRunning: form.maxRunning.value.trim() || null });
81
+ if (!remote()) await api('PUT', '/update', { check: form.updateCheck.checked }).catch(() => {});
82
+ dialog.close();
83
+ toast(c.maxRunning ? `At most ${c.maxRunning} agents open; the ones idle longest close above that` : 'No limit on open agents');
84
+ } catch (err) {
85
+ $('#settings-error').textContent = err.message;
86
+ $('#settings-error').hidden = false;
87
+ }
88
+ });
89
+
90
+ loadMe();
91
+ return { loadMe };
92
+ }
package/public/setup.js CHANGED
@@ -1,5 +1,5 @@
1
- // Getting started: shown where an agent's details go while none is selected, until every step
2
- // is done or it is hidden. Steps open the dialog that does them.
1
+ // The home page, where an agent's details go while none is selected: Getting started until every
2
+ // step is done or it is hidden (steps open the dialog that does them), then what the app can do.
3
3
 
4
4
  const STEPS = [
5
5
  ['agent', 'Start your first agent', 'Give it a request and a repo or folder; it works in its own branch.', '#new-btn', 'New agents'],
@@ -9,6 +9,21 @@ const STEPS = [
9
9
  ['budget', 'Set a budget', 'An alert at 80% of an agent budget, and a daily limit.', '#spend-btn', 'Results'],
10
10
  ];
11
11
 
12
+ // What the app can do, each with where to find it: a button to click, a sidebar tab, or a link.
13
+ const FEATURES = [
14
+ ['Agents side by side', 'Each works on its own branch in its own folder; the list shows which need you.', { btn: '#new-btn', label: 'New agents' }],
15
+ ['Teams and races', 'A team (analyst, critics, implementer, tester, reviewer) solves one problem; a race tries variants and you keep the best.', { btn: '#new-btn', label: 'New agents' }],
16
+ ['Inbox', 'Every question, permission prompt, stuck agent and overlap between agents, in one place.', { tab: 'inbox', label: 'Inbox' }],
17
+ ['Code', "Every agent has a Code tab: its files in the editor from VS Code, with search and a diff against where it started. Or open the folder in VS Code or Cursor."],
18
+ ['Shells and browser', "+ Shell opens a terminal in an agent's folder; + Browser shows a page as this computer sees it (a dev server on localhost), from your phone too."],
19
+ ['Terminal', 'A shell on this computer in your home folder, from any device you are signed in on.', { btn: '#machine-btn', label: 'Terminal' }],
20
+ ['Review and land', "Review an agent's changes and send comments back; Land shows each pull request and what it needs to merge.", { tab: 'land', label: 'Land' }],
21
+ ['Backlog and assistant', 'Queue work to start as agents finish; the assistant and triggers (a GitHub label, a webhook) bring in new work.', { tab: 'backlog', label: 'Backlog' }],
22
+ ['Results', 'Merge rate, time to PR and cost per merged PR, with budgets and a daily limit.', { btn: '#spend-btn', label: 'Results' }],
23
+ ['From your phone', 'Answer agents, review and use the terminals from anywhere on your network.', { btn: '#remote-btn', label: 'Remote access' }],
24
+ ['Several machines', 'Link your computers to a hub to see every agent in one place, and move an agent to another machine to carry on there.', { href: 'https://github.com/jdmunta/ai-agentmanager-core/blob/main/docs/distributed.md', label: 'How it works' }],
25
+ ];
26
+
12
27
  export function initSetup({ api, esc, $ }) {
13
28
  const el = $('#placeholder');
14
29
  const plain = el.innerHTML;
@@ -21,30 +36,41 @@ export function initSetup({ api, esc, $ }) {
21
36
  }
22
37
  };
23
38
 
24
- function render() {
25
- if (!data || hidden()) {
26
- el.innerHTML = plain;
27
- return;
28
- }
39
+ // A feature's button, unless what it opens isn't available here (local-only, on a phone).
40
+ function action(f) {
41
+ if (f.href) return `<a href="${f.href}" target="_blank" rel="noopener">${f.label} ↗</a>`;
42
+ if (f.btn && document.body.classList.contains('remote') && document.querySelector(f.btn)?.classList.contains('local-only')) return '';
43
+ if (f.btn) return `<button class="small ghost" data-setup-open="${f.btn}">${f.label}</button>`;
44
+ if (f.tab) return `<button class="small ghost" data-setup-tab="${f.tab}">${f.label}</button>`;
45
+ return '';
46
+ }
47
+
48
+ function steps() {
49
+ if (!data || hidden()) return '';
29
50
  const done = STEPS.filter(([k]) => data.steps[k]).length;
30
- if (done === STEPS.length && !data.checks.length) {
31
- el.innerHTML = plain;
32
- return;
33
- }
34
- el.innerHTML = `<div class="setup">
35
- <div class="setup-head"><h3>Getting started</h3><span class="hint">${done} of ${STEPS.length} done</span><span class="spacer"></span><button class="link" data-setup-hide>Hide</button></div>
51
+ if (done === STEPS.length && !data.checks.length) return '';
52
+ return `<div class="setup-head"><h3>Getting started</h3><span class="hint">${done} of ${STEPS.length} done</span><span class="spacer"></span><button class="link" data-setup-hide>Hide</button></div>
36
53
  ${data.checks.length ? `<div class="setup-checks">${data.checks.map((c) => `<div class="setup-check ${c.status}"><b>${esc(c.name)}</b>: ${esc(c.detail)}${c.fix ? `<div class="hint">${esc(c.fix)}</div>` : ''}</div>`).join('')}</div>` : ''}
37
54
  <ol class="setup-steps">${STEPS.map(([k, title, why, btn, label]) => `<li class="${data.steps[k] ? 'done' : ''}">
38
55
  <span class="setup-mark">${data.steps[k] ? '✓' : ''}</span>
39
56
  <div><b>${title}</b><div class="hint">${why}</div></div>
40
- ${data.steps[k] ? '' : `<button class="small" data-setup-open="${btn}">${label}</button>`}</li>`).join('')}</ol>
57
+ ${data.steps[k] ? '' : `<button class="small" data-setup-open="${btn}">${label}</button>`}</li>`).join('')}</ol>`;
58
+ }
59
+
60
+ function render() {
61
+ el.innerHTML = `<div class="setup">
41
62
  <p class="hint">${plain}</p>
63
+ ${steps()}
64
+ <h3 class="setup-features-head">What you can do</h3>
65
+ <ul class="setup-features">${FEATURES.map(([title, why, f = {}]) => `<li><div><b>${title}</b><div class="hint">${why}</div></div>${action(f)}</li>`).join('')}</ul>
42
66
  </div>`;
43
67
  }
44
68
 
45
69
  el.addEventListener('click', (e) => {
46
70
  const open = e.target.closest('[data-setup-open]');
47
71
  if (open) return document.querySelector(open.dataset.setupOpen)?.click();
72
+ const tab = e.target.closest('[data-setup-tab]');
73
+ if (tab) return document.querySelector(`.side-tab[data-tab="${tab.dataset.setupTab}"]`)?.click();
48
74
  if (e.target.closest('[data-setup-hide]')) {
49
75
  try {
50
76
  localStorage.setItem('agentmgr.setupHidden', '1');
package/public/style.css CHANGED
@@ -55,6 +55,12 @@ a:hover { text-decoration: underline; }
55
55
  .brand { display: flex; align-items: center; gap: 10px; }
56
56
  .brand h1 { font-size: 16px; margin: 0; font-weight: 600; }
57
57
  .logo { color: var(--accent); }
58
+ .update-btn { font-size: 12px; padding: 2px 10px; border-radius: 999px; background: var(--accent); color: var(--accent-text); border: none; cursor: pointer; }
59
+ .update-btn:disabled { opacity: 0.6; cursor: progress; }
60
+ .about-auto { display: flex; align-items: center; gap: 6px; margin-right: auto; font-size: 13px; }
61
+ .home-link { display: flex; align-items: center; gap: 10px; color: inherit; text-decoration: none; border-radius: 6px; }
62
+ .home-link:hover h1 { text-decoration: underline; text-underline-offset: 3px; }
63
+ .home-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
58
64
  .conn { font-size: 12px; color: var(--muted); }
59
65
  .about-btn { font-size: 12px; padding: 2px 8px; color: var(--muted); background: transparent; border: 1px solid var(--border); border-radius: 999px; font-variant-numeric: tabular-nums; }
60
66
  .about-btn:hover, .about-btn:focus-visible { color: var(--text); border-color: var(--accent); }
@@ -149,6 +155,10 @@ body.term-max .layout { grid-template-columns: 1fr; }
149
155
  .recent-bar input[type="number"] { width: 56px; flex: none; padding: 2px 6px; font-size: 12px; }
150
156
  .agent-list { list-style: none; margin: 0; padding: 0; }
151
157
  .agent-list li.project { border-bottom: 1px solid var(--border); }
158
+ .agent-list li.agent[draggable='true'] { cursor: grab; }
159
+ .agent-list li.agent.dragging { opacity: 0.45; }
160
+ .agent-list li.project.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; background: color-mix(in srgb, var(--accent) 10%, transparent); }
161
+ .agent-list li.project.drop-target .project-head .pname::after { content: ' ← move here'; color: var(--accent); font-weight: 500; }
152
162
  .project-head {
153
163
  width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 14px; border: 0; border-radius: 0;
154
164
  background: var(--panel-2); font-weight: 600; text-align: left; position: sticky; top: 0; z-index: 1;
@@ -197,7 +207,7 @@ body.term-max .layout { grid-template-columns: 1fr; }
197
207
 
198
208
  /* ---------- detail */
199
209
  .detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
200
- .placeholder { margin: auto; color: var(--muted); padding: 24px; text-align: center; }
210
+ .placeholder { margin: auto; color: var(--muted); padding: 24px; text-align: center; max-height: 100%; overflow-y: auto; box-sizing: border-box; min-height: 0; width: 100%; }
201
211
  #detail-body { padding: 16px 20px; overflow-y: auto; flex: 0 1 auto; max-height: 100%; }
202
212
  .detail.with-term #detail-body { max-height: var(--brief-h, 42%); border-bottom: 1px solid var(--border); }
203
213
  .detail.with-term.brief-sized #detail-body { height: var(--brief-h); flex: none; }
@@ -333,6 +343,11 @@ body.term-max .layout { grid-template-columns: 1fr; }
333
343
  .term-wrap.browsing .browser { display: flex; }
334
344
  .term-wrap.browsing .terminal, .term-wrap.browsing .keybar, .term-wrap.browsing .compose,
335
345
  .term-wrap.browsing #term-attach, .term-wrap.browsing #term-fit, .term-wrap.browsing .term-drop { display: none !important; }
346
+ /* Code replaces the terminal the same way. */
347
+ .code { display: none; flex: 1; min-height: 0; background: #1e1e1e; color: #d4d4d4; }
348
+ .term-wrap.coding .code { display: flex; }
349
+ .term-wrap.coding .terminal, .term-wrap.coding .keybar, .term-wrap.coding .compose, .term-wrap.coding .browser,
350
+ .term-wrap.coding #term-attach, .term-wrap.coding #term-fit, .term-wrap.coding .term-drop { display: none !important; }
336
351
  .browser-bar { display: flex; gap: 6px; align-items: center; padding: 6px 10px; background: #161a22; border-top: 1px solid #30363d; }
337
352
  .browser-bar input { flex: 1; min-width: 0; font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
338
353
  .browser-bar button, .browser-bar a { color: #c9d1d9; border-color: #30363d; }
@@ -720,7 +735,7 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
720
735
  .pb-form { border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-top: 10px; }
721
736
  .memory-text { width: 100%; font: 12px/1.5 var(--mono); }
722
737
  #memory-dialog { width: min(760px, calc(100vw - 32px)); }
723
- .setup { max-width: 640px; margin: 24px auto; text-align: left; color: var(--text); }
738
+ .setup { max-width: 820px; margin: 24px auto; text-align: left; color: var(--text); }
724
739
  .setup-head { display: flex; gap: 10px; align-items: baseline; }
725
740
  .setup-head h3 { margin: 0; font-size: 16px; }
726
741
  .setup-steps { list-style: none; padding: 0; margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
@@ -728,4 +743,89 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
728
743
  .setup-steps li.done { opacity: .6; }
729
744
  .setup-mark { color: var(--green); font-weight: 700; }
730
745
  .setup-check { padding: 6px 10px; border-radius: 6px; margin: 6px 0; border-left: 3px solid var(--yellow); background: var(--panel-2); font-size: 13px; }
746
+ .setup > .hint:first-child { text-align: center; margin: 0 0 16px; }
747
+ .setup-features-head { margin: 22px 0 8px; font-size: 16px; }
748
+ .setup-features { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px; }
749
+ .setup-features li { display: flex; flex-direction: column; justify-content: space-between; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
750
+ .setup-features li > :last-child:not(div) { align-self: flex-start; }
731
751
  .setup-check.fail { border-left-color: var(--red); }
752
+
753
+ /* ---------------------------------------------------------------- menu and settings */
754
+ .menu-wrap { position: relative; }
755
+ .menu-btn { display: flex; align-items: center; gap: 8px; padding: 3px 10px 3px 4px; border-radius: 999px; }
756
+ .avatar { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--accent); color: var(--accent-text); font-size: 11px; font-weight: 600; }
757
+ .me-name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
758
+ .burger { font-size: 15px; line-height: 1; opacity: 0.8; }
759
+ .menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 50; min-width: 240px; padding: 6px; background: var(--panel, var(--bg)); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); display: flex; flex-direction: column; }
760
+ .menu[hidden] { display: none; }
761
+ .menu > button { text-align: left; background: none; border: none; padding: 7px 10px; border-radius: 6px; cursor: pointer; color: inherit; font: inherit; }
762
+ .menu > button:hover, .menu > button:focus-visible { background: color-mix(in srgb, var(--accent) 12%, transparent); }
763
+ .menu-me { display: flex; flex-direction: column; gap: 2px; padding: 6px 10px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
764
+ .menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
765
+ .settings-dialog .hint { margin: 0; }
766
+ .settings-dialog input.num { width: 7em; }
767
+ @media (max-width: 720px) {
768
+ .me-name { display: none; }
769
+ .menu-btn { padding: 3px 8px 3px 4px; }
770
+ }
771
+
772
+ /* ---------------------------------------------------------------- code (studio editor) */
773
+ .code-side { width: 240px; flex: 0 0 240px; display: flex; flex-direction: column; min-height: 0; background: #181818; border-right: 1px solid #2b2b2b; }
774
+ .code.no-side .code-side { display: none; }
775
+ .code-side-bar, .code-tabs-bar { display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: #181818; border-bottom: 1px solid #2b2b2b; min-height: 32px; }
776
+ .code-side-bar .spacer, .code-tabs-bar .spacer { flex: 1; }
777
+ .code button { background: none; border: none; color: #cccccc; font: inherit; font-size: 12px; cursor: pointer; border-radius: 4px; padding: 3px 7px; }
778
+ .code button:hover { background: #2a2d2e; }
779
+ .code button.active { background: #37373d; color: #fff; }
780
+ .code .code-icon { padding: 3px 5px; }
781
+ .code .code-btn { border: 1px solid #3c3c3c; }
782
+ .code-tree { flex: 1; overflow: auto; padding: 4px 0; font-size: 13px; }
783
+ .code-node { padding: 2px 8px; white-space: nowrap; cursor: pointer; overflow: hidden; text-overflow: ellipsis; user-select: none; }
784
+ .code-node:hover { background: #2a2d2e; }
785
+ .code-node.selected { background: #37373d; }
786
+ .code-node.active { color: #fff; }
787
+ .code-node.dim { opacity: 0.55; }
788
+ .code-node .caret { display: inline-block; width: 14px; color: #8b8b8b; }
789
+ .code-search { display: flex; flex-direction: column; gap: 6px; padding: 6px; min-height: 0; flex: 1; }
790
+ .code-search[hidden] { display: none; }
791
+ .code-search input { background: #252526; color: #ddd; border: 1px solid #3c3c3c; border-radius: 4px; padding: 5px 7px; font-size: 13px; }
792
+ .code-hits { overflow: auto; flex: 1; font-size: 12px; }
793
+ .code-hit { padding: 4px 6px; border-radius: 4px; cursor: pointer; overflow-wrap: anywhere; }
794
+ .code-hit:hover { background: #2a2d2e; }
795
+ .code-hit div { color: #9d9d9d; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
796
+ .code-main { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; }
797
+ .code-tabs { display: flex; gap: 2px; overflow-x: auto; min-width: 0; scrollbar-width: none; }
798
+ .code .code-tab { white-space: nowrap; border-radius: 4px 4px 0 0; padding: 4px 8px; }
799
+ .code-tab .x { margin-left: 6px; opacity: 0.6; }
800
+ .code-tab .x:hover { opacity: 1; }
801
+ .code-editor { flex: 1; min-height: 0; position: relative; }
802
+ .code-editor[hidden], .code-empty[hidden] { display: none; }
803
+ .code-diff { position: absolute; inset: 0; }
804
+ .code-empty { flex: 1; display: grid; place-items: center; padding: 24px; color: #8b8b8b; text-align: center; font-size: 13px; }
805
+ .code-plain { width: 100%; height: 100%; resize: none; border: none; outline: none; background: #1e1e1e; color: #d4d4d4; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 8px 12px; tab-size: 2; }
806
+ .code-status { padding: 2px 10px; font-size: 11px; color: #9d9d9d; background: #181818; border-top: 1px solid #2b2b2b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-height: 18px; }
807
+ @media (max-width: 720px) {
808
+ .code-side { position: absolute; z-index: 3; inset: 0 auto 0 0; width: min(82vw, 300px); }
809
+ }
810
+ .code { position: relative; }
811
+
812
+ /* ---------------------------------------------------------------- machine terminal */
813
+ .machine-dialog { width: min(1100px, calc(100vw - 24px)); height: min(720px, calc(100vh - 24px)); max-height: none; background: #0f1117; color: #c9d1d9; border-color: #30363d; }
814
+ .machine-dialog[open] { display: flex; flex-direction: column; }
815
+ .machine-dialog .terminal { flex: 1; min-height: 0; }
816
+ .machine-dialog #machine-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8b93a7; margin-left: 8px; }
817
+
818
+ /* Diagrams: big pictures, one under another, for a quick grasp. */
819
+ #doc-body.diagrams-view h2 { margin: 26px 0 8px; padding-top: 14px; border-top: 1px solid var(--border); }
820
+ #doc-body.diagrams-view h2:first-of-type { border-top: 0; padding-top: 0; }
821
+ #doc-body.diagrams-view .diagram { display: flex; justify-content: center; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); overflow-x: auto; }
822
+ #doc-body.diagrams-view .diagram svg { max-width: 100%; height: auto; }
823
+ @media (prefers-reduced-motion: reduce) { .diagram svg * { animation: none !important; } }
824
+
825
+ .row.intent-split { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; margin-bottom: 4px; }
826
+ .intent-split > span:first-child { font-weight: 600; }
827
+ .intent-split .check { margin: 0; }
828
+ .intent-split[hidden] { display: none; }
829
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
830
+ .hint.warn { display: block; margin-top: 4px; color: var(--yellow); }
831
+ .hint.warn[hidden] { display: none; }
@@ -14,7 +14,8 @@ export const post = (url, body) =>
14
14
 
15
15
  /**
16
16
  * Open the event stream (`{ events: true }`) or a terminal (`{ agentId, kind }`).
17
- * Handlers: onOpen(), onMessage(msg), onClose({ code, reason }) when it ends for good.
17
+ * Handlers: onOpen(), onMessage(msg), onClose({ code, reason }) when it ends for good, and
18
+ * onInputLost(text) when typing could not be delivered (remote only).
18
19
  * Returns { send(msg), close() } where msg is { t: 'i', d } (input) or { t: 'r', cols, rows } (resize).
19
20
  */
20
21
  export function openStream({ events = false, agentId, kind = 'claude' }, h) {
@@ -43,16 +44,31 @@ export function openStream({ events = false, agentId, kind = 'claude' }, h) {
43
44
  }
44
45
  };
45
46
 
46
- // Keystrokes: coalesced and sent strictly in order, one request at a time.
47
+ // Keystrokes: coalesced and sent strictly in order, one request at a time. A request that fails
48
+ // (a network blip, a phone waking up) is retried, not dropped: losing a line of a paste would run
49
+ // the rest without it.
47
50
  let queue = '';
48
51
  let inflight = false;
52
+ const deliver = async (d) => {
53
+ for (let attempt = 0; attempt < 6 && !closed; attempt++) {
54
+ try {
55
+ const res = await post(`/api/agents/${agentId}/term/input`, { d, kind });
56
+ // A refusal (signed out, terminal gone) won't change on a retry.
57
+ if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)) return res.ok;
58
+ } catch {
59
+ // Offline for a moment; try again.
60
+ }
61
+ await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
62
+ }
63
+ return false;
64
+ };
49
65
  const pump = async () => {
50
66
  if (inflight || !queue) return;
51
67
  inflight = true;
52
68
  const d = queue;
53
69
  queue = '';
54
70
  try {
55
- await post(`/api/agents/${agentId}/term/input`, { d, kind });
71
+ if (!(await deliver(d)) && !closed) h.onInputLost?.(d);
56
72
  } finally {
57
73
  inflight = false;
58
74
  if (queue) pump();
@@ -0,0 +1,88 @@
1
+ // Updating from the app: a button in the top bar when a newer release is out, and "Update and
2
+ // restart" in About. The daemon installs it (git checkout of the release tag, or npm install -g)
3
+ // and restarts onto it; this page then waits for it to come back and reloads.
4
+
5
+ export function initUpdate({ api, esc, toast, $ }) {
6
+ const remote = () => document.body.classList.contains('remote');
7
+ let state = null;
8
+
9
+ function show(s) {
10
+ state = s;
11
+ const btn = $('#update-btn');
12
+ const ready = s?.newer && !s.blocker && !s.updating;
13
+ btn.hidden = !ready;
14
+ if (ready) {
15
+ btn.textContent = `Update to ${s.latest}`;
16
+ btn.title = `Version ${s.latest} is out (you have ${s.current}). Installs it and restarts; running agents resume.`;
17
+ }
18
+ $('#about-auto').checked = s?.check !== false;
19
+ $('#about-install').hidden = !ready;
20
+ }
21
+
22
+ async function load() {
23
+ if (remote()) return;
24
+ try {
25
+ show(await api('GET', '/update'));
26
+ } catch {
27
+ // An older daemon has no /update.
28
+ }
29
+ }
30
+
31
+ async function waitForRestart(from) {
32
+ for (let i = 0; i < 300; i++) {
33
+ await new Promise((r) => setTimeout(r, 2000));
34
+ try {
35
+ const a = await api('GET', '/about');
36
+ if (a.daemon.pid !== from) return location.reload();
37
+ } catch {
38
+ // Down while it restarts.
39
+ }
40
+ }
41
+ toast('The daemon has not come back yet; check ~/.agentmanager/daemon.log', 8000);
42
+ }
43
+
44
+ async function install() {
45
+ if (!state?.newer) return;
46
+ if (!confirm(`Update to ${state.latest} and restart?\n\nRunning agents are closed and resume on the new version. This takes a minute or two with many agents.`)) return;
47
+ const before = (await api('GET', '/about')).daemon.pid;
48
+ $('#update-btn').disabled = $('#about-install').disabled = true;
49
+ toast(`Installing ${state.latest}…`, 60_000);
50
+ try {
51
+ const r = await api('POST', '/update');
52
+ if (r.upToDate) {
53
+ toast(`${r.current} is the newest release`);
54
+ return load();
55
+ }
56
+ toast(`Installed ${r.to}; restarting… (agents resume)`, 600_000);
57
+ await waitForRestart(before);
58
+ } catch (err) {
59
+ toast(`Update failed: ${err.message}`, 10_000);
60
+ } finally {
61
+ $('#update-btn').disabled = $('#about-install').disabled = false;
62
+ }
63
+ }
64
+
65
+ /** "Check for updates" in About: the daemon's check where it can update itself. */
66
+ async function check() {
67
+ if (remote()) return null;
68
+ const s = await api('POST', '/update/check');
69
+ show(s);
70
+ if (!s.newer) return `You have the newest release (${esc(s.current)}).`;
71
+ if (s.blocker) return `Version ${esc(s.latest)} is out. ${esc(s.blocker)}.`;
72
+ return `Version ${esc(s.latest)} is out (you have ${esc(s.current)}). <b>Update and restart</b> installs it; running agents resume.`;
73
+ }
74
+
75
+ $('#update-btn').onclick = install;
76
+ $('#about-install').onclick = install;
77
+ $('#about-auto').onchange = async (e) => {
78
+ try {
79
+ show(await api('PUT', '/update', { check: e.target.checked }));
80
+ } catch (err) {
81
+ toast(`Error: ${err.message}`, 5000);
82
+ }
83
+ };
84
+ load();
85
+ // The daemon checks twice a day; look at what it found now and then.
86
+ setInterval(load, 30 * 60_000);
87
+ return { load, check };
88
+ }
package/src/brief.js CHANGED
@@ -342,6 +342,75 @@ export function docPrompt(kind, { intent, status, digest, repo, briefMarkdown, a
342
342
  ].join('\n');
343
343
  }
344
344
 
345
+ // ------------------------------------------------------------------ Diagrams
346
+
347
+ const DIAGRAM_SET = [
348
+ ['The problem', 'a flowchart of the situation today and where it goes wrong (the root cause, from the analysis)'],
349
+ ['Architecture', 'a flowchart of the components and how they connect, grouped with subgraphs; mark the components this work adds or changes with class "changed"'],
350
+ ['Design', 'a classDiagram (or erDiagram for data) of the key modules, types or records and their relationships'],
351
+ ['Data flow', 'a flowchart LR of how data moves through the system, with the main path animated'],
352
+ ['Key interaction', 'a sequenceDiagram (with autonumber) of the most important exchange between components, services or the user'],
353
+ ['Plan', 'a flowchart of the delivery steps or a stateDiagram-v2 of the main lifecycle, whichever the work is about'],
354
+ ];
355
+
356
+ /**
357
+ * Diagrams only: the problem and its solution drawn for a quick grasp, from the documents
358
+ * already written for this work (analysis, design, flow, plan) and the agent's live context.
359
+ */
360
+ export function diagramsPrompt({ intent, status, digest, repo, briefMarkdown, documents, repoMap }) {
361
+ return [
362
+ "Draw diagrams that let a person grasp an AI coding agent's work at a glance: the problem and how it is being solved.",
363
+ 'Diagrams only. No paragraphs, bullets, captions, notes or explanations outside the diagrams.',
364
+ '',
365
+ 'Output Markdown in exactly this shape: a level-1 title "# Diagrams: <short name of the work>", then for each diagram a',
366
+ 'level-2 heading with its name followed by one ```mermaid code block. Nothing else.',
367
+ '',
368
+ 'The diagrams, in this order (leave one out only when the context has nothing for it):',
369
+ ...DIAGRAM_SET.map(([name, what], i) => `${i + 1}. "${name}": ${what}.`),
370
+ '',
371
+ 'Rules:',
372
+ '- Valid Mermaid syntax. At most about 15 nodes per diagram; labels of five words or fewer; quote labels with punctuation, e.g. A["Parse (JSON)"]. No HTML in labels.',
373
+ '- Animate the main path in every flowchart: give its edges ids and animate them, e.g.',
374
+ ' A e1@--> B',
375
+ ' B e2@-->|"saves"| C',
376
+ ' e1@{ animate: true }',
377
+ ' e2@{ animate: true }',
378
+ '- In the architecture diagram, style what this work adds or changes: `classDef changed stroke-width:3px,stroke:#22c55e` then `class X,Y changed`.',
379
+ '- Use the real names of files, modules, services and data from the context. Prefer what the documents below say; do not invent components.',
380
+ '- Output only the Markdown.',
381
+ '',
382
+ contextBlock({ intent, status, digest, repo, briefMarkdown }).join('\n'),
383
+ '<documents_already_written>',
384
+ documents || '(none yet: draw from the context above)',
385
+ '</documents_already_written>',
386
+ '<repository_layout>',
387
+ repoMap || '(no repository)',
388
+ '</repository_layout>',
389
+ ].join('\n');
390
+ }
391
+
392
+ /** Keep only the title, the diagram headings and the Mermaid blocks of a reply. */
393
+ export function diagramsOnly(markdown) {
394
+ const out = [];
395
+ let block = null;
396
+ for (const line of String(markdown ?? '').split('\n')) {
397
+ if (block) {
398
+ if (/^\s*```\s*$/.test(line)) {
399
+ if (block.length) out.push('```mermaid', ...block, '```', '');
400
+ block = null;
401
+ } else block.push(line);
402
+ } else if (/^\s*```\s*mermaid\s*$/i.test(line)) block = [];
403
+ else if (/^#{1,2} \S/.test(line)) out.push(line, '');
404
+ }
405
+ if (!out.some((l) => l === '```mermaid')) throw new Error('The reply had no diagrams');
406
+ // A heading left with no diagram under it says nothing.
407
+ return out
408
+ .filter((l, i) => !/^## /.test(l) || out.slice(i + 1).find((x) => x !== '') === '```mermaid')
409
+ .join('\n')
410
+ .replace(/\n{3,}/g, '\n\n')
411
+ .trim();
412
+ }
413
+
345
414
  // ------------------------------------------------------------------ Human vs AI
346
415
 
347
416
  export const contextBlock = ({ intent, status, digest, repo, briefMarkdown }) => [