ai-agentmanager 1.3.1 → 1.4.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,114 @@
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 fit = null;
12
+ let conn = null;
13
+ let current = null;
14
+ let shells = [];
15
+
16
+ function ensure() {
17
+ if (xterm) return;
18
+ xterm = new Terminal({ cursorBlink: true, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13, scrollback: 10000, theme: { background: '#0f1117' } });
19
+ fit = new FitAddon();
20
+ xterm.loadAddon(fit);
21
+ xterm.open($('#machine-term'));
22
+ xterm.onData((d) => conn?.send({ t: 'i', d }));
23
+ xterm.onResize(({ cols, rows }) => conn?.send({ t: 'r', cols, rows }));
24
+ new ResizeObserver(() => dialog.open && fit.fit()).observe($('#machine-term'));
25
+ }
26
+
27
+ function renderTabs() {
28
+ const nums = [...new Set([...shells, current].filter(Boolean))].sort((a, b) => a - b);
29
+ $('#machine-tabs').innerHTML = nums
30
+ .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>`)
31
+ .join('');
32
+ }
33
+
34
+ function connect(n) {
35
+ conn?.close();
36
+ current = n;
37
+ xterm.reset();
38
+ renderTabs();
39
+ conn = openStream(
40
+ { agentId: MACHINE, kind: `shell:${n}` },
41
+ {
42
+ onOpen: () => {
43
+ fit.fit();
44
+ conn.send({ t: 'r', cols: xterm.cols, rows: xterm.rows });
45
+ xterm.focus();
46
+ },
47
+ onMessage: (msg) => {
48
+ if (msg.t === 'hello' && msg.backlog) xterm.write(msg.backlog);
49
+ else if (msg.t === 'o') xterm.write(msg.d);
50
+ else if (msg.t === 'exit') xterm.write('\r\n\x1b[2m[terminal ended; reopen it with + Terminal]\x1b[0m\r\n');
51
+ else if (msg.t === 'error') xterm.write(`\r\n\x1b[31m${msg.error}\x1b[0m\r\n`);
52
+ },
53
+ onClose: ({ reason }) => reason && xterm.write(`\r\n\x1b[2m[${reason}]\x1b[0m\r\n`),
54
+ },
55
+ );
56
+ xterm.focus();
57
+ }
58
+
59
+ async function refresh() {
60
+ const r = await api('GET', '/machine/shells');
61
+ shells = r.shells;
62
+ $('#machine-title').textContent = r.home;
63
+ return r;
64
+ }
65
+
66
+ $('#machine-btn').onclick = async () => {
67
+ try {
68
+ dialog.showModal();
69
+ ensure();
70
+ await refresh();
71
+ const n = current && shells.includes(current) ? current : shells[0] ?? (await api('POST', '/machine/shell/new')).n;
72
+ requestAnimationFrame(() => connect(n));
73
+ } catch (err) {
74
+ dialog.close();
75
+ toast(`Error: ${err.message}`, 5000);
76
+ }
77
+ };
78
+ $('#machine-new').onclick = async () => {
79
+ const { n } = await api('POST', '/machine/shell/new');
80
+ shells = [...new Set([...shells, n])];
81
+ connect(n);
82
+ };
83
+ $('#machine-close').onclick = () => {
84
+ conn?.close();
85
+ conn = null;
86
+ dialog.close();
87
+ };
88
+ dialog.addEventListener('close', () => {
89
+ conn?.close();
90
+ conn = null;
91
+ });
92
+ $('#machine-tabs').addEventListener('click', async (e) => {
93
+ const drop = e.target.closest('[data-drop]');
94
+ if (drop) {
95
+ e.stopPropagation();
96
+ const n = Number(drop.dataset.drop);
97
+ await api('POST', '/machine/shell/drop', { n });
98
+ await refresh();
99
+ shells = shells.filter((x) => x !== n);
100
+ if (n === current) {
101
+ if (shells.length) connect(shells[0]);
102
+ else {
103
+ current = null;
104
+ conn?.close();
105
+ dialog.close();
106
+ }
107
+ } else renderTabs();
108
+ return;
109
+ }
110
+ const tab = e.target.closest('[data-n]');
111
+ if (tab && Number(tab.dataset.n) !== current) connect(Number(tab.dataset.n));
112
+ });
113
+ return {};
114
+ }
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/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;
@@ -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; }
@@ -729,3 +744,68 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
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; }
731
746
  .setup-check.fail { border-left-color: var(--red); }
747
+
748
+ /* ---------------------------------------------------------------- menu and settings */
749
+ .menu-wrap { position: relative; }
750
+ .menu-btn { display: flex; align-items: center; gap: 8px; padding: 3px 10px 3px 4px; border-radius: 999px; }
751
+ .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; }
752
+ .me-name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
753
+ .burger { font-size: 15px; line-height: 1; opacity: 0.8; }
754
+ .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; }
755
+ .menu[hidden] { display: none; }
756
+ .menu > button { text-align: left; background: none; border: none; padding: 7px 10px; border-radius: 6px; cursor: pointer; color: inherit; font: inherit; }
757
+ .menu > button:hover, .menu > button:focus-visible { background: color-mix(in srgb, var(--accent) 12%, transparent); }
758
+ .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; }
759
+ .menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
760
+ .settings-dialog .hint { margin: 0; }
761
+ .settings-dialog input.num { width: 7em; }
762
+ @media (max-width: 720px) {
763
+ .me-name { display: none; }
764
+ .menu-btn { padding: 3px 8px 3px 4px; }
765
+ }
766
+
767
+ /* ---------------------------------------------------------------- code (studio editor) */
768
+ .code-side { width: 240px; flex: 0 0 240px; display: flex; flex-direction: column; min-height: 0; background: #181818; border-right: 1px solid #2b2b2b; }
769
+ .code.no-side .code-side { display: none; }
770
+ .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; }
771
+ .code-side-bar .spacer, .code-tabs-bar .spacer { flex: 1; }
772
+ .code button { background: none; border: none; color: #cccccc; font: inherit; font-size: 12px; cursor: pointer; border-radius: 4px; padding: 3px 7px; }
773
+ .code button:hover { background: #2a2d2e; }
774
+ .code button.active { background: #37373d; color: #fff; }
775
+ .code .code-icon { padding: 3px 5px; }
776
+ .code .code-btn { border: 1px solid #3c3c3c; }
777
+ .code-tree { flex: 1; overflow: auto; padding: 4px 0; font-size: 13px; }
778
+ .code-node { padding: 2px 8px; white-space: nowrap; cursor: pointer; overflow: hidden; text-overflow: ellipsis; user-select: none; }
779
+ .code-node:hover { background: #2a2d2e; }
780
+ .code-node.selected { background: #37373d; }
781
+ .code-node.active { color: #fff; }
782
+ .code-node.dim { opacity: 0.55; }
783
+ .code-node .caret { display: inline-block; width: 14px; color: #8b8b8b; }
784
+ .code-search { display: flex; flex-direction: column; gap: 6px; padding: 6px; min-height: 0; flex: 1; }
785
+ .code-search[hidden] { display: none; }
786
+ .code-search input { background: #252526; color: #ddd; border: 1px solid #3c3c3c; border-radius: 4px; padding: 5px 7px; font-size: 13px; }
787
+ .code-hits { overflow: auto; flex: 1; font-size: 12px; }
788
+ .code-hit { padding: 4px 6px; border-radius: 4px; cursor: pointer; overflow-wrap: anywhere; }
789
+ .code-hit:hover { background: #2a2d2e; }
790
+ .code-hit div { color: #9d9d9d; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
791
+ .code-main { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; }
792
+ .code-tabs { display: flex; gap: 2px; overflow-x: auto; min-width: 0; scrollbar-width: none; }
793
+ .code .code-tab { white-space: nowrap; border-radius: 4px 4px 0 0; padding: 4px 8px; }
794
+ .code-tab .x { margin-left: 6px; opacity: 0.6; }
795
+ .code-tab .x:hover { opacity: 1; }
796
+ .code-editor { flex: 1; min-height: 0; position: relative; }
797
+ .code-editor[hidden], .code-empty[hidden] { display: none; }
798
+ .code-diff { position: absolute; inset: 0; }
799
+ .code-empty { flex: 1; display: grid; place-items: center; padding: 24px; color: #8b8b8b; text-align: center; font-size: 13px; }
800
+ .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; }
801
+ .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; }
802
+ @media (max-width: 720px) {
803
+ .code-side { position: absolute; z-index: 3; inset: 0 auto 0 0; width: min(82vw, 300px); }
804
+ }
805
+ .code { position: relative; }
806
+
807
+ /* ---------------------------------------------------------------- machine terminal */
808
+ .machine-dialog { width: min(1100px, calc(100vw - 24px)); height: min(720px, calc(100vh - 24px)); max-height: none; background: #0f1117; color: #c9d1d9; border-color: #30363d; }
809
+ .machine-dialog[open] { display: flex; flex-direction: column; }
810
+ .machine-dialog .terminal { flex: 1; min-height: 0; }
811
+ .machine-dialog #machine-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8b93a7; margin-left: 8px; }
@@ -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/cleanup.js CHANGED
@@ -5,11 +5,15 @@
5
5
  // and "off". Never touched: agents marked Keep, agents still setting up or working, members of a
6
6
  // team that is still going, and, for deleting, agents with an open pull request or with work
7
7
  // that exists only in their worktree (uncommitted changes or commits on no other branch).
8
+ // Optionally, a cap on open sessions ("maxRunning"): above it, the agents idle longest are closed
9
+ // (they resume when opened), so the machine stays responsive. The same agents are left alone,
10
+ // plus ones waiting for an answer and ones active in the last few minutes.
8
11
  import fs from 'node:fs';
9
12
  import * as git from './git.js';
10
13
 
11
14
  export const STRATEGIES = ['close-delete', 'close', 'manual', 'off'];
12
- export const CLEANUP_DEFAULTS = { strategy: 'close', closeAfterDays: 14, deleteAfterDays: 14 };
15
+ export const CLEANUP_DEFAULTS = { strategy: 'close', closeAfterDays: 14, deleteAfterDays: 14, maxRunning: null };
16
+ const CAP_GRACE_MS = 5 * 60_000;
13
17
  const DAY = 86_400_000;
14
18
  const TEAM_DONE = new Set(['done', 'stopped']);
15
19
 
@@ -24,6 +28,7 @@ export function cleanupSettings(config = {}) {
24
28
  strategy: STRATEGIES.includes(c.strategy) ? c.strategy : CLEANUP_DEFAULTS.strategy,
25
29
  closeAfterDays: n(c.closeAfterDays, CLEANUP_DEFAULTS.closeAfterDays),
26
30
  deleteAfterDays: n(c.deleteAfterDays, CLEANUP_DEFAULTS.deleteAfterDays),
31
+ maxRunning: Number(c.maxRunning) >= 1 ? Math.min(500, Math.floor(Number(c.maxRunning))) : null,
27
32
  };
28
33
  }
29
34
 
@@ -56,11 +61,70 @@ export class Cleanup {
56
61
  this.firstTimer.unref?.();
57
62
  this.timer = setInterval(() => this.sweep().catch((e) => this.log(`clean up: ${e.message}`)), (config.cleanupEveryMin ?? 360) * 60_000);
58
63
  this.timer.unref?.();
64
+ // The cap: checked soon after agents change (one starts) and every minute.
65
+ this.onChange = () => {
66
+ if (!this.settings.maxRunning || this.capSoon) return;
67
+ this.capSoon = setTimeout(() => {
68
+ this.capSoon = null;
69
+ this.enforceCap().catch((e) => this.log(`cap: ${e.message}`));
70
+ }, config.capDelayMs ?? 5000);
71
+ this.capSoon.unref?.();
72
+ };
73
+ manager.on?.('change', this.onChange);
74
+ this.capTimer = setInterval(() => this.enforceCap().catch((e) => this.log(`cap: ${e.message}`)), 60_000);
75
+ this.capTimer.unref?.();
59
76
  }
60
77
 
61
78
  close() {
62
79
  clearTimeout(this.firstTimer);
63
80
  clearInterval(this.timer);
81
+ clearTimeout(this.capSoon);
82
+ clearInterval(this.capTimer);
83
+ this.manager.off?.('change', this.onChange);
84
+ }
85
+
86
+ /** Open sessions that count toward the cap (not the assistant's). */
87
+ runningAgents() {
88
+ return this.store.list().filter((a) => a.kind !== 'assistant' && this.manager.runtimes.get(a.id)?.pty);
89
+ }
90
+
91
+ /** Which agents to close to get back under the cap: idle longest first, protected ones skipped. */
92
+ capPlan(now = Date.now()) {
93
+ const max = this.settings.maxRunning;
94
+ const running = this.runningAgents();
95
+ if (!max || running.length <= max) return { max, running: running.length, close: [] };
96
+ const activeAt = (a) => Date.parse(this.manager.activeAt(a, this.manager.runtimes.get(a.id)) ?? a.createdAt) || now;
97
+ const closable = running
98
+ .filter((a) => !this.protectedBy(a) && !this.manager.runtimes.get(a.id)?.attention && now - activeAt(a) >= (this.config.capGraceMs ?? CAP_GRACE_MS))
99
+ .sort((x, y) => activeAt(x) - activeAt(y));
100
+ return { max, running: running.length, close: closable.slice(0, running.length - max).map((a) => ({ id: a.id, name: a.name, idleMin: Math.floor((now - activeAt(a)) / 60_000) })) };
101
+ }
102
+
103
+ async enforceCap() {
104
+ if (this.capping) return null;
105
+ const plan = this.capPlan();
106
+ if (!plan.close.length) return null;
107
+ this.capping = true;
108
+ const closed = [];
109
+ try {
110
+ for (const row of plan.close) {
111
+ try {
112
+ await this.manager.stop(row.id);
113
+ this.manager.patch(row.id, { closedBy: 'cap' });
114
+ closed.push(row);
115
+ } catch (e) {
116
+ this.log(`cap: couldn't close ${row.name}: ${e.message}`);
117
+ }
118
+ }
119
+ } finally {
120
+ this.capping = false;
121
+ }
122
+ if (closed.length) {
123
+ this.log(`cap: ${plan.running} open, limit ${plan.max}; closed ${closed.map((r) => `${r.name} (idle ${r.idleMin} min)`).join(', ')}`);
124
+ this.store.data.cleanup.lastCap = { at: new Date().toISOString(), closed };
125
+ this.store.save();
126
+ }
127
+ return closed;
64
128
  }
65
129
 
66
130
  get settings() {
@@ -73,8 +137,13 @@ export class Cleanup {
73
137
  for (const k of ['closeAfterDays', 'deleteAfterDays']) {
74
138
  if (patch[k] !== undefined && !(Number(patch[k]) >= 1)) throw err(400, `${k} must be 1 or more`);
75
139
  }
140
+ // maxRunning: a number of open sessions, or empty/null/0 for no cap.
141
+ if (patch.maxRunning !== undefined && patch.maxRunning !== null && patch.maxRunning !== '' && Number(patch.maxRunning) !== 0 && !(Number(patch.maxRunning) >= 1)) {
142
+ throw err(400, 'maxRunning must be 1 or more, or empty for no cap');
143
+ }
76
144
  const next = cleanupSettings({ cleanup: { ...this.config.cleanup, ...patch } });
77
145
  this.config.cleanup = next;
146
+ if (next.maxRunning) this.onChange?.();
78
147
  return next;
79
148
  }
80
149
 
@@ -181,6 +250,6 @@ export class Cleanup {
181
250
 
182
251
  async status() {
183
252
  const s = this.settings;
184
- return { ...s, ...(await this.plan()), lastRun: this.store.data.cleanup.lastRun ?? null };
253
+ return { ...s, ...(await this.plan()), running: this.runningAgents().length, lastRun: this.store.data.cleanup.lastRun ?? null, lastCap: this.store.data.cleanup.lastCap ?? null };
185
254
  }
186
255
  }
package/src/cli.js CHANGED
@@ -1346,9 +1346,11 @@ export async function main(argv) {
1346
1346
  .option('--strategy <s>', 'close-delete (both automatically), close (close automatically, delete by hand), manual, or off')
1347
1347
  .option('--close-after <days>', 'close sessions idle this many days')
1348
1348
  .option('--delete-after <days>', 'delete agents closed this many days')
1349
+ .option('--max-open <n>', 'keep at most this many agents open; the ones idle longest are closed above it ("off": no limit)')
1349
1350
  .action(async (opts) => {
1350
1351
  await daemon();
1351
1352
  const patch = {
1353
+ ...(opts.maxOpen ? { maxRunning: /^(off|none|0)$/i.test(opts.maxOpen) ? null : Number(opts.maxOpen) } : {}),
1352
1354
  ...(opts.strategy ? { strategy: opts.strategy } : {}),
1353
1355
  ...(opts.closeAfter ? { closeAfterDays: Number(opts.closeAfter) } : {}),
1354
1356
  ...(opts.deleteAfter ? { deleteAfterDays: Number(opts.deleteAfter) } : {}),
@@ -1365,6 +1367,8 @@ export async function main(argv) {
1365
1367
  const c = await api('GET', '/cleanup');
1366
1368
  const what = { 'close-delete': 'closes and deletes automatically', close: 'closes automatically; delete with --run', manual: 'nothing automatic; use --run', off: 'off' }[c.strategy];
1367
1369
  console.log(`${bold('Clean up')}: ${c.strategy} (${what}) · close after ${c.closeAfterDays} days idle · delete ${c.deleteAfterDays} days after closing`);
1370
+ console.log(`${bold('Open agents')}: ${c.running} open · ${c.maxRunning ? `at most ${c.maxRunning} (the ones idle longest close above it)` : 'no limit (set one: --max-open <n>)'}`);
1371
+ if (c.lastCap) console.log(dim(` last closed for the limit ${new Date(c.lastCap.at).toLocaleString()}: ${c.lastCap.closed.map((x) => x.name).join(', ')}`));
1368
1372
  const rows = [
1369
1373
  ...c.close.map((x) => [yellow('close'), x.name, `idle ${x.idleDays}d`, '']),
1370
1374
  ...c.delete.map((x) => [red('delete'), x.name, `closed ${x.closedDays}d`, '']),
@@ -1691,6 +1695,19 @@ export async function main(argv) {
1691
1695
  .option('--url <url>', 'hub address')
1692
1696
  .action((ref, opts) => fleetAct(opts, ref, action, {}, (a, r) => `${action === 'start' ? 'starting' : 'stopping'} ${r.name}/${a.name}`));
1693
1697
  }
1698
+ fleetCmd
1699
+ .command('move')
1700
+ .description('Move an agent to another machine and continue it there: same conversation, branch and uncommitted work (both machines need "move")')
1701
+ .argument('<agent>', 'machine/agent (or a unique agent id or name)')
1702
+ .requiredOption('--to <machine>', 'the machine to move it to')
1703
+ .option('--url <url>', 'hub address')
1704
+ .action(async (ref, opts) => {
1705
+ const f = await hubApi(opts, 'GET', '/fleet');
1706
+ const { runner, agent } = resolveFleetAgent(f, ref);
1707
+ console.log(dim(`moving ${runner.name}/${agent.name} to ${opts.to}…`));
1708
+ const out = await hubApi(opts, 'POST', `/runners/${runner.id}/agents/${agent.id}/move`, { to: opts.to });
1709
+ console.log(`${green('✓')} ${agent.name} now runs on ${out.to.machine} as ${out.to.agent}${out.agent?.status ? ` (${out.agent.status})` : ''}`);
1710
+ });
1694
1711
  fleetCmd
1695
1712
  .command('new')
1696
1713
  .description('Start a new agent on another machine, in one of its projects (needs "create")')
@@ -2038,6 +2055,32 @@ export async function main(argv) {
2038
2055
  console.log(`${green('✓')} restarted${st.loaded ? ' (login service)' : ''}; ${agents.filter((a) => a.desired === 'running').length} agents set to resume`);
2039
2056
  });
2040
2057
 
2058
+ program
2059
+ .command('update')
2060
+ .description('Install the newest release and restart onto it (agents resume); a git checkout moves to the newest release tag')
2061
+ .option('--check', 'only say whether a newer release exists')
2062
+ .option('--auto-check <on|off>', 'whether the daemon checks for a new release twice a day (shown in the app)')
2063
+ .action(async (opts) => {
2064
+ const up = await import('./update.js');
2065
+ if (opts.autoCheck) {
2066
+ const on = /^(on|true|yes|1)$/i.test(opts.autoCheck);
2067
+ if ((await health())?.pid) await api('PUT', '/update', { check: on });
2068
+ else (await import('./config.js')).saveConfig({ updates: { check: on } });
2069
+ return console.log(`Update checks ${on ? green('on') : dim('off')}`);
2070
+ }
2071
+ const info = await up.checkUpdate();
2072
+ if (!info.newer) return console.log(`${green('✓')} ${info.current} is the newest release`);
2073
+ if (info.blocker) throw new Error(`${info.latest} is available, but: ${info.blocker}`);
2074
+ if (opts.check) return console.log(`${yellow(info.latest)} is available (you have ${info.current}); install it: agentmgr update`);
2075
+ const r = await up.installUpdate({ log: (m) => console.log(dim(m)) });
2076
+ console.log(`${green('✓')} installed ${r.to} (was ${r.from})`);
2077
+ if (!(await health())) return console.log(dim('The daemon is not running; it starts on the new version next time'));
2078
+ // The new version's own restart, not the copy of it loaded in this process.
2079
+ const { spawnSync } = await import('node:child_process');
2080
+ const res = spawnSync(process.execPath, [process.argv[1], 'restart'], { stdio: 'inherit' });
2081
+ if (res.status !== 0) process.exitCode = res.status ?? 1;
2082
+ });
2083
+
2041
2084
  program
2042
2085
  .command('shutdown')
2043
2086
  .description('Stop the daemon (running agents are closed; sessions are kept)')
package/src/fleet.js CHANGED
@@ -45,6 +45,7 @@ export function fleetAgent(a) {
45
45
  stuck: Array.isArray(a.stuck) ? a.stuck.length : num(a.stuck) ?? 0,
46
46
  conflicts: Array.isArray(a.conflicts) ? a.conflicts.length : num(a.conflicts) ?? 0,
47
47
  raceId: str(a.raceId, 20),
48
+ movedTo: str(typeof a.movedTo === 'string' ? a.movedTo : a.movedTo?.machine, 60),
48
49
  error: str(a.error, 300),
49
50
  createdAt: iso(a.createdAt),
50
51
  updatedAt: iso(a.updatedAt),
package/src/git.js CHANGED
@@ -7,7 +7,7 @@ const baseEnv = () => ({ ...process.env, GIT_TERMINAL_PROMPT: '0' });
7
7
 
8
8
  const cancelled = () => Object.assign(new Error('Cancelled'), { cancelled: true });
9
9
 
10
- export function run(cmd, args, { cwd, timeout = 60_000, signal, env } = {}) {
10
+ export function run(cmd, args, { cwd, timeout = 60_000, signal, env, trim = true } = {}) {
11
11
  return new Promise((resolve, reject) => {
12
12
  execFile(cmd, args, { cwd, timeout, signal, env: { ...baseEnv(), ...env }, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
13
13
  if (err) {
@@ -15,7 +15,7 @@ export function run(cmd, args, { cwd, timeout = 60_000, signal, env } = {}) {
15
15
  err.message = `${cmd} ${args.join(' ')} failed: ${(stderr || err.message).trim()}`;
16
16
  reject(err);
17
17
  } else {
18
- resolve(stdout.trim());
18
+ resolve(trim ? stdout.trim() : stdout);
19
19
  }
20
20
  });
21
21
  });