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
package/public/code.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// Code: an agent's files in the app. A file tree, tabs, and Monaco (the editor in VS Code) loaded
|
|
2
|
+
// from a CDN the first time; a plain text editor when that can't load. Save with Ctrl/Cmd+S
|
|
3
|
+
// (refused if the agent changed the file meanwhile, unless you overwrite), search the folder,
|
|
4
|
+
// compare a file with where the agent started, and open the folder in VS Code or Cursor.
|
|
5
|
+
|
|
6
|
+
const MONACO = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min';
|
|
7
|
+
|
|
8
|
+
let monacoReady = null;
|
|
9
|
+
function loadMonaco() {
|
|
10
|
+
monacoReady ??= new Promise((resolve, reject) => {
|
|
11
|
+
window.MonacoEnvironment = {
|
|
12
|
+
// Workers from another origin start through a same-origin data: URL.
|
|
13
|
+
getWorkerUrl: () =>
|
|
14
|
+
`data:text/javascript;charset=utf-8,${encodeURIComponent(`self.MonacoEnvironment={baseUrl:'${MONACO}/'};importScripts('${MONACO}/vs/base/worker/workerMain.js');`)}`,
|
|
15
|
+
};
|
|
16
|
+
const s = document.createElement('script');
|
|
17
|
+
s.src = `${MONACO}/vs/loader.js`;
|
|
18
|
+
s.onload = () => {
|
|
19
|
+
window.require.config({ paths: { vs: `${MONACO}/vs` } });
|
|
20
|
+
window.require(['vs/editor/editor.main'], () => resolve(window.monaco), reject);
|
|
21
|
+
};
|
|
22
|
+
s.onerror = () => reject(new Error('Monaco could not load'));
|
|
23
|
+
document.head.appendChild(s);
|
|
24
|
+
setTimeout(() => reject(new Error('Monaco took too long to load')), 20_000);
|
|
25
|
+
});
|
|
26
|
+
return monacoReady;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const icon = (e) => (e.type === 'dir' ? '▸' : '·');
|
|
30
|
+
const q = (s) => encodeURIComponent(s);
|
|
31
|
+
|
|
32
|
+
export function initCode({ api, esc, toast, $, agents, isOpen }) {
|
|
33
|
+
const root = $('#code');
|
|
34
|
+
root.innerHTML = `
|
|
35
|
+
<aside class="code-side">
|
|
36
|
+
<div class="code-side-bar">
|
|
37
|
+
<button class="code-mode active" data-mode="files" title="Files">Files</button>
|
|
38
|
+
<button class="code-mode" data-mode="search" title="Search in the folder">Search</button>
|
|
39
|
+
<span class="spacer"></span>
|
|
40
|
+
<button class="code-icon" data-act="new-file" title="New file (in the selected folder)">+</button>
|
|
41
|
+
<button class="code-icon" data-act="new-dir" title="New folder">▣</button>
|
|
42
|
+
<button class="code-icon" data-act="rename" title="Rename the selected item">✎</button>
|
|
43
|
+
<button class="code-icon" data-act="delete" title="Delete the selected item">🗑</button>
|
|
44
|
+
<button class="code-icon" data-act="refresh" title="Reload the tree">⟳</button>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="code-tree" role="tree"></div>
|
|
47
|
+
<form class="code-search" hidden>
|
|
48
|
+
<input name="q" placeholder="Search text in files" autocomplete="off" spellcheck="false" />
|
|
49
|
+
<div class="code-hits"></div>
|
|
50
|
+
</form>
|
|
51
|
+
</aside>
|
|
52
|
+
<section class="code-main">
|
|
53
|
+
<div class="code-tabs-bar">
|
|
54
|
+
<button class="code-icon code-toggle-side" title="Show or hide the files">☰</button>
|
|
55
|
+
<div class="code-tabs" role="tablist"></div>
|
|
56
|
+
<span class="spacer"></span>
|
|
57
|
+
<button class="code-btn" data-act="diff" title="Compare with where the agent started">Diff</button>
|
|
58
|
+
<button class="code-btn" data-act="save" title="Save (Ctrl/Cmd+S)">Save</button>
|
|
59
|
+
<button class="code-btn local-only" data-act="vscode" title="Open the agent's folder in VS Code on this computer">VS Code</button>
|
|
60
|
+
<button class="code-btn local-only" data-act="cursor" title="Open the agent's folder in Cursor on this computer" hidden>Cursor</button>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="code-editor"></div>
|
|
63
|
+
<div class="code-empty">Pick a file on the left. Edits are saved into the agent's folder; tell the agent if it should know.</div>
|
|
64
|
+
<div class="code-status"></div>
|
|
65
|
+
</section>`;
|
|
66
|
+
const tree = root.querySelector('.code-tree');
|
|
67
|
+
const tabsEl = root.querySelector('.code-tabs');
|
|
68
|
+
const host = root.querySelector('.code-editor');
|
|
69
|
+
const empty = root.querySelector('.code-empty');
|
|
70
|
+
const status = root.querySelector('.code-status');
|
|
71
|
+
const searchForm = root.querySelector('.code-search');
|
|
72
|
+
|
|
73
|
+
// Per agent: open files, the active one, expanded folders, the selected tree item.
|
|
74
|
+
const states = new Map();
|
|
75
|
+
let agentId = null;
|
|
76
|
+
let monaco = null;
|
|
77
|
+
let editor = null;
|
|
78
|
+
let diffEditor = null;
|
|
79
|
+
let plain = null; // fallback <textarea>
|
|
80
|
+
let editorsChecked = false;
|
|
81
|
+
|
|
82
|
+
const st = () => {
|
|
83
|
+
if (!states.has(agentId)) states.set(agentId, { files: new Map(), active: null, expanded: new Set(['']), dirs: new Map(), selected: '', diff: false });
|
|
84
|
+
return states.get(agentId);
|
|
85
|
+
};
|
|
86
|
+
const say = (text) => (status.textContent = text);
|
|
87
|
+
|
|
88
|
+
// ------------------------------------------------------------- editor
|
|
89
|
+
async function ensureEditor() {
|
|
90
|
+
if (editor || plain) return;
|
|
91
|
+
try {
|
|
92
|
+
monaco = await loadMonaco();
|
|
93
|
+
editor = monaco.editor.create(host, { theme: 'vs-dark', automaticLayout: true, minimap: { enabled: false }, fontSize: 13, scrollBeyondLastLine: false, tabSize: 2 });
|
|
94
|
+
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => save());
|
|
95
|
+
editor.onDidChangeCursorPosition((e) => {
|
|
96
|
+
const f = activeFile();
|
|
97
|
+
if (f) say(`${f.path} · Ln ${e.position.lineNumber}, Col ${e.position.column}${f.language ? ` · ${f.language}` : ''}`);
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
plain = document.createElement('textarea');
|
|
101
|
+
plain.className = 'code-plain';
|
|
102
|
+
plain.spellcheck = false;
|
|
103
|
+
host.appendChild(plain);
|
|
104
|
+
plain.addEventListener('input', () => {
|
|
105
|
+
const f = activeFile();
|
|
106
|
+
if (f) {
|
|
107
|
+
f.text = plain.value;
|
|
108
|
+
markDirty(f);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
plain.addEventListener('keydown', (e) => {
|
|
112
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
save();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
toast(`${err.message}; using a plain editor`, 4000);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const activeFile = () => {
|
|
122
|
+
const s = agentId && st();
|
|
123
|
+
return s?.active ? s.files.get(s.active) : null;
|
|
124
|
+
};
|
|
125
|
+
const isDirty = (f) => (f.model ? f.model.getAlternativeVersionId() !== f.savedVersion : f.text !== f.saved);
|
|
126
|
+
function markDirty() {
|
|
127
|
+
renderTabs();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function openFile(path, { line } = {}) {
|
|
131
|
+
const s = st();
|
|
132
|
+
await ensureEditor();
|
|
133
|
+
let f = s.files.get(path);
|
|
134
|
+
if (!f) {
|
|
135
|
+
const r = await api('GET', `/agents/${agentId}/file?path=${q(path)}`);
|
|
136
|
+
if (r.binary || r.tooLarge) {
|
|
137
|
+
toast(r.binary ? `${path} is a binary file` : `${path} is too large to edit here (${Math.round(r.size / 1e6)} MB)`, 4000);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
f = { path, language: r.language, mtimeMs: r.mtimeMs, saved: r.content, text: r.content };
|
|
141
|
+
if (monaco) {
|
|
142
|
+
f.model = monaco.editor.createModel(r.content, r.language, monaco.Uri.parse(`file:///${agentId}/${path}`));
|
|
143
|
+
f.savedVersion = f.model.getAlternativeVersionId();
|
|
144
|
+
f.model.onDidChangeContent(() => markDirty(f));
|
|
145
|
+
}
|
|
146
|
+
s.files.set(path, f);
|
|
147
|
+
}
|
|
148
|
+
s.active = path;
|
|
149
|
+
s.selected = path;
|
|
150
|
+
show(f);
|
|
151
|
+
if (line && editor) {
|
|
152
|
+
editor.revealLineInCenter(line);
|
|
153
|
+
editor.setPosition({ lineNumber: line, column: 1 });
|
|
154
|
+
}
|
|
155
|
+
renderTabs();
|
|
156
|
+
renderTree();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function show(f) {
|
|
160
|
+
const s = st();
|
|
161
|
+
empty.hidden = Boolean(f);
|
|
162
|
+
host.hidden = !f;
|
|
163
|
+
if (!f) return;
|
|
164
|
+
if (s.diff && monaco) return showDiff(f);
|
|
165
|
+
if (diffEditor) {
|
|
166
|
+
diffEditor.dispose();
|
|
167
|
+
diffEditor = null;
|
|
168
|
+
host.querySelector('.code-diff')?.remove();
|
|
169
|
+
}
|
|
170
|
+
if (editor) {
|
|
171
|
+
editor.getDomNode().style.display = '';
|
|
172
|
+
editor.setModel(f.model);
|
|
173
|
+
editor.focus();
|
|
174
|
+
} else if (plain) {
|
|
175
|
+
plain.value = f.text;
|
|
176
|
+
plain.focus();
|
|
177
|
+
}
|
|
178
|
+
say(`${f.path}${f.language ? ` · ${f.language}` : ''}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function showDiff(f) {
|
|
182
|
+
const r = await api('GET', `/agents/${agentId}/file/original?path=${q(f.path)}`);
|
|
183
|
+
if (editor) editor.getDomNode().style.display = 'none';
|
|
184
|
+
diffEditor?.dispose();
|
|
185
|
+
host.querySelector('.code-diff')?.remove();
|
|
186
|
+
const box = document.createElement('div');
|
|
187
|
+
box.className = 'code-diff';
|
|
188
|
+
host.appendChild(box);
|
|
189
|
+
diffEditor = monaco.editor.createDiffEditor(box, { theme: 'vs-dark', automaticLayout: true, renderSideBySide: host.clientWidth > 700, originalEditable: false });
|
|
190
|
+
const original = monaco.editor.createModel(r.content ?? '', f.language);
|
|
191
|
+
diffEditor.setModel({ original, modified: f.model });
|
|
192
|
+
say(r.new ? `${f.path} is new since ${r.base}` : `${f.path}: ${r.base} (left) and now (right); edit on the right`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function save({ force = false } = {}) {
|
|
196
|
+
const f = activeFile();
|
|
197
|
+
if (!f) return;
|
|
198
|
+
const content = f.model ? f.model.getValue() : f.text;
|
|
199
|
+
try {
|
|
200
|
+
const r = await api('PUT', `/agents/${agentId}/file`, { path: f.path, content, mtimeMs: f.mtimeMs, force });
|
|
201
|
+
f.mtimeMs = r.mtimeMs;
|
|
202
|
+
f.saved = content;
|
|
203
|
+
if (f.model) f.savedVersion = f.model.getAlternativeVersionId();
|
|
204
|
+
renderTabs();
|
|
205
|
+
say(`Saved ${f.path}`);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (/changed on disk/.test(err.message)) {
|
|
208
|
+
if (confirm(`${f.path} changed on disk since you opened it (the agent may have edited it).\n\nOK: overwrite it with your version.\nCancel: keep the file as it is (reload it to see the change).`)) return save({ force: true });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
toast(`Couldn't save: ${err.message}`, 6000);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Pick up the agent's edits to open files you haven't changed. */
|
|
216
|
+
async function syncFromDisk() {
|
|
217
|
+
if (!agentId || !isOpen()) return;
|
|
218
|
+
for (const f of st().files.values()) {
|
|
219
|
+
if (isDirty(f)) continue;
|
|
220
|
+
const r = await api('GET', `/agents/${agentId}/file?path=${q(f.path)}`).catch(() => null);
|
|
221
|
+
if (!r || r.content == null || r.mtimeMs === f.mtimeMs) continue;
|
|
222
|
+
f.mtimeMs = r.mtimeMs;
|
|
223
|
+
f.saved = r.content;
|
|
224
|
+
if (f.model) {
|
|
225
|
+
f.model.setValue(r.content);
|
|
226
|
+
f.savedVersion = f.model.getAlternativeVersionId();
|
|
227
|
+
} else f.text = r.content;
|
|
228
|
+
if (f === activeFile() && plain) plain.value = r.content;
|
|
229
|
+
}
|
|
230
|
+
renderTabs();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function closeFile(path) {
|
|
234
|
+
const s = st();
|
|
235
|
+
const f = s.files.get(path);
|
|
236
|
+
if (f && isDirty(f) && !confirm(`Close ${path} without saving your changes?`)) return;
|
|
237
|
+
f?.model?.dispose();
|
|
238
|
+
s.files.delete(path);
|
|
239
|
+
if (s.active === path) s.active = [...s.files.keys()].pop() ?? null;
|
|
240
|
+
show(activeFile());
|
|
241
|
+
renderTabs();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderTabs() {
|
|
245
|
+
const s = st();
|
|
246
|
+
tabsEl.innerHTML = [...s.files.values()]
|
|
247
|
+
.map(
|
|
248
|
+
(f) => `<button class="code-tab ${f.path === s.active ? 'active' : ''}" data-path="${esc(f.path)}" title="${esc(f.path)}">${isDirty(f) ? '● ' : ''}${esc(f.path.split('/').pop())}<span class="x" data-close="${esc(f.path)}">×</span></button>`,
|
|
249
|
+
)
|
|
250
|
+
.join('');
|
|
251
|
+
root.querySelector('[data-act="diff"]').classList.toggle('active', s.diff);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ------------------------------------------------------------- tree
|
|
255
|
+
async function loadDir(dir) {
|
|
256
|
+
const r = await api('GET', `/agents/${agentId}/files?dir=${q(dir)}`);
|
|
257
|
+
st().dirs.set(dir, r.entries);
|
|
258
|
+
return r;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderTree() {
|
|
262
|
+
const s = st();
|
|
263
|
+
const rows = [];
|
|
264
|
+
const walk = (dir, depth) => {
|
|
265
|
+
for (const e of s.dirs.get(dir) ?? []) {
|
|
266
|
+
const open = e.type === 'dir' && s.expanded.has(e.path);
|
|
267
|
+
rows.push(
|
|
268
|
+
`<div class="code-node ${e.hidden ? 'dim' : ''} ${s.selected === e.path ? 'selected' : ''} ${e.path === s.active ? 'active' : ''}" role="treeitem" data-path="${esc(e.path)}" data-type="${e.type}" style="padding-left:${8 + depth * 14}px">` +
|
|
269
|
+
`<span class="caret">${e.type === 'dir' ? (open ? '▾' : '▸') : ''}</span>${esc(e.name)}</div>`,
|
|
270
|
+
);
|
|
271
|
+
if (open) walk(e.path, depth + 1);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
walk('', 0);
|
|
275
|
+
tree.innerHTML = rows.join('') || '<p class="hint" style="padding:8px">Empty folder</p>';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function toggleDir(path) {
|
|
279
|
+
const s = st();
|
|
280
|
+
if (s.expanded.has(path)) s.expanded.delete(path);
|
|
281
|
+
else {
|
|
282
|
+
s.expanded.add(path);
|
|
283
|
+
if (!s.dirs.has(path)) await loadDir(path);
|
|
284
|
+
}
|
|
285
|
+
renderTree();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function refreshTree() {
|
|
289
|
+
const s = st();
|
|
290
|
+
s.dirs.clear();
|
|
291
|
+
for (const dir of [...s.expanded]) await loadDir(dir).catch(() => s.expanded.delete(dir));
|
|
292
|
+
renderTree();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The folder new items go in: the selected folder, or the selected file's folder. */
|
|
296
|
+
const targetDir = () => {
|
|
297
|
+
const s = st();
|
|
298
|
+
const sel = s.selected;
|
|
299
|
+
if (!sel) return '';
|
|
300
|
+
const isDir = [...s.dirs.values()].flat().find((e) => e.path === sel)?.type === 'dir';
|
|
301
|
+
return isDir ? sel : sel.split('/').slice(0, -1).join('/');
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
async function act(what) {
|
|
305
|
+
const s = st();
|
|
306
|
+
try {
|
|
307
|
+
if (what === 'refresh') return refreshTree();
|
|
308
|
+
if (what === 'new-file' || what === 'new-dir') {
|
|
309
|
+
const dir = targetDir();
|
|
310
|
+
const name = prompt(`New ${what === 'new-dir' ? 'folder' : 'file'} in ${dir || 'the agent folder'}:`, '');
|
|
311
|
+
if (!name?.trim()) return;
|
|
312
|
+
const path = [dir, name.trim()].filter(Boolean).join('/');
|
|
313
|
+
await api('POST', `/agents/${agentId}/files`, { path, type: what === 'new-dir' ? 'dir' : 'file' });
|
|
314
|
+
if (dir) s.expanded.add(dir);
|
|
315
|
+
await refreshTree();
|
|
316
|
+
if (what === 'new-file') await openFile(path);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!s.selected) return toast('Select a file or folder first');
|
|
320
|
+
if (what === 'rename') {
|
|
321
|
+
const to = prompt('Rename to (path in the agent folder):', s.selected);
|
|
322
|
+
if (!to?.trim() || to.trim() === s.selected) return;
|
|
323
|
+
await api('POST', `/agents/${agentId}/files/rename`, { from: s.selected, to: to.trim() });
|
|
324
|
+
const f = s.files.get(s.selected);
|
|
325
|
+
if (f) closeFile(s.selected);
|
|
326
|
+
s.selected = to.trim();
|
|
327
|
+
await refreshTree();
|
|
328
|
+
if (f) await openFile(to.trim());
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (what === 'delete') {
|
|
332
|
+
if (!confirm(`Delete ${s.selected}? It is removed from the agent's folder (git still has committed versions).`)) return;
|
|
333
|
+
await api('DELETE', `/agents/${agentId}/file?path=${q(s.selected)}`);
|
|
334
|
+
for (const p of [...s.files.keys()]) if (p === s.selected || p.startsWith(`${s.selected}/`)) {
|
|
335
|
+
s.files.get(p)?.model?.dispose();
|
|
336
|
+
s.files.delete(p);
|
|
337
|
+
}
|
|
338
|
+
if (s.active && !s.files.has(s.active)) s.active = [...s.files.keys()].pop() ?? null;
|
|
339
|
+
s.selected = '';
|
|
340
|
+
show(activeFile());
|
|
341
|
+
renderTabs();
|
|
342
|
+
return refreshTree();
|
|
343
|
+
}
|
|
344
|
+
if (what === 'save') return save();
|
|
345
|
+
if (what === 'diff') {
|
|
346
|
+
if (!monaco) return toast('Diff needs the full editor (Monaco), which could not load');
|
|
347
|
+
s.diff = !s.diff;
|
|
348
|
+
renderTabs();
|
|
349
|
+
return show(activeFile());
|
|
350
|
+
}
|
|
351
|
+
if (what === 'vscode' || what === 'cursor') {
|
|
352
|
+
const r = await api('POST', `/agents/${agentId}/open-in`, { editor: what, path: s.active ?? null });
|
|
353
|
+
return toast(`Opened in ${r.editor}`);
|
|
354
|
+
}
|
|
355
|
+
} catch (err) {
|
|
356
|
+
toast(`Error: ${err.message}`, 5000);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ------------------------------------------------------------- search
|
|
361
|
+
async function search(text) {
|
|
362
|
+
const hits = root.querySelector('.code-hits');
|
|
363
|
+
if (text.trim().length < 2) return void (hits.innerHTML = '');
|
|
364
|
+
hits.innerHTML = '<p class="hint">Searching…</p>';
|
|
365
|
+
try {
|
|
366
|
+
const r = await api('GET', `/agents/${agentId}/search?q=${q(text)}`);
|
|
367
|
+
hits.innerHTML =
|
|
368
|
+
r.hits
|
|
369
|
+
.map((h) => `<div class="code-hit" data-path="${esc(h.path)}" data-line="${h.line}"><b>${esc(h.path)}</b>:${h.line}<div>${esc(h.text.trim())}</div></div>`)
|
|
370
|
+
.join('') + (r.truncated ? '<p class="hint">More matches not shown; search for something longer.</p>' : r.hits.length ? '' : '<p class="hint">No matches.</p>');
|
|
371
|
+
} catch (err) {
|
|
372
|
+
hits.innerHTML = `<p class="hint">${esc(err.message)}</p>`;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ------------------------------------------------------------- events
|
|
377
|
+
root.addEventListener('click', (e) => {
|
|
378
|
+
const mode = e.target.closest('.code-mode');
|
|
379
|
+
if (mode) {
|
|
380
|
+
for (const b of root.querySelectorAll('.code-mode')) b.classList.toggle('active', b === mode);
|
|
381
|
+
tree.hidden = mode.dataset.mode !== 'files';
|
|
382
|
+
searchForm.hidden = mode.dataset.mode !== 'search';
|
|
383
|
+
if (!searchForm.hidden) searchForm.q.focus();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (e.target.closest('.code-toggle-side')) return root.classList.toggle('no-side');
|
|
387
|
+
const x = e.target.closest('[data-close]');
|
|
388
|
+
if (x) {
|
|
389
|
+
e.stopPropagation();
|
|
390
|
+
return closeFile(x.dataset.close);
|
|
391
|
+
}
|
|
392
|
+
const tab = e.target.closest('.code-tab');
|
|
393
|
+
if (tab) return openFile(tab.dataset.path);
|
|
394
|
+
const hit = e.target.closest('.code-hit');
|
|
395
|
+
if (hit) return openFile(hit.dataset.path, { line: Number(hit.dataset.line) });
|
|
396
|
+
const node = e.target.closest('.code-node');
|
|
397
|
+
if (node) {
|
|
398
|
+
st().selected = node.dataset.path;
|
|
399
|
+
if (node.dataset.type === 'dir') return toggleDir(node.dataset.path);
|
|
400
|
+
if (window.innerWidth < 720) root.classList.add('no-side');
|
|
401
|
+
return openFile(node.dataset.path).catch((err) => toast(`Error: ${err.message}`, 5000));
|
|
402
|
+
}
|
|
403
|
+
const b = e.target.closest('[data-act]');
|
|
404
|
+
if (b) return act(b.dataset.act);
|
|
405
|
+
});
|
|
406
|
+
let searchTimer = null;
|
|
407
|
+
searchForm.addEventListener('submit', (e) => {
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
search(searchForm.q.value);
|
|
410
|
+
});
|
|
411
|
+
searchForm.q.addEventListener('input', () => {
|
|
412
|
+
clearTimeout(searchTimer);
|
|
413
|
+
searchTimer = setTimeout(() => search(searchForm.q.value), 350);
|
|
414
|
+
});
|
|
415
|
+
window.addEventListener('focus', () => syncFromDisk().catch(() => {}));
|
|
416
|
+
setInterval(() => document.visibilityState === 'visible' && syncFromDisk().catch(() => {}), 5000);
|
|
417
|
+
window.addEventListener('beforeunload', (e) => {
|
|
418
|
+
for (const s of states.values()) for (const f of s.files.values()) if (isDirty(f)) return void e.preventDefault();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
async function open(id) {
|
|
422
|
+
agentId = id;
|
|
423
|
+
const s = st();
|
|
424
|
+
if (!editorsChecked && !document.body.classList.contains('remote')) {
|
|
425
|
+
editorsChecked = true;
|
|
426
|
+
api('GET', '/editors')
|
|
427
|
+
.then((list) => {
|
|
428
|
+
root.querySelector('[data-act="vscode"]').hidden = !list.some((x) => x.id === 'vscode');
|
|
429
|
+
root.querySelector('[data-act="cursor"]').hidden = !list.some((x) => x.id === 'cursor');
|
|
430
|
+
})
|
|
431
|
+
.catch(() => {});
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
if (!s.dirs.has('')) await loadDir('');
|
|
435
|
+
renderTree();
|
|
436
|
+
renderTabs();
|
|
437
|
+
show(activeFile());
|
|
438
|
+
if (!activeFile()) {
|
|
439
|
+
empty.hidden = false;
|
|
440
|
+
host.hidden = true;
|
|
441
|
+
}
|
|
442
|
+
say(agents.get(id)?.workdir ?? '');
|
|
443
|
+
} catch (err) {
|
|
444
|
+
tree.innerHTML = `<p class="hint" style="padding:8px">${esc(err.message)}</p>`;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return { open, save };
|
|
449
|
+
}
|
package/public/hub/hub.js
CHANGED
|
@@ -631,14 +631,42 @@ function agentActions(a, runner) {
|
|
|
631
631
|
'div',
|
|
632
632
|
{ class: 'actions' },
|
|
633
633
|
btn('Message', () => (state.open.has(tellKey) ? state.open.delete(tellKey) : state.open.add(tellKey), render(true), document.querySelector(`[data-draft="${CSS.escape(`${tellKey}:text`)}"]`)?.focus()), { title: 'Type a message into its session (sent when it is idle; resumes it if closed)' }),
|
|
634
|
-
|
|
635
|
-
?
|
|
636
|
-
:
|
|
637
|
-
? btn('
|
|
638
|
-
:
|
|
634
|
+
a.movedTo
|
|
635
|
+
? el('span', { class: 'hint' }, `Moved to ${a.movedTo}`)
|
|
636
|
+
: running
|
|
637
|
+
? btn('Stop', () => act(r, 'stop', { agent: a.id }, { key: key('stop'), done: `Stopping ${a.name}; its session is kept` }), { key: key('stop'), cls: 'ghost' })
|
|
638
|
+
: a.status !== 'preparing'
|
|
639
|
+
? btn('Start', () => act(r, 'start', { agent: a.id }, { key: key('start'), done: `Starting ${a.name}` }), { key: key('start') })
|
|
640
|
+
: null,
|
|
641
|
+
!a.movedTo && a.kind === 'task' && a.status !== 'preparing' && allows(runner, 'move') && state.fleet.runners.some((x) => x.id !== r && allows(x, 'move'))
|
|
642
|
+
? btn('Move to…', () => moveAgent(a, runner), { key: key('move'), cls: 'ghost', title: 'Continue it on another machine: same conversation, branch and uncommitted work (its work goes through the repo\'s remote)' })
|
|
643
|
+
: null,
|
|
639
644
|
);
|
|
640
645
|
}
|
|
641
646
|
|
|
647
|
+
/** Move an agent to another machine that allows it; the hub reports when it runs there. */
|
|
648
|
+
async function moveAgent(a, runner) {
|
|
649
|
+
const targets = state.fleet.runners.filter((x) => x.id !== runner.id && allows(x, 'move'));
|
|
650
|
+
const name = targets.length === 1 ? targets[0].name : prompt(`Move ${a.name} to which machine?\n${targets.map((t) => `• ${t.name}`).join('\n')}`, targets[0]?.name ?? '');
|
|
651
|
+
if (!name) return;
|
|
652
|
+
const to = targets.find((t) => t.name.toLowerCase() === name.trim().toLowerCase());
|
|
653
|
+
if (!to) return toast(`No machine "${name}" that allows moves`, true);
|
|
654
|
+
if (!confirm(`Move ${a.name} from ${runner.name} to ${to.name}?\n\nIts session closes here, its uncommitted work goes to ${to.name} through the repo's remote, and it continues there with the same conversation.`)) return;
|
|
655
|
+
const key = `${runner.id}:${a.id}:move`;
|
|
656
|
+
busy.add(key);
|
|
657
|
+
render(true);
|
|
658
|
+
toast(`Moving ${a.name} to ${to.name}…`);
|
|
659
|
+
try {
|
|
660
|
+
const out = await call('POST', `/runners/${encodeURIComponent(runner.id)}/agents/${encodeURIComponent(a.id)}/move`, { to: to.id });
|
|
661
|
+
toast(`${a.name} now runs on ${out.to.machine}`);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
toast(`Couldn't move ${a.name}: ${err.message}`, true);
|
|
664
|
+
} finally {
|
|
665
|
+
busy.delete(key);
|
|
666
|
+
render(true);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
642
670
|
function tellForm(a) {
|
|
643
671
|
const tellKey = `tell:${a.runnerId}:${a.id}`;
|
|
644
672
|
const dk = `${tellKey}:text`;
|
package/public/index.html
CHANGED
|
@@ -15,20 +15,36 @@
|
|
|
15
15
|
<body>
|
|
16
16
|
<header class="topbar">
|
|
17
17
|
<div class="brand">
|
|
18
|
-
<span class="logo" aria-hidden="true">◆</span>
|
|
19
|
-
<h1>AI Agent Manager</h1>
|
|
18
|
+
<a href="#" id="home-link" class="home-link" title="Home: all agents"><span class="logo" aria-hidden="true">◆</span><h1>AI Agent Manager</h1></a>
|
|
20
19
|
<button id="about-btn" class="about-btn" type="button" title="About AI Agent Manager: version, release, links">About</button>
|
|
20
|
+
<button id="update-btn" class="update-btn local-only" type="button" hidden></button>
|
|
21
21
|
<span id="conn" class="conn" title="Daemon connection">connecting…</span>
|
|
22
22
|
</div>
|
|
23
23
|
<div class="top-actions">
|
|
24
24
|
<input id="filter" type="search" placeholder="Filter agents" aria-label="Filter agents" />
|
|
25
25
|
<button id="assistant-btn" title="An always-on session that finds new work and starts agents for it">Assistant <span id="assistant-dot" class="assistant-dot" hidden></span></button>
|
|
26
26
|
<button id="spend-btn" title="Results across agents (merge rate, time to PR, cost per merged PR), what they cost, and spending limits">Results <span id="spend-today" class="spend-today"></span></button>
|
|
27
|
-
<button id="
|
|
28
|
-
<button id="alerts-btn" class="local-only" title="Slack alerts when an agent needs you">Alerts</button>
|
|
29
|
-
<button id="remote-btn" class="local-only" title="Open AI Agent Manager from your phone">Remote access</button>
|
|
27
|
+
<button id="machine-btn" title="A terminal on this computer (your home folder), from any device you're signed in on">Terminal</button>
|
|
30
28
|
<button id="ask-btn" title="A general Claude session with no repo or folder">Ask Claude</button>
|
|
31
29
|
<button id="new-btn" class="primary">+ New agents</button>
|
|
30
|
+
<div class="menu-wrap">
|
|
31
|
+
<button id="menu-btn" class="menu-btn" type="button" aria-haspopup="menu" aria-expanded="false" title="Settings and preferences">
|
|
32
|
+
<span id="me-avatar" class="avatar" aria-hidden="true">·</span><span id="me-name" class="me-name"></span><span class="burger" aria-hidden="true">☰</span>
|
|
33
|
+
</button>
|
|
34
|
+
<div id="menu" class="menu" role="menu" hidden>
|
|
35
|
+
<div class="menu-me">
|
|
36
|
+
<b id="me-display"></b>
|
|
37
|
+
<span id="me-email" class="hint"></span>
|
|
38
|
+
<span id="me-where" class="hint"></span>
|
|
39
|
+
</div>
|
|
40
|
+
<button role="menuitem" id="settings-btn">Settings…</button>
|
|
41
|
+
<button role="menuitem" id="cleanup-btn" title="Close agents idle for a while, and delete old closed ones">Clean up…</button>
|
|
42
|
+
<button role="menuitem" id="alerts-btn" class="local-only" title="Slack alerts when an agent needs you">Alerts…</button>
|
|
43
|
+
<button role="menuitem" id="remote-btn" class="local-only" title="Open AI Agent Manager from your phone">Remote access…</button>
|
|
44
|
+
<div class="menu-sep"></div>
|
|
45
|
+
<button role="menuitem" id="menu-about">About and updates…</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
32
48
|
</div>
|
|
33
49
|
</header>
|
|
34
50
|
|
|
@@ -69,6 +85,7 @@
|
|
|
69
85
|
<div class="term-bar">
|
|
70
86
|
<div class="term-tabs" role="tablist">
|
|
71
87
|
<button class="term-tab" data-kind="claude" role="tab">Claude</button>
|
|
88
|
+
<button class="term-tab" data-kind="code" role="tab" title="The agent's files: browse, edit, search and compare (Monaco, the editor in VS Code)">Code</button>
|
|
72
89
|
<span id="shell-tabs" class="term-tabs"></span>
|
|
73
90
|
<button class="term-tab term-add" id="shell-new" title="Open another shell in the agent's folder">+ Shell</button>
|
|
74
91
|
<span id="browser-tabs" class="term-tabs"></span>
|
|
@@ -85,6 +102,7 @@
|
|
|
85
102
|
</div>
|
|
86
103
|
<div id="signin-bar" class="signin-bar" hidden></div>
|
|
87
104
|
<div id="terminal" class="terminal"></div>
|
|
105
|
+
<div id="code" class="code"></div>
|
|
88
106
|
<div id="browser" class="browser">
|
|
89
107
|
<form id="browser-bar" class="browser-bar" autocomplete="off">
|
|
90
108
|
<button type="button" id="browser-back" class="ghost small live-only" title="Back">◀</button>
|
|
@@ -639,11 +657,49 @@
|
|
|
639
657
|
<dl id="about-list" class="about-list"></dl>
|
|
640
658
|
<p id="about-update" class="hint" hidden></p>
|
|
641
659
|
<div class="dialog-actions">
|
|
642
|
-
<
|
|
660
|
+
<label class="about-auto local-only" title="Twice a day, ask for the newest release (GitHub for a git checkout, npm otherwise)"><input type="checkbox" id="about-auto" /> Check automatically</label>
|
|
661
|
+
<button type="button" id="about-check" class="ghost" title="Asks for the newest release">Check for updates</button>
|
|
662
|
+
<button type="button" id="about-install" class="primary local-only" hidden>Update and restart</button>
|
|
643
663
|
<button type="button" id="about-close" class="primary">Close</button>
|
|
644
664
|
</div>
|
|
645
665
|
</dialog>
|
|
646
666
|
|
|
667
|
+
<dialog id="settings-dialog" class="settings-dialog">
|
|
668
|
+
<form id="settings-form" method="dialog" class="alerts-body">
|
|
669
|
+
<h2>Settings</h2>
|
|
670
|
+
<fieldset>
|
|
671
|
+
<legend>Open agents</legend>
|
|
672
|
+
<label class="check">Keep at most <input type="number" name="maxRunning" min="1" max="500" placeholder="no limit" class="num" /> agents open at once</label>
|
|
673
|
+
<p class="hint">Above it, the agents idle longest are closed (they resume when you open them), so the machine stays responsive. Never closed: agents working, waiting for your answer, active in the last 5 minutes, marked Keep, or in a team that is still going. <span id="settings-running"></span></p>
|
|
674
|
+
</fieldset>
|
|
675
|
+
<fieldset class="local-only">
|
|
676
|
+
<legend>Updates</legend>
|
|
677
|
+
<label class="check"><input type="checkbox" name="updateCheck" /> Check for a new release twice a day</label>
|
|
678
|
+
<p class="hint">Shows <b>Update to …</b> in the top bar; one click installs it and restarts, and running agents resume.</p>
|
|
679
|
+
</fieldset>
|
|
680
|
+
<fieldset>
|
|
681
|
+
<legend>More</legend>
|
|
682
|
+
<p class="hint settings-links"><button type="button" class="link" data-open="cleanup-btn">Clean up old agents</button> · <button type="button" class="link" data-open="spend-btn">Spend and budgets</button> · <button type="button" class="link local-only" data-open="alerts-btn">Slack alerts</button> · <button type="button" class="link local-only" data-open="remote-btn">Remote access</button></p>
|
|
683
|
+
</fieldset>
|
|
684
|
+
<p id="settings-error" class="error" hidden></p>
|
|
685
|
+
<div class="dialog-actions">
|
|
686
|
+
<button type="button" id="settings-cancel" class="ghost">Cancel</button>
|
|
687
|
+
<button type="submit" class="primary">Save</button>
|
|
688
|
+
</div>
|
|
689
|
+
</form>
|
|
690
|
+
</dialog>
|
|
691
|
+
|
|
692
|
+
<dialog id="machine-dialog" class="machine-dialog">
|
|
693
|
+
<div class="term-bar">
|
|
694
|
+
<div class="term-tabs" id="machine-tabs" role="tablist"></div>
|
|
695
|
+
<button class="term-tab term-add" id="machine-new" title="Open another terminal">+ Terminal</button>
|
|
696
|
+
<span id="machine-title" class="hint"></span>
|
|
697
|
+
<span class="spacer"></span>
|
|
698
|
+
<button id="machine-close" class="ghost small" title="Hide (the shells keep running)">Hide</button>
|
|
699
|
+
</div>
|
|
700
|
+
<div id="machine-term" class="terminal"></div>
|
|
701
|
+
</dialog>
|
|
702
|
+
|
|
647
703
|
<script type="module" src="/app.js"></script>
|
|
648
704
|
</body>
|
|
649
705
|
</html>
|