ai-agentmanager 1.11.1 → 1.13.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,332 @@
1
+ // The Built tab: agents made with the builder (agentkit agents, docs/agentkit.md). Each one is
2
+ // developed in an agent session working in its folder: building one starts that session, and the
3
+ // person keeps shaping the agent there. Here (and in a panel on that session) they review its
4
+ // analysis, spec and tests, test it, run it here, open its chat, package and download it.
5
+ import { attachBox } from '/attach.js';
6
+
7
+ const STATUS = { designing: 'designing…', designed: 'designed', 'needs-fixing': 'needs fixing', testing: 'testing…', tested: 'tests pass', failing: 'tests fail', running: 'running' };
8
+
9
+ const ago = (iso) => {
10
+ const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
11
+ return s < 60 ? 'just now' : s < 3600 ? `${Math.round(s / 60)} min ago` : s < 86400 ? `${Math.round(s / 3600)} h ago` : `${Math.round(s / 86400)} d ago`;
12
+ };
13
+
14
+ const sessionText = (s) => (!s ? 'no session' : s.attention ? 'session waiting for you' : s.busy ? 'session working' : s.running ? 'session idle' : 'session closed');
15
+ const tagClass = (b) => (b.status === 'tested' ? 'open' : b.status === 'failing' || b.status === 'needs-fixing' ? 'fail' : '');
16
+ const later = (a, b) => Boolean(a && b && Date.parse(a) > Date.parse(b) + 1000);
17
+
18
+ /** `openSession(id)`: show an agent session (the agents view). `onPanel()`: a session's panel has new data. */
19
+ export function initBuilt({ api, esc, toast, $, isOpen, setCount, reports = () => [], openSession = () => {}, onPanel = () => {} }) {
20
+ const view = $('#built-view');
21
+ const dlg = $('#built-dialog');
22
+ const body = $('#built-body');
23
+ let list = [];
24
+ let open = null; // the built agent shown in the dialog
25
+ let poll = null;
26
+
27
+ async function load() {
28
+ try {
29
+ list = await api('GET', '/builder');
30
+ } catch {
31
+ list = [];
32
+ }
33
+ setCount(list.length || '');
34
+ if (isOpen()) render();
35
+ }
36
+
37
+ function render() {
38
+ view.innerHTML = `<div class="built-head"><button class="small primary" data-bt="new">+ Build an agent</button><span class="hint">An agent for a task or workflow: it plans, acts, checks itself and keeps working in the background. Each is developed in its own agent session.</span></div>
39
+ ${list.length ? `<ul class="built-list">${list.map((b) => `<li data-bt-open="${esc(b.name)}"><div class="line1"><b>${esc(b.title ?? b.name)}</b>${b.running ? ' <span class="dot-run" title="Running">●</span>' : ''}<span class="spacer"></span>${b.session ? `<a href="#${esc(b.session.id)}" class="button small ghost" data-bt-session="${esc(b.session.id)}" title="Its session: where you shape it">Session${b.session.busy ? ' ●' : ''}</a>` : ''}<span class="tag ${tagClass(b)}">${esc(b.pending ?? STATUS[b.status] ?? b.status)}</span></div>
40
+ <div class="hint">${esc(b.name)} · ${esc(b.engine ?? '')} · ${sessionText(b.session)}${b.results?.total ? ` · tests ${b.results.passed}/${b.results.total}` : ''}${b.triggers.length ? ` · ${esc(b.triggers.join(', '))}` : ''}</div></li>`).join('')}</ul>` : '<p class="hint built-empty">No agents built yet.</p>'}
41
+ ${reporting()}`;
42
+ }
43
+
44
+ // ------------------------------------------------------------ agents reporting in (here or elsewhere)
45
+ function reporting() {
46
+ const rs = reports();
47
+ const head = `<div class="built-head reporting-head"><b>Reporting in</b><span class="hint">Agents running here or on other computers: their runs, cost and the tools they wait to have approved.</span><span class="spacer"></span><button class="small ghost" data-bt="connect">Connect one from elsewhere</button></div><div id="built-connect" ${connectHtml ? '' : 'hidden'}>${connectHtml ?? ''}</div>`;
48
+ if (!rs.length) return `${head}<p class="hint built-empty">None yet. Agents you start here report automatically.</p>`;
49
+ return `${head}<ul class="built-list reports">${rs.map((r) => `<li data-report="${esc(r.key)}">
50
+ <div class="line1"><span class="dot-run ${r.online ? '' : 'off'}" title="${r.online ? 'Online' : 'Not heard from'}">●</span> <b>${esc(r.title)}</b> <span class="hint">${esc(r.host)}</span><span class="spacer"></span>
51
+ ${r.chat && r.online ? `<a class="button small ghost" href="${esc(r.chat)}" target="_blank" rel="noopener">Chat ↗</a>` : ''}
52
+ <button class="small ghost" data-rp="forget" title="Remove it from this list (it shows up again when it reports)">Forget</button></div>
53
+ <div class="hint">${esc(r.engine)} · ${r.online ? (r.current ? `working on a run (${esc(r.current.source)})` : 'idle') : `last seen ${ago(r.lastSeen)}`} · ${r.totals.runs} run${r.totals.runs === 1 ? '' : 's'}${r.totals.failed ? `, ${r.totals.failed} failed` : ''}${r.totals.costUsd ? ` · $${r.totals.costUsd.toFixed(2)}` : ''}${r.triggers.length ? ` · ${esc(r.triggers.join(', '))}` : ''}</div>
54
+ ${r.pending.map((p) => `<div class="report-approval" data-ap="${esc(p.id)}"><b>Wants to run <code>${esc(p.tool)}</code></b> <span class="hint">${ago(p.at)}</span><pre>${esc(JSON.stringify(p.input, null, 2).slice(0, 1500))}</pre><button class="small primary" data-rp="approve">Approve</button> <button class="small" data-rp="deny">Deny</button></div>`).join('')}
55
+ ${r.runs.length ? `<details><summary class="hint">Recent runs</summary><ul class="built-runs">${r.runs.map((x) => `<li><span class="tag ${x.status === 'failed' ? 'fail' : x.status === 'done' ? 'open' : ''}">${esc(x.status)}</span> <span class="hint">${esc(x.source)} · ${x.at ? ago(x.at) : ''}${x.costUsd ? ` · $${x.costUsd.toFixed(3)}` : ''}${x.ms ? ` · ${Math.round(x.ms / 1000)}s` : ''}</span><div>${esc(x.result.slice(0, 400))}</div></li>`).join('')}</ul></details>` : ''}
56
+ </li>`).join('')}</ul>`;
57
+ }
58
+
59
+ let connectHtml = null; // the connection details, kept open across live re-renders
60
+ async function connect() {
61
+ const box = $('#built-connect');
62
+ if (connectHtml) return (connectHtml = null), (box.hidden = true);
63
+ try {
64
+ const c = await api('GET', '/agentkit/connect');
65
+ const lines = (url, cert) => [`AGENTKIT_REPORT_URL=${url}`, `AGENTKIT_REPORT_TOKEN=${c.token}`, ...(cert ? [`AGENTKIT_REPORT_CERT=${cert}`] : [])].join('\n');
66
+ box.innerHTML = `<p class="hint">Set these where the agent runs (in its environment, or in its service), then start it. It reports here and asks here for approvals.</p>
67
+ ${c.remote.length ? c.remote.map((u) => `<p><b>From another computer</b> <span class="hint">(over remote access)</span></p><pre class="code-area">${esc(lines(u.replace(/\/$/, ''), c.cert))}</pre>`).join('') : '<p class="hint">Turn on remote access (Settings) to connect agents on other computers.</p>'}
68
+ <p><b>On this computer</b></p><pre class="code-area">${esc(lines(c.local))}</pre>`;
69
+ } catch (err) {
70
+ box.innerHTML = `<p class="hint">${esc(err.message)}. Open AI Agent Manager on this computer to see the connection details.</p>`;
71
+ }
72
+ connectHtml = box.innerHTML;
73
+ box.hidden = false;
74
+ }
75
+
76
+ // ------------------------------------------------------------ build
77
+ const buildDlg = $('#build-dialog');
78
+ const files = attachBox({ slot: $('#build-attach'), form: $('#build-form'), paste: $('#build-form textarea'), esc, toast });
79
+ async function openBuild() {
80
+ const sel = $('#build-form').engine;
81
+ if (sel.options.length <= 1) {
82
+ const engines = await api('GET', '/engines').catch(() => []);
83
+ for (const e of engines.filter((x) => x.installed && x.id !== 'claude')) sel.append(Object.assign(document.createElement('option'), { value: e.id.startsWith('acp-') ? `acp:${e.id.slice(4)}` : e.id, textContent: e.label }));
84
+ }
85
+ $('#build-error').hidden = true;
86
+ buildDlg.showModal();
87
+ $('#build-form textarea').focus();
88
+ }
89
+ $('#build-form').addEventListener('submit', async (e) => {
90
+ e.preventDefault();
91
+ const f = e.target;
92
+ if (files.pending()) return toast('Still uploading the attached files');
93
+ const btn = f.querySelector('button[type=submit]');
94
+ btn.disabled = true;
95
+ btn.textContent = 'Starting its session…';
96
+ try {
97
+ const b = await api('POST', '/builder', { task: f.task.value, engine: f.engine.value, name: f.name.value.trim() || undefined, attachments: files.paths().length ? files.paths() : undefined });
98
+ buildDlg.close();
99
+ f.reset();
100
+ files.clear();
101
+ cache.set(b.name, b);
102
+ await load();
103
+ // Building happens in its session: go there.
104
+ if (b.session) openSession(b.session.id);
105
+ else show(b);
106
+ } catch (err) {
107
+ $('#build-error').textContent = err.message;
108
+ $('#build-error').hidden = false;
109
+ } finally {
110
+ btn.disabled = false;
111
+ btn.textContent = 'Start building';
112
+ }
113
+ });
114
+ $('#build-cancel').onclick = () => buildDlg.close();
115
+
116
+ // ------------------------------------------------------------ one agent
117
+ function show(b) {
118
+ open = b;
119
+ $('#built-title').textContent = b.title ?? b.name;
120
+ const a = b.analysis ?? {};
121
+ const li = (xs) => (xs?.length ? `<ul>${xs.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : '<p class="hint">—</p>');
122
+ const r = b.results;
123
+ const failed = (r?.list ?? []).filter((t) => t.pass === false);
124
+ body.innerHTML = `
125
+ ${sessionBlock(b)}
126
+ <div class="built-actions">
127
+ <span class="tag ${tagClass(b)}">${esc(b.pending ?? STATUS[b.status] ?? b.status)}</span>
128
+ ${b.running ? `<span class="hint">running since ${new Date(b.running.since).toLocaleTimeString()}</span>${b.running.url ? ` <a href="${esc(b.running.url)}" target="_blank" rel="noopener">Open its chat ↗</a>` : ''}` : ''}
129
+ <span class="spacer"></span>
130
+ <button class="small" data-bt="test" ${b.pending || b.specError || b.status === 'designing' ? 'disabled' : ''} title="Run each test through the real engine and judge the results">${b.pending === 'testing' ? 'Testing…' : 'Run tests'}</button>
131
+ <button class="small ${b.running ? '' : 'primary'}" data-bt="${b.running ? 'stop' : 'start'}" ${b.specError || b.status === 'designing' ? 'disabled' : ''} title="${b.running ? 'Stop it' : 'Run it continuously on this computer: its triggers, chat page and API'}">${b.running ? 'Stop' : 'Start'}</button>
132
+ <a class="button small ghost" href="/api/builder/${esc(b.name)}/download" title="Its folder (agent.yaml, instructions, tests) to run anywhere with agentkit">Download folder</a>
133
+ <button class="small ghost" data-bt="delete">Delete</button>
134
+ </div>
135
+ <div class="built-exe"><b>Single executable</b> <span class="hint">one file that runs this agent anywhere, no Node or agentkit needed (its engine's CLI must be installed and signed in)</span>
136
+ <div class="built-exe-row"><select id="built-platform">${(b.platforms ?? []).map((p) => `<option value="${esc(p)}" ${p === b.host ? 'selected' : ''}>${esc(p)}${p === b.host ? ' (this computer)' : ''}</option>`).join('')}</select>
137
+ <button class="small" data-bt="package" ${b.pending || b.specError || b.status === 'designing' ? 'disabled' : ''}>${b.pending?.startsWith('packaging') ? 'Building… (a minute or two)' : 'Build executable'}</button>
138
+ ${Object.entries(b.executables ?? {}).map(([p, x]) => `<a class="button small ghost" href="/api/builder/${esc(b.name)}/executable/${esc(p)}" title="Built ${esc(new Date(x.at).toLocaleString())}${later(b.changedAt, x.at) ? ', before its latest change: build it again' : ''}">⬇ ${esc(p)} · ${Math.round(x.size / 1e6)} MB${later(b.changedAt, x.at) ? ' (older)' : ''}</a>`).join('')}</div>
139
+ ${b.packageError ? `<p class="error">${esc(b.packageError)}</p>` : ''}</div>
140
+ ${b.specError ? `<p class="error">${esc(b.specError)}</p>` : ''}
141
+ ${b.missingEnv.length ? `<p class="hint warn">Set these in the environment before running it: ${esc(b.missingEnv.join(', '))}</p>` : ''}
142
+ <details open><summary><b>Analysis</b></summary>${b.status === 'designing' && !a.problem ? '<p class="hint">Its session is working it out; this fills in as it writes the files.</p>' : ''}<p>${esc(a.problem ?? '')}</p><div class="row three"><div><b>Inputs</b>${li(a.inputs)}</div><div><b>Outputs</b>${li(a.outputs)}</div><div><b>Risks</b>${li(a.risks)}</div></div>
143
+ ${b.tools?.length ? `<p><b>Tools</b></p><ul>${b.tools.map((t) => `<li><code>${esc(t.name)}</code> ${esc(t.why)}</li>`).join('')}</ul>` : ''}</details>
144
+ <details ${r ? 'open' : ''}><summary><b>Tests</b> ${r?.total ? `<span class="hint">${r.passed}/${r.total} pass · ${new Date(r.at).toLocaleString()}</span>` : '<span class="hint">not run yet</span>'}</summary>
145
+ ${r?.error ? `<p class="error">${esc(r.error)}</p>` : ''}
146
+ ${r?.at && later(b.changedAt, r.at) ? '<p class="hint warn">The agent changed after these results; run the tests again.</p>' : ''}
147
+ ${failed.length && b.session ? `<button class="small" data-bt="fix">Ask its session to fix the ${failed.length} failing test${failed.length > 1 ? 's' : ''}</button>` : ''}
148
+ <ol class="built-tests">${(r?.list?.length ? r.list : b.tests ?? []).map((t) => `<li class="${t.pass === true ? 'pass' : t.pass === false ? 'fail' : ''}"><div><b>${t.pass === true ? '✓' : t.pass === false ? '✗' : '·'}</b> ${esc(t.input)}</div><div class="hint">Expect: ${esc(t.expect)}</div>${t.why ? `<div class="hint">${esc(t.why)}</div>` : ''}${t.files?.length ? `<div class="hint">Wrote: ${t.files.map((f) => `<code>${esc(f)}</code>`).join(', ')}</div>` : ''}${t.result ? `<details><summary class="hint">Result</summary><pre>${esc(t.result)}</pre></details>` : ''}</li>`).join('')}</ol></details>
149
+ <details><summary><b>agent.yaml</b> and instructions</summary>
150
+ <textarea id="built-yaml" class="code-area" rows="16" spellcheck="false">${esc(b.yaml)}</textarea>
151
+ <textarea id="built-instr" class="code-area" rows="10" spellcheck="false">${esc(b.instructionsText)}</textarea>
152
+ <div class="dialog-actions"><button class="small primary" data-bt="save">Save</button></div></details>
153
+ ${b.runs.length ? `<details><summary><b>Recent runs</b></summary><ul class="built-runs">${b.runs.map((x) => `<li><span class="tag">${esc(x.status)}</span> <span class="hint">${esc(x.source ?? '')} · ${x.startedAt ? new Date(x.startedAt).toLocaleString() : ''}</span><div>${esc(String(x.result ?? '').slice(0, 300))}</div></li>`).join('')}</ul></details>` : ''}
154
+ <p class="hint">Task: ${esc(b.task ?? '')}</p>`;
155
+ if (!dlg.open) dlg.showModal();
156
+ clearInterval(poll);
157
+ if (b.pending || b.status === 'designing') poll = setInterval(refresh, 3000);
158
+ }
159
+
160
+ // Where the agent is developed: its session, and a box to ask it for a change.
161
+ function sessionBlock(b) {
162
+ if (!b.session)
163
+ return `<div class="built-session"><b>No session</b> <span class="hint">Open an agent session in its folder to keep shaping it with you.</span><span class="spacer"></span><button class="small primary" data-bt="session-new">Open a session for it</button></div>`;
164
+ return `<div class="built-session"><div class="line1"><b>Developed in its session</b> <span class="hint">${sessionText(b.session)}</span><span class="spacer"></span><a href="#${esc(b.session.id)}" class="button small primary" data-bt-session="${esc(b.session.id)}">Open session</a></div>
165
+ <div class="built-change"><textarea id="built-change" rows="2" placeholder="Ask its session for a change: “also post a summary to #support every evening”, “never reassign tickets”…"></textarea><button class="small" data-bt="change">Send</button></div></div>`;
166
+ }
167
+
168
+ function failuresText(b) {
169
+ const failed = (b.results?.list ?? []).filter((t) => t.pass === false);
170
+ return `These tests failed. Change the agent so they pass (or fix a test if its expectation is wrong), check it, and tell me what you changed.\n\n${failed.map((t, i) => `${i + 1}. Input: ${t.input}\n Expected: ${t.expect}\n Judge: ${t.why}\n Result: ${String(t.result ?? '').slice(0, 600)}`).join('\n\n')}`;
171
+ }
172
+
173
+ async function refresh() {
174
+ if (!open || !dlg.open) return clearInterval(poll);
175
+ try {
176
+ const b = await api('GET', `/builder/${open.name}`);
177
+ cache.set(b.name, b);
178
+ onPanel();
179
+ const typing = document.activeElement?.closest?.('#built-body textarea');
180
+ if (!typing && JSON.stringify(b) !== JSON.stringify(open)) show(b);
181
+ if (!b.pending && b.status !== 'designing') clearInterval(poll), load();
182
+ } catch {}
183
+ }
184
+
185
+ async function act(what) {
186
+ const name = open?.name;
187
+ try {
188
+ if (what === 'test') show(await api('POST', `/builder/${name}/test`));
189
+ else if (what === 'package') show(await api('POST', `/builder/${name}/package`, { platform: $('#built-platform').value }));
190
+ else if (what === 'start') {
191
+ show(await api('POST', `/builder/${name}/start`));
192
+ setTimeout(refresh, 1500);
193
+ } else if (what === 'stop') show(await api('POST', `/builder/${name}/stop`));
194
+ else if (what === 'change' || what === 'fix') {
195
+ const text = what === 'fix' ? failuresText(open) : $('#built-change').value.trim();
196
+ if (!text) return toast('Say what to change');
197
+ show(await api('POST', `/builder/${name}/change`, { text }));
198
+ toast('Sent to its session');
199
+ } else if (what === 'session-new') {
200
+ const b = await api('POST', `/builder/${name}/change`, {});
201
+ dlg.close();
202
+ if (b.session) openSession(b.session.id);
203
+ }
204
+ else if (what === 'save') {
205
+ show(await api('PUT', `/builder/${name}`, { yaml: $('#built-yaml').value, instructions: $('#built-instr').value }));
206
+ toast('Saved');
207
+ } else if (what === 'delete') {
208
+ if (!confirm(`Delete ${name} and its folder?`)) return;
209
+ await api('DELETE', `/builder/${name}`);
210
+ dlg.close();
211
+ }
212
+ load();
213
+ } catch (err) {
214
+ toast(`Error: ${err.message}`, 6000);
215
+ }
216
+ }
217
+
218
+ view.addEventListener('click', async (e) => {
219
+ if (e.target.closest('[data-bt="new"]')) return openBuild();
220
+ if (e.target.closest('[data-bt="connect"]')) return connect();
221
+ const rp = e.target.closest('[data-rp]');
222
+ if (rp) {
223
+ const key = rp.closest('[data-report]').dataset.report;
224
+ try {
225
+ if (rp.dataset.rp === 'forget') await api('DELETE', `/agentkit/agents/${encodeURIComponent(key)}`);
226
+ else {
227
+ rp.disabled = true;
228
+ await api('POST', `/agentkit/agents/${encodeURIComponent(key)}/approvals/${encodeURIComponent(rp.closest('[data-ap]').dataset.ap)}`, { allow: rp.dataset.rp === 'approve' });
229
+ toast(rp.dataset.rp === 'approve' ? 'Approved; the agent goes on' : 'Declined');
230
+ }
231
+ } catch (err) {
232
+ toast(`Error: ${err.message}`, 6000);
233
+ }
234
+ return;
235
+ }
236
+ const ses = e.target.closest('[data-bt-session]');
237
+ if (ses) return e.preventDefault(), openSession(ses.dataset.btSession);
238
+ const li = e.target.closest('[data-bt-open]');
239
+ if (li) show(await api('GET', `/builder/${li.dataset.btOpen}`));
240
+ });
241
+ body.addEventListener('click', (e) => {
242
+ const ses = e.target.closest('[data-bt-session]');
243
+ if (ses) return e.preventDefault(), dlg.close(), openSession(ses.dataset.btSession);
244
+ const b = e.target.closest('[data-bt]');
245
+ if (b) act(b.dataset.bt);
246
+ });
247
+ $('#built-close').onclick = () => (clearInterval(poll), dlg.close());
248
+
249
+ // ------------------------------------------------------------ the panel on its session
250
+ const cache = new Map(); // name -> its latest view (or { missing: true })
251
+ const fetching = new Set();
252
+ async function fetchOne(name) {
253
+ if (fetching.has(name)) return;
254
+ fetching.add(name);
255
+ try {
256
+ cache.set(name, await api('GET', `/builder/${encodeURIComponent(name)}`));
257
+ } catch (err) {
258
+ cache.set(name, /No built agent|Unknown agent/.test(err.message) ? { missing: true, name } : { error: err.message, name });
259
+ } finally {
260
+ fetching.delete(name);
261
+ onPanel();
262
+ }
263
+ }
264
+
265
+ /** The panel shown on a session that builds an agent (agent `a`, with a.builds). */
266
+ function panel(a) {
267
+ const name = a.builds.name;
268
+ const b = cache.get(name);
269
+ if (!b) {
270
+ fetchOne(name);
271
+ return `<div class="built-panel"><b>⚙ Builds the agent ${esc(name)}</b> <span class="hint">loading…</span></div>`;
272
+ }
273
+ if (b.error) return `<div class="built-panel"><b>⚙ Builds the agent ${esc(name)}</b> <span class="hint">· ${esc(b.error)}</span></div>`;
274
+ if (b.missing) return `<div class="built-panel"><b>⚙ Built the agent ${esc(name)}</b> <span class="hint">· deleted from the Built tab</span></div>`;
275
+ const r = b.results;
276
+ const failed = (r?.list ?? []).filter((t) => t.pass === false);
277
+ const stale = r?.at && later(b.changedAt, r.at);
278
+ return `<div class="built-panel" data-bp-name="${esc(name)}">
279
+ <div class="line1"><b>⚙ ${esc(b.title ?? name)}</b> <span class="tag ${tagClass(b)}">${esc(b.pending ?? STATUS[b.status] ?? b.status)}</span>
280
+ <span class="hint">${esc(b.engine ?? '')}${b.triggers.length ? ` · ${esc(b.triggers.join(', '))}` : ''}${r?.total ? ` · tests ${r.passed}/${r.total}${stale ? ' (before the latest change)' : ''}` : b.tests.length ? ` · ${b.tests.length} tests, not run` : ''}${b.running ? ' · running here' : ''}</span>
281
+ <span class="spacer"></span>
282
+ <button class="small" data-bp="test" ${b.pending || b.specError || b.status === 'designing' ? 'disabled' : ''} title="Run each test through the real engine and judge the results">${b.pending === 'testing' ? 'Testing…' : 'Run tests'}</button>
283
+ <button class="small" data-bp="${b.running ? 'stop' : 'start'}" ${b.specError || b.status === 'designing' ? 'disabled' : ''} title="${b.running ? 'Stop it' : 'Run it continuously on this computer: its triggers, chat page and API'}">${b.running ? 'Stop' : 'Start'}</button>
284
+ ${b.running?.url ? `<a class="button small ghost" href="${esc(b.running.url)}" target="_blank" rel="noopener">Chat ↗</a>` : ''}
285
+ <button class="small ghost" data-bp="open" title="Analysis, spec, tests, executables, downloads">Details</button></div>
286
+ ${b.specError ? `<p class="error">${esc(b.specError)}</p>` : ''}
287
+ ${b.missingEnv.length ? `<p class="hint warn">Needs in the environment before it runs: ${esc(b.missingEnv.join(', '))}</p>` : ''}
288
+ ${b.description ? `<div class="hint">${esc(b.description)}</div>` : ''}
289
+ ${failed.length && !stale ? `<div class="hint">Failing: ${failed.map((t) => esc(t.input.slice(0, 60))).join(' · ')} <button class="small" data-bp="fix">Ask this session to fix them</button></div>` : ''}
290
+ </div>`;
291
+ }
292
+
293
+ document.addEventListener('click', async (e) => {
294
+ const el = e.target.closest('[data-bp]');
295
+ if (!el) return;
296
+ const name = el.closest('[data-bp-name]').dataset.bpName;
297
+ try {
298
+ if (el.dataset.bp === 'open') return show(await api('GET', `/builder/${name}`));
299
+ if (el.dataset.bp === 'fix') {
300
+ await api('POST', `/builder/${name}/change`, { text: failuresText(cache.get(name)) });
301
+ return toast('Sent to this session');
302
+ }
303
+ el.disabled = true;
304
+ cache.set(name, await api('POST', `/builder/${name}/${el.dataset.bp}`));
305
+ onPanel();
306
+ // Tests and starting finish in the background; follow them.
307
+ const follow = setInterval(async () => {
308
+ await fetchOne(name);
309
+ const b = cache.get(name);
310
+ if (!b || b.missing || (!b.pending && (el.dataset.bp !== 'start' || b.running?.url || !b.running))) clearInterval(follow), load();
311
+ }, 2000);
312
+ } catch (err) {
313
+ toast(`Error: ${err.message}`, 6000);
314
+ }
315
+ });
316
+
317
+ // Its session changed (it edits the files as it goes): fetch the agent again, a moment later.
318
+ const timers = new Map();
319
+ function sessionChanged(a) {
320
+ const name = a.builds?.name;
321
+ if (!name) return;
322
+ clearTimeout(timers.get(name));
323
+ timers.set(name, setTimeout(() => {
324
+ fetchOne(name);
325
+ if (open?.name === name && dlg.open) refresh();
326
+ if (isOpen()) load();
327
+ }, 1000));
328
+ }
329
+
330
+ load();
331
+ return { load, render, panel, sessionChanged, openAgent: async (name) => show(await api('GET', `/builder/${name}`)) };
332
+ }
package/public/index.html CHANGED
@@ -64,6 +64,7 @@
64
64
  <button class="side-tab" data-tab="inbox" role="tab" title="Everything waiting on you: prompts, questions, stuck agents, overlaps">Inbox <span id="inbox-count" class="badge"></span></button>
65
65
  <button class="side-tab" data-tab="backlog" role="tab">Backlog <span id="backlog-count" class="badge"></span></button>
66
66
  <button class="side-tab" data-tab="land" role="tab" title="Agents' open pull requests: what each needs to get merged">Land <span id="land-count" class="badge"></span></button>
67
+ <button class="side-tab" data-tab="built" role="tab" title="Agents you build for a task or workflow: they plan, act, check themselves and run in the background">Built <span id="built-count" class="badge"></span></button>
67
68
  </div>
68
69
  <div id="agents-view">
69
70
  <div class="counts" id="counts"></div>
@@ -79,6 +80,7 @@
79
80
  <div id="backlog-view" hidden></div>
80
81
  <div id="inbox-view" hidden></div>
81
82
  <div id="land-view" hidden></div>
83
+ <div id="built-view" hidden></div>
82
84
  <div id="empty" class="empty" hidden>
83
85
  <p>No agents yet.</p>
84
86
  <p>Add one or more intents and a repo or folder to get started.</p>
@@ -377,6 +379,26 @@
377
379
  </form>
378
380
  </dialog>
379
381
 
382
+ <dialog id="build-dialog">
383
+ <form id="build-form" method="dialog">
384
+ <h2>Build an agent</h2>
385
+ <p class="hint">Describe the task or workflow it takes over: what comes in, what it should do, what it must never do. An agent session works it out with you in the agent's own folder (analysis, spec, instructions, test cases); you keep shaping it there, then test, run and package it.</p>
386
+ <label>Task or workflow
387
+ <textarea name="task" rows="8" required placeholder="Every morning, look at new support tickets in Jira, label each by product area, route it to the right team, and draft a first reply for a person to send. Never close or reply to tickets on its own."></textarea>
388
+ </label>
389
+ <div id="build-attach"></div>
390
+ <div class="row"><label>Engine it runs on <select name="engine"><option value="claude">Claude Code</option></select></label>
391
+ <label>Name <input name="name" placeholder="from the task" pattern="[a-z][a-z0-9-]{1,40}" title="lowercase letters, digits and dashes"></label></div>
392
+ <p id="build-error" class="error" hidden></p>
393
+ <div class="dialog-actions"><button type="button" id="build-cancel" class="ghost">Cancel</button><button type="submit" class="primary">Start building</button></div>
394
+ </form>
395
+ </dialog>
396
+
397
+ <dialog id="built-dialog" class="doc-dialog">
398
+ <div class="doc-head"><h2 id="built-title"></h2><span class="spacer"></span><button type="button" id="built-close" class="small ghost">Close</button></div>
399
+ <div id="built-body" class="built-body"></div>
400
+ </dialog>
401
+
380
402
  <dialog id="doc-dialog" class="doc-dialog">
381
403
  <div class="doc-head">
382
404
  <h2 id="doc-title"></h2>
package/public/style.css CHANGED
@@ -497,6 +497,7 @@ dialog::backdrop { background: rgba(0, 0, 0, .45); }
497
497
  .need-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; }
498
498
  .need-kind { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
499
499
  .need.prompt .need-kind { color: var(--red); }
500
+ .need.approval .need-kind { color: var(--red); }
500
501
  .need.question .need-kind { color: var(--accent); }
501
502
  .need.stuck .need-kind { color: var(--yellow); }
502
503
  .need.conflict .need-kind { color: #8e59ff; }
@@ -949,3 +950,32 @@ body.remote .settings-links .local-only { display: none; }
949
950
  .clip-box { margin: 10px 12px; width: calc(100% - 24px); font: 13px var(--mono, ui-monospace, monospace); }
950
951
  .clip-foot { display: flex; align-items: center; gap: 8px; padding: 0 12px 12px; }
951
952
  .clip-foot .hint { flex: 1; }
953
+
954
+ /* Built agents. */
955
+ .built-head { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; }
956
+ .built-list { list-style: none; margin: 0; padding: 0; }
957
+ .built-list li { padding: 9px 12px; border-bottom: 1px solid var(--border); cursor: pointer; }
958
+ .built-list li:hover { background: var(--panel-2); }
959
+ .built-list .line1 { display: flex; align-items: center; gap: 6px; }
960
+ .built-empty { padding: 0 12px; }
961
+ .dot-run { color: var(--green); font-size: 10px; }
962
+ .dot-run.off { color: var(--muted); }
963
+ .reporting-head { margin-top: 18px; }
964
+ .report-approval { margin: 8px 0; padding: 8px 10px; border: 1px solid var(--yellow); border-radius: 8px; }
965
+ .report-approval pre, .need-input { font-size: 12px; white-space: pre-wrap; max-height: 160px; overflow: auto; margin: 6px 0; }
966
+ #built-connect { padding: 0 12px; } #built-connect pre { white-space: pre-wrap; word-break: break-all; padding: 8px; border: 1px solid var(--border); border-radius: 6px; }
967
+ .built-body { padding: 0 4px 12px; display: flex; flex-direction: column; gap: 10px; }
968
+ .built-body details > summary { cursor: pointer; padding: 4px 0; }
969
+ .built-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
970
+ .built-exe { margin: 10px 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; }
971
+ .built-session, .built-panel { margin: 0 0 10px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel, transparent); }
972
+ .built-session .line1, .built-panel .line1 { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
973
+ .built-panel { margin: 10px 0; }
974
+ .built-panel p { margin: 6px 0 0; }
975
+ .built-change { display: flex; gap: 6px; margin-top: 6px; align-items: flex-end; }
976
+ .built-change textarea { flex: 1; min-width: 0; }
977
+ .built-exe-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
978
+ .built-tests li { margin-bottom: 8px; } .built-tests li.pass b { color: var(--green); } .built-tests li.fail b { color: var(--red); }
979
+ .built-tests pre, .built-runs div { white-space: pre-wrap; font-size: 12.5px; }
980
+ .code-area { width: 100%; font: 12.5px/1.45 var(--mono); margin-top: 6px; }
981
+ .built-runs { list-style: none; padding: 0; } .built-runs li { padding: 6px 0; border-bottom: 1px solid var(--border); }
@@ -0,0 +1,171 @@
1
+ // The agent at work: each input becomes a run of plan → act → check (→ act again on what's left)
2
+ // → reflect, through the engine (engines.js), one run at a time. Runs are logged as JSONL; memory
3
+ // carries lessons between runs; a conversation (a chat, an A2A context) keeps its engine session.
4
+ import crypto from 'node:crypto';
5
+ import { EventEmitter } from 'node:events';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { Approvals } from './approvals.js';
9
+ import { agentPaths } from './spec.js';
10
+ import { APPROVER, approverServer, turn, writeMcpConfig } from './engines.js';
11
+
12
+ const now = () => new Date().toISOString();
13
+ const readJson = (f, d) => {
14
+ try {
15
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
16
+ } catch {
17
+ return d;
18
+ }
19
+ };
20
+
21
+ /** The check step's verdict, from a reply that should hold {"done": …, "remaining": …}. */
22
+ export function verdictOf(text) {
23
+ const m = /\{[\s\S]*\}/.exec(String(text ?? ''));
24
+ try {
25
+ const j = JSON.parse(m?.[0] ?? '');
26
+ return { done: j.done === true, remaining: String(j.remaining ?? '').trim() };
27
+ } catch {
28
+ // No JSON: read "done" / "not done" in plain words, else assume done (don't loop on noise).
29
+ return { done: !/\bnot (yet )?(done|met|complete)\b/i.test(String(text)), remaining: '' };
30
+ }
31
+ }
32
+
33
+ export class Agent extends EventEmitter {
34
+ /** `approvals`: 'ask' (a person decides approval tools, where the engine can ask) or 'deny'. */
35
+ constructor(spec, { env = process.env, engineTurn = turn, approvals = 'ask' } = {}) {
36
+ super();
37
+ this.spec = spec;
38
+ this.env = env;
39
+ this.engineTurn = engineTurn;
40
+ this.paths = agentPaths(spec);
41
+ fs.mkdirSync(this.paths.runs, { recursive: true });
42
+ this.mcpFile = writeMcpConfig(spec, this.paths.mcp);
43
+ this.approvals = new Approvals(spec, { timeoutMs: spec.approveTimeoutMs ?? 30 * 60_000 });
44
+ this.approvals.on('change', () => this.emit('approvals', this.approvals.pending()));
45
+ // Only Claude can ask mid-run (the others get approval tools taken away instead).
46
+ this.asks = approvals === 'ask' && spec.engine === 'claude' && spec.approve.length > 0;
47
+ this.approverReady = false;
48
+ this.sessions = readJson(this.paths.sessions, {});
49
+ this.queue = Promise.resolve();
50
+ this.current = null;
51
+ }
52
+
53
+ memory() {
54
+ try {
55
+ return fs.readFileSync(this.paths.memory, 'utf8').trim();
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ system() {
62
+ const s = this.spec;
63
+ return [
64
+ `You are "${s.title}", an autonomous agent that works on its own, without a person watching.`,
65
+ `Your goal:\n${s.goal}`,
66
+ s.instructions ? `How to do the job:\n${s.instructions}` : '',
67
+ s.memory && this.memory() ? `What you learned in earlier runs (keep using it):\n${this.memory().slice(-6000)}` : '',
68
+ 'Work in your folder with your tools. Never invent results: if you could not do something, say so plainly.',
69
+ ]
70
+ .filter(Boolean)
71
+ .join('\n\n');
72
+ }
73
+
74
+ /** Queue a run; resolves with its result. `conversation` continues an engine session. */
75
+ run(input, { source = 'api', conversation = null } = {}) {
76
+ const job = this.queue.then(() => this.doRun(String(input ?? ''), { source, conversation }));
77
+ this.queue = job.catch(() => {});
78
+ return job;
79
+ }
80
+
81
+ async doRun(input, { source, conversation }) {
82
+ const id = `${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
83
+ const file = path.join(this.paths.runs, `${id}.jsonl`);
84
+ const t0 = Date.now();
85
+ let cost = 0;
86
+ const log = (rec) => {
87
+ const r = { t: now(), ...rec };
88
+ fs.appendFileSync(file, JSON.stringify(r) + '\n');
89
+ this.emit('step', { run: id, ...r });
90
+ };
91
+ this.current = { id, source, startedAt: now() };
92
+ log({ type: 'start', input, source, conversation });
93
+ let session = conversation ? this.sessions[conversation] ?? null : null;
94
+ if (this.asks && !this.approverReady) {
95
+ const url = await this.approvals.address();
96
+ this.mcpFile = writeMcpConfig(this.spec, this.paths.mcp, { [APPROVER]: approverServer({ url, token: this.approvals.token }) });
97
+ this.approverReady = true;
98
+ }
99
+ this.approvals.run = id;
100
+ const ask = async (step, prompt) => {
101
+ const res = await this.engineTurn(this.spec, { prompt, system: this.system(), session, mcpFile: this.mcpFile, approver: this.asks, env: this.env });
102
+ session = res.session ?? session;
103
+ if (res.costUsd) cost += res.costUsd;
104
+ log({ type: step, text: res.text });
105
+ if (this.spec.loop.budgetUsd && cost > this.spec.loop.budgetUsd) throw new Error(`Over the run budget ($${cost.toFixed(2)} of $${this.spec.loop.budgetUsd})`);
106
+ return res.text;
107
+ };
108
+ let result = '';
109
+ let status = 'done';
110
+ try {
111
+ // Someone asked (chat, API, A2A, ACP, MCP, the command line), or a trigger fired ("every 10m", …).
112
+ const from = ['chat', 'api', 'a2a', 'acp', 'mcp', 'cli'].includes(source) ? 'a request' : `a trigger (${source})`;
113
+ // The goal goes in every step, not only the system prompt: the engine's own framing ("a coding
114
+ // agent") must not turn an input into a different job than this agent's.
115
+ const goal = `Your goal (this is your whole job): ${this.spec.goal}`;
116
+ const given = `New input, from ${from}:\n${input || '(none: do your scheduled work)'}`;
117
+ if (this.spec.loop.plan) await ask('plan', `${goal}\n\n${given}\n\nWrite a short numbered plan to achieve your goal for this input. Treat the input as material for your goal, not as a new assignment. Don't act yet.`);
118
+ result = await ask('act', this.spec.loop.plan ? `Carry out the plan now. ${goal}\nWhen you are done, reply with the result your goal calls for.` : `${goal}\n\n${given}\n\nTreat the input as material for your goal, not as a new assignment. Do what your goal calls for and reply with the result.`);
119
+ for (let step = 1; this.spec.loop.check && step < this.spec.loop.maxSteps; step++) {
120
+ const v = verdictOf(await ask('check', `Check your last result against your goal. ${goal}\nReply with JSON only: {"done": true|false, "remaining": "<what is still missing for the goal, if anything>"}.`));
121
+ if (v.done) break;
122
+ result = await ask('act', `Not done yet. Continue with what is left: ${v.remaining || 'finish the remaining work'}. Then reply with the full result.`);
123
+ if (step === this.spec.loop.maxSteps - 1) status = 'partial';
124
+ }
125
+ if (this.spec.memory) {
126
+ const lesson = (await ask('reflect', 'In at most two short bullet points, what should you remember for next time (about this kind of input, the tools, or the people)? Reply "nothing" if there is nothing new.')).trim();
127
+ if (lesson && !/^nothing\.?$/i.test(lesson)) fs.appendFileSync(this.paths.memory, `\n## ${now().slice(0, 10)}\n${lesson}\n`);
128
+ }
129
+ } catch (err) {
130
+ status = 'failed';
131
+ result = result || `The run failed: ${err.message}`;
132
+ log({ type: 'error', text: err.message });
133
+ }
134
+ if (conversation && session) {
135
+ this.sessions[conversation] = session;
136
+ fs.writeFileSync(this.paths.sessions, JSON.stringify(this.sessions, null, 2));
137
+ }
138
+ const summary = { id, status, result, source, conversation, costUsd: cost ? Math.round(cost * 1e6) / 1e6 : null, ms: Date.now() - t0 };
139
+ log({ type: 'end', ...summary });
140
+ this.current = null;
141
+ this.approvals.run = null;
142
+ this.emit('run', summary);
143
+ return summary;
144
+ }
145
+
146
+ close() {
147
+ this.approvals.close();
148
+ }
149
+
150
+ /** Recent runs, newest first: id, status, source, start time, result (from each log). */
151
+ runs(limit = 50) {
152
+ let names = [];
153
+ try {
154
+ names = fs.readdirSync(this.paths.runs).filter((n) => n.endsWith('.jsonl')).sort().reverse().slice(0, limit);
155
+ } catch {}
156
+ return names.map((n) => this.runLog(n.slice(0, -6))).filter(Boolean).map((steps) => {
157
+ const start = steps.find((s) => s.type === 'start');
158
+ const end = steps.find((s) => s.type === 'end');
159
+ return { id: start?.id ?? end?.id, startedAt: start?.t, source: start?.source, input: start?.input, status: end?.status ?? 'running', result: end?.result ?? null, costUsd: end?.costUsd ?? null };
160
+ }).map((r, i) => ({ ...r, id: r.id ?? names[i].slice(0, -6) }));
161
+ }
162
+
163
+ runLog(id) {
164
+ if (!/^[\w-]+$/.test(id)) return null;
165
+ try {
166
+ return fs.readFileSync(path.join(this.paths.runs, `${id}.jsonl`), 'utf8').trim().split('\n').map((l) => JSON.parse(l));
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+ }