ai-agentmanager 1.3.0 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +23 -5
- package/docs/distributed.md +2 -1
- package/docs/plugins.md +2 -1
- package/package.json +1 -1
- package/public/app.js +107 -4
- package/public/code.js +449 -0
- package/public/hub/hub.js +33 -5
- package/public/index.html +62 -6
- package/public/machine.js +114 -0
- package/public/menu.js +92 -0
- package/public/style.css +80 -0
- package/public/update.js +88 -0
- package/src/cleanup.js +71 -2
- package/src/cli.js +74 -12
- package/src/client.js +46 -6
- package/src/fleet.js +1 -0
- package/src/git.js +2 -2
- package/src/handoff.js +240 -0
- package/src/hub.js +50 -2
- package/src/hubactions.js +14 -1
- package/src/identity.js +41 -0
- package/src/manager.js +25 -17
- package/src/server.js +50 -9
- package/src/studio.js +255 -0
- package/src/update.js +160 -0
|
@@ -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; }
|
package/public/update.js
ADDED
|
@@ -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
|
}
|