agentainer 0.1.7 → 2.0.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.
Files changed (44) hide show
  1. package/README.md +248 -677
  2. package/agentainer +16 -18
  3. package/agentainer.example.yaml +86 -0
  4. package/bin/agentainer.js +9 -8
  5. package/examples/brainstorm.yaml +27 -128
  6. package/examples/bug-hunt.yaml +51 -96
  7. package/examples/code-review.yaml +73 -0
  8. package/examples/debate.yaml +16 -90
  9. package/examples/incident-response.yaml +52 -109
  10. package/examples/localization.yaml +56 -123
  11. package/examples/quickstart.yaml +48 -0
  12. package/examples/research.yaml +25 -0
  13. package/examples/software-company.yaml +71 -128
  14. package/examples/tdd-pingpong.yaml +36 -68
  15. package/examples/writers-room.yaml +49 -111
  16. package/hooks/claude_stop.sh +5 -3
  17. package/hooks/codex_notify.sh +4 -3
  18. package/lib/cli.py +929 -0
  19. package/lib/config.py +247 -305
  20. package/lib/hooks.py +246 -0
  21. package/lib/lock.py +75 -0
  22. package/lib/log.py +64 -0
  23. package/lib/mail.py +634 -0
  24. package/lib/minyaml.py +1 -39
  25. package/lib/reconcile.py +473 -0
  26. package/lib/sessions.py +223 -0
  27. package/lib/supervisor.py +216 -0
  28. package/lib/telegram.py +372 -0
  29. package/lib/tmux.py +355 -0
  30. package/lib/turn.py +159 -0
  31. package/lib/ui.py +1020 -0
  32. package/llms.txt +145 -429
  33. package/package.json +9 -7
  34. package/scripts/check-deps.js +18 -61
  35. package/ui/app.js +869 -0
  36. package/ui/index.html +348 -0
  37. package/agents.example.yaml +0 -257
  38. package/examples/code-review-broadcast.yaml +0 -109
  39. package/examples/existing-repo.yaml +0 -74
  40. package/examples/multi-language-broadcast.yaml +0 -127
  41. package/examples/ping-pong.yaml +0 -89
  42. package/examples/red-team.yaml +0 -117
  43. package/examples/research-swarm.yaml +0 -129
  44. package/lib/swarm.py +0 -2461
package/ui/app.js ADDED
@@ -0,0 +1,869 @@
1
+ "use strict";
2
+ // Agentainer UI -- vanilla JS, no framework, no build step, no CDN.
3
+ // A mobile-friendly mail app over the file-based mailroom: browse agents, read
4
+ // each agent's correspondence as threads, reply as the user, watch the tmux
5
+ // pane / type straight into it, and edit agentainer.yaml (settings + agents).
6
+ // The token rides on every request via ?token= (simplest, works everywhere).
7
+
8
+ (function () {
9
+ const $ = (id) => document.getElementById(id);
10
+ const el = (html) => { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstElementChild; };
11
+
12
+ let TOKEN = "";
13
+ const state = {
14
+ view: "agents", // agents | mail | settings
15
+ status: null, // last /api/status
16
+ agent: null, // open agent (mail view)
17
+ peer: null, // open contact
18
+ contacts: [],
19
+ thread: [],
20
+ tab: "mail", // mail | terminal (agent view)
21
+ config: null, // last /api/config
22
+ telegram: null, // last /api/telegram
23
+ wrap: (() => { try { return !!localStorage.getItem("paneWrap"); } catch (_) { return false; } })(),
24
+ };
25
+ const timers = {};
26
+
27
+ // ---- tiny utilities ----------------------------------------------------
28
+
29
+ function esc(s) {
30
+ return String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
31
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
32
+ }
33
+ function initials(name) {
34
+ if (name === "user") return "You";
35
+ return String(name).slice(0, 2).toUpperCase();
36
+ }
37
+ function hueFor(name) {
38
+ let h = 0;
39
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
40
+ return h;
41
+ }
42
+ function avatar(name, cls) {
43
+ const special = name === "user" ? 215 : name === "system" ? 0 : hueFor(name);
44
+ const sat = name === "system" ? "0%" : "62%";
45
+ return `<span class="avatar ${cls || ""}" style="background:hsl(${special} ${sat} 48%)">${esc(initials(name))}</span>`;
46
+ }
47
+ function fmtTime(iso) {
48
+ if (!iso) return "";
49
+ const d = new Date(iso);
50
+ if (isNaN(d)) return "";
51
+ const now = new Date();
52
+ const sameDay = d.toDateString() === now.toDateString();
53
+ return sameDay
54
+ ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
55
+ : d.toLocaleDateString([], { month: "short", day: "numeric" });
56
+ }
57
+ // Minimal, dependency-free, XSS-safe markdown -> HTML. Everything is escaped
58
+ // first (so agent/user text can never inject markup); we then emit only our
59
+ // own tags, and only allow http(s)/mailto links. Good enough for mail bodies.
60
+ function md(src) {
61
+ const lines = esc(src).replace(/\r/g, "").split("\n");
62
+ const inline = (t) => t
63
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
64
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
65
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
66
+ .replace(/~~([^~]+)~~/g, "<del>$1</del>")
67
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
68
+ let html = "", i = 0;
69
+ const isBlock = (l) => /^\s*(```|#{1,6}\s|[-*+]\s|\d+\.\s|&gt;)/.test(l);
70
+ while (i < lines.length) {
71
+ const line = lines[i];
72
+ if (/^\s*```/.test(line)) {
73
+ const buf = []; i++;
74
+ while (i < lines.length && !/^\s*```/.test(lines[i])) buf.push(lines[i++]);
75
+ i++;
76
+ html += `<pre class="md-pre"><code>${buf.join("\n")}</code></pre>`;
77
+ continue;
78
+ }
79
+ const h = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
80
+ if (h) { html += `<h${h[1].length} class="md-h">${inline(h[2])}</h${h[1].length}>`; i++; continue; }
81
+ if (/^\s*[-*+]\s+/.test(line)) {
82
+ const items = [];
83
+ while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) items.push(inline(lines[i++].replace(/^\s*[-*+]\s+/, "")));
84
+ html += "<ul>" + items.map((x) => `<li>${x}</li>`).join("") + "</ul>";
85
+ continue;
86
+ }
87
+ if (/^\s*\d+\.\s+/.test(line)) {
88
+ const items = [];
89
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(inline(lines[i++].replace(/^\s*\d+\.\s+/, "")));
90
+ html += "<ol>" + items.map((x) => `<li>${x}</li>`).join("") + "</ol>";
91
+ continue;
92
+ }
93
+ if (/^\s*&gt;\s?/.test(line)) {
94
+ const buf = [];
95
+ while (i < lines.length && /^\s*&gt;\s?/.test(lines[i])) buf.push(inline(lines[i++].replace(/^\s*&gt;\s?/, "")));
96
+ html += `<blockquote>${buf.join("<br>")}</blockquote>`;
97
+ continue;
98
+ }
99
+ if (/^\s*$/.test(line)) { i++; continue; }
100
+ const buf = [];
101
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && !isBlock(lines[i])) buf.push(inline(lines[i++]));
102
+ html += `<p>${buf.join("<br>")}</p>`;
103
+ }
104
+ return html;
105
+ }
106
+
107
+ function clearTimers() { Object.values(timers).forEach(clearInterval); Object.keys(timers).forEach((k) => delete timers[k]); }
108
+ function banner(msg) { $("banner").textContent = msg || ""; }
109
+ let toastT = null;
110
+ function toast(msg) {
111
+ const t = $("toast"); t.textContent = msg; t.classList.add("show");
112
+ clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove("show"), 2200);
113
+ }
114
+
115
+ // ---- API ---------------------------------------------------------------
116
+
117
+ function withToken(path) { return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN); }
118
+ function apiGet(path) {
119
+ return fetch(withToken(path), { headers: { Accept: "application/json" } }).then((r) => {
120
+ if (r.status === 401) throw new Error("unauthorized");
121
+ return r.json();
122
+ });
123
+ }
124
+ function apiPost(path, body) {
125
+ return fetch(withToken(path), {
126
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}),
127
+ }).then((r) => r.json().then((j) => ({ ok: r.ok, status: r.status, j })).catch(() => ({ ok: r.ok, status: r.status, j: {} })));
128
+ }
129
+
130
+ // ---- shell / navigation ------------------------------------------------
131
+
132
+ function connect() {
133
+ TOKEN = $("token").value.trim();
134
+ if (!TOKEN) { banner("enter a token first"); return; }
135
+ apiGet("/api/status").then((data) => {
136
+ state.status = data;
137
+ $("login").hidden = true;
138
+ $("view").hidden = false;
139
+ $("nav").hidden = false;
140
+ $("availWrap").hidden = false;
141
+ banner("");
142
+ syncAvailability(data.user_available);
143
+ go("agents");
144
+ }).catch((e) => banner("connect failed: " + e.message));
145
+ }
146
+
147
+ function syncAvailability(available) {
148
+ $("availToggle").checked = !!available;
149
+ $("availLbl").textContent = available ? "You: available" : "You: away";
150
+ }
151
+
152
+ function go(view) {
153
+ state.view = view;
154
+ clearTimers();
155
+ const nav = view === "settings" ? "settings" : view === "activity" ? "activity" : "agents";
156
+ for (const b of document.querySelectorAll(".navbtn"))
157
+ b.classList.toggle("active", b.dataset.view === nav);
158
+ if (view === "agents") renderAgents();
159
+ else if (view === "mail") renderMail();
160
+ else if (view === "activity") renderActivity();
161
+ else if (view === "settings") renderSettings();
162
+ }
163
+
164
+ // ---- agents overview ---------------------------------------------------
165
+
166
+ function statusPills(a) {
167
+ const run = a.running ? '<span class="pill ok"><span class="dotpulse"></span>running</span>'
168
+ : '<span class="pill no">stopped</span>';
169
+ const st = a.busy ? '<span class="pill busy">busy</span>' : '<span class="pill mute">idle</span>';
170
+ const un = a.unread ? `<span class="pill busy">${a.unread} unread</span>` : "";
171
+ const q = a.queue_depth ? `<span class="pill mute">${a.queue_depth} queued</span>` : "";
172
+ // A down agent gets a Start button; a running one gets a Stop button.
173
+ const act = a.running
174
+ ? `<button class="pill downbtn" data-down="${esc(a.name)}">■ Stop</button>`
175
+ : `<button class="pill upbtn" data-up="${esc(a.name)}">▶ Start</button>`;
176
+ return run + st + act + un + q;
177
+ }
178
+
179
+ function renderAgents() {
180
+ // Clear any prior poll timer so a poll-triggered re-render can't stack them.
181
+ if (timers.status) { clearInterval(timers.status); delete timers.status; }
182
+ const agents = (state.status && state.status.agents) || [];
183
+ const cards = agents.map((a) => `
184
+ <div class="card agentcard" data-agent="${esc(a.name)}">
185
+ <div class="top">
186
+ ${avatar(a.name)}
187
+ <div style="min-width:0">
188
+ <div class="name">${esc(a.name)}</div>
189
+ <div class="muted" style="font-size:.8rem">${esc(a.type)}</div>
190
+ </div>
191
+ </div>
192
+ <div class="role" data-role="${esc(a.name)}">${esc(a.role_preview || "")}</div>
193
+ <div class="meta">${statusPills(a)}</div>
194
+ <div class="muted" style="font-size:.78rem">talks to: ${esc((a.can_talk_to || []).join(", ") || "—")}</div>
195
+ </div>`).join("");
196
+ $("view").innerHTML = `
197
+ <div class="sectiontitle">
198
+ <h2>Agents <span class="muted" style="font-weight:500">(${agents.length})</span></h2>
199
+ <button class="btn ghost sm" id="refreshBtn">Refresh</button>
200
+ </div>
201
+ ${topologyCard(agents)}
202
+ <div class="grid">${cards || '<p class="empty">No agents configured. Add one in Settings.</p>'}</div>`;
203
+ $("refreshBtn").onclick = pollStatus;
204
+ for (const c of document.querySelectorAll(".agentcard"))
205
+ c.onclick = (e) => {
206
+ // The Start/Stop pills live inside the card; the delegated document
207
+ // listener handles them. Its stopPropagation fires too late to stop this
208
+ // (closer) handler, so skip opening the mail page for those clicks here.
209
+ if (e.target.closest("[data-up],[data-down]")) return;
210
+ openAgent(c.dataset.agent);
211
+ };
212
+ for (const g of document.querySelectorAll(".gnode")) {
213
+ g.onclick = () => openAgent(g.dataset.agent);
214
+ g.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAgent(g.dataset.agent); } };
215
+ }
216
+ // Lazily enrich each card's role text (kept out of /api/status to stay light).
217
+ agents.forEach((a) => apiGet("/api/agent?agent=" + encodeURIComponent(a.name))
218
+ .then((d) => { const n = document.querySelector(`[data-role="${cssq(a.name)}"]`); if (n) n.textContent = (d.agent && d.agent.role) || "(no role set)"; })
219
+ .catch(() => {}));
220
+ timers.status = setInterval(pollStatus, 5000);
221
+ }
222
+
223
+ function cssq(s) { return String(s).replace(/"/g, '\\"'); }
224
+
225
+ // Bring one agent up (from a `.upbtn` on a card or the mail header).
226
+ function startAgent(name, btn) {
227
+ if (btn) { btn.disabled = true; btn.textContent = "starting…"; }
228
+ apiPost("/api/up", { agent: name }).then((res) => {
229
+ if (!res.ok) {
230
+ toast("error: " + (res.j.error || "failed"));
231
+ if (btn) { btn.disabled = false; btn.textContent = "▶ Start"; }
232
+ return;
233
+ }
234
+ toast(res.j.started ? "starting " + name : name + " already running");
235
+ // Give the CLI a moment to open its tmux session, then refresh.
236
+ setTimeout(() => { pollStatus(); refreshMailStatus(); }, 800);
237
+ });
238
+ }
239
+
240
+ // Take one agent down (kill its tmux session). Config is left untouched.
241
+ function stopAgent(name, btn) {
242
+ if (!confirm("Stop " + name + "? Its tmux session (and any in-flight turn) will be killed.")) return;
243
+ if (btn) { btn.disabled = true; btn.textContent = "stopping…"; }
244
+ apiPost("/api/down", { agent: name }).then((res) => {
245
+ if (!res.ok) {
246
+ toast("error: " + (res.j.error || "failed"));
247
+ if (btn) { btn.disabled = false; btn.textContent = "■ Stop"; }
248
+ return;
249
+ }
250
+ toast(res.j.stopped ? "stopped " + name : name + " already down");
251
+ setTimeout(() => { pollStatus(); refreshMailStatus(); }, 400);
252
+ });
253
+ }
254
+
255
+ function pollStatus() {
256
+ apiGet("/api/status").then((data) => {
257
+ state.status = data;
258
+ syncAvailability(data.user_available);
259
+ $("swarmMeta").textContent = (data.name || "swarm") + " · " + ((data.agents || []).length) + " agents";
260
+ if (state.view === "agents") {
261
+ const agents = data.agents || [];
262
+ const shown = Array.from(document.querySelectorAll(".agentcard")).map((c) => c.dataset.agent);
263
+ const same = shown.length === agents.length && agents.every((a, i) => shown[i] === a.name);
264
+ if (!same) {
265
+ // Agent set changed (added / removed / brought up elsewhere): rebuild
266
+ // the whole view so new cards and the topology graph appear.
267
+ renderAgents();
268
+ } else {
269
+ // Same set: patch pill rows in place so we don't stomp scroll / role text.
270
+ agents.forEach((a) => {
271
+ const card = document.querySelector(`.agentcard[data-agent="${cssq(a.name)}"] .meta`);
272
+ if (card) card.innerHTML = statusPills(a);
273
+ });
274
+ }
275
+ }
276
+ }).catch((e) => banner(e.message));
277
+ }
278
+
279
+ // ---- topology graph (who-talks-to-whom) --------------------------------
280
+
281
+ function topologyCard(agents) {
282
+ if (!agents.length) return "";
283
+ return `<div class="card panel" style="margin-bottom:1rem">
284
+ <h3 style="margin:0 0 .4rem">Who talks to whom</h3>
285
+ <div style="overflow-x:auto">${drawTopology(agents)}</div></div>`;
286
+ }
287
+
288
+ function drawTopology(agents) {
289
+ const nodes = agents.map((a) => a.name);
290
+ if (agents.some((a) => (a.can_talk_to || []).includes("user"))) nodes.push("user");
291
+ const W = 560, H = 300, cx = W / 2, cy = H / 2, r = Math.min(W, H) / 2 - 48, N = nodes.length;
292
+ const pos = {};
293
+ nodes.forEach((n, i) => {
294
+ const ang = -Math.PI / 2 + (i * 2 * Math.PI) / N;
295
+ pos[n] = { x: cx + r * Math.cos(ang), y: cy + r * Math.sin(ang) };
296
+ });
297
+ let edges = "";
298
+ agents.forEach((a) => (a.can_talk_to || []).forEach((p) => {
299
+ if (pos[a.name] && pos[p]) {
300
+ const s = pos[a.name], t = pos[p];
301
+ const dx = t.x - s.x, dy = t.y - s.y, len = Math.hypot(dx, dy) || 1, R = 21;
302
+ edges += `<line x1="${s.x + (dx / len) * R}" y1="${s.y + (dy / len) * R}" x2="${t.x - (dx / len) * R}" y2="${t.y - (dy / len) * R}" class="edge" marker-end="url(#arrow)"/>`;
303
+ }
304
+ }));
305
+ const circles = nodes.map((n) => {
306
+ const p = pos[n];
307
+ const fill = n === "user" ? "hsl(215 62% 48%)" : `hsl(${hueFor(n)} 62% 48%)`;
308
+ const clickable = n !== "user";
309
+ return `<g${clickable ? ` class="gnode" data-agent="${esc(n)}" role="button" tabindex="0" aria-label="open ${esc(n)}"` : ""}>
310
+ <circle cx="${p.x}" cy="${p.y}" r="19" fill="${fill}"/>
311
+ <text x="${p.x}" y="${p.y + 4}" text-anchor="middle" class="gnode-t">${esc(initials(n))}</text>
312
+ <text x="${p.x}" y="${p.y + 36}" text-anchor="middle" class="gnode-l">${esc(n === "user" ? "you" : n)}</text></g>`;
313
+ }).join("");
314
+ return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;min-width:420px;height:auto;color:var(--muted)" role="img" aria-label="agent communication graph">
315
+ <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
316
+ <path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker></defs>
317
+ <g class="edges">${edges}</g>${circles}</svg>`;
318
+ }
319
+
320
+ // ---- activity timeline (global event log) ------------------------------
321
+
322
+ const KIND_CLASS = {
323
+ route: "ok", delivered: "ok", "user-send": "ok", read: "mute", "read-receipt": "mute",
324
+ bounce: "no", "rate-limited": "no", ping: "busy", "user-held": "busy",
325
+ "user-available": "ok", "user-away": "mute",
326
+ };
327
+
328
+ function renderActivity() {
329
+ $("view").innerHTML = `
330
+ <div class="sectiontitle">
331
+ <h2>Activity</h2>
332
+ <button class="btn ghost sm" id="refreshBtn">Refresh</button>
333
+ </div>
334
+ <div class="card" style="padding:.3rem .2rem"><div id="timeline" class="timeline"><p class="empty">Loading…</p></div></div>`;
335
+ $("refreshBtn").onclick = loadTimeline;
336
+ loadTimeline();
337
+ timers.activity = setInterval(loadTimeline, 5000);
338
+ }
339
+
340
+ function loadTimeline() {
341
+ apiGet("/api/logs?n=250").then((d) => {
342
+ const logs = (d.logs || []).slice().reverse(); // newest first
343
+ const box = $("timeline"); if (!box) return;
344
+ box.innerHTML = logs.map((r) => {
345
+ const kind = r.kind || "?";
346
+ const cls = KIND_CLASS[kind] || "mute";
347
+ const route = [r.from_, r.to].filter(Boolean).join(" → ");
348
+ const extra = [route, r.id, r.reason].filter(Boolean).map(esc).join(" · ");
349
+ return `<div class="event">
350
+ <span class="t">${esc(fmtTime(r.ts))}</span>
351
+ <span class="pill ${cls}">${esc(kind)}</span>
352
+ <b>${esc(r.agent || "")}</b>
353
+ <span class="muted">${extra}</span></div>`;
354
+ }).join("") || '<p class="empty">No events yet.</p>';
355
+ }).catch((e) => banner(e.message));
356
+ }
357
+
358
+ // ---- mail app ----------------------------------------------------------
359
+
360
+ function openAgent(name) {
361
+ state.agent = name;
362
+ state.peer = "user";
363
+ state.tab = "mail";
364
+ go("mail");
365
+ }
366
+
367
+ function orderedContacts(contacts) {
368
+ const rank = (c) => (c.name === "user" ? 0 : c.kind === "agent" ? 1 : 2);
369
+ return contacts.slice().sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
370
+ }
371
+
372
+ function renderMail() {
373
+ clearTimers();
374
+ const agent = state.agent;
375
+ $("view").innerHTML = `
376
+ <div class="mailhead">
377
+ <button class="btn ghost sm" id="backBtn">← Agents</button>
378
+ <div class="who">${avatar(agent, "sm")}<h2>${esc(agent)}</h2><span id="mailStatus"></span></div>
379
+ <span style="flex:1"></span>
380
+ <div class="tabs">
381
+ <button class="tab ${state.tab === "mail" ? "active" : ""}" data-tab="mail">Mail</button>
382
+ <button class="tab ${state.tab === "terminal" ? "active" : ""}" data-tab="terminal">Terminal</button>
383
+ </div>
384
+ </div>
385
+ <div id="agentBody"></div>`;
386
+ $("backBtn").onclick = () => go("agents");
387
+ for (const b of document.querySelectorAll(".tab"))
388
+ b.onclick = () => { state.tab = b.dataset.tab; renderMail(); };
389
+ refreshMailStatus();
390
+ if (state.tab === "terminal") renderTerminalTab();
391
+ else renderMailTab();
392
+ }
393
+
394
+ function renderMailTab() {
395
+ document.body.dataset.pane = "list";
396
+ $("agentBody").innerHTML = `
397
+ <div class="mail">
398
+ <div class="card contacts" id="contacts"></div>
399
+ <div class="thread-wrap">
400
+ <div class="card thread">
401
+ <div class="scroll" id="threadScroll"><p class="empty">Select a contact.</p></div>
402
+ <div id="composeArea"></div>
403
+ </div>
404
+ </div>
405
+ </div>`;
406
+ loadContacts();
407
+ loadThread();
408
+ timers.mail = setInterval(() => { loadContacts(); loadThread(); refreshMailStatus(); }, 4000);
409
+ }
410
+
411
+ function renderTerminalTab() {
412
+ document.body.dataset.pane = "thread";
413
+ $("agentBody").innerHTML = `
414
+ <div class="card terminal">
415
+ <div class="thead">
416
+ <b>Live terminal · ${esc(state.agent)}</b>
417
+ <label class="wrapchk"><input type="checkbox" id="wrapPane" ${state.wrap ? "checked" : ""}/> Wrap text</label>
418
+ <span class="muted" style="font-size:.78rem">capture-pane · refreshes 2s</span>
419
+ </div>
420
+ <pre class="pane${state.wrap ? " wrap" : ""}" id="pane">— loading —</pre>
421
+ <div class="keyrow">${KEYS.map((k) => `<button class="keybtn" data-key="${esc(k.key)}" title="${esc(k.title)}">${esc(k.label)}</button>`).join("")}</div>
422
+ <div class="typerow">
423
+ <input class="field" id="typeText" placeholder="Type straight into ${esc(state.agent)}'s session, press Enter…" />
424
+ <button class="btn" id="typeSend">Send</button>
425
+ </div>
426
+ <p class="muted" style="font-size:.8rem;margin:.5rem 0 0">Types directly into the tmux pane (bypasses mail). The keys above send a single control keystroke (e.g. Esc to dismiss a prompt). An empty pane means the agent's session isn't running.</p>
427
+ </div>`;
428
+ const inp = $("typeText");
429
+ inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); sendType(); } });
430
+ $("typeSend").onclick = sendType;
431
+ for (const b of document.querySelectorAll(".keybtn")) b.onclick = () => sendKey(b.dataset.key);
432
+ $("wrapPane").onchange = (e) => {
433
+ state.wrap = e.target.checked;
434
+ try { localStorage.setItem("paneWrap", state.wrap ? "1" : ""); } catch (_) {}
435
+ $("pane").classList.toggle("wrap", state.wrap);
436
+ };
437
+ loadPane();
438
+ timers.pane = setInterval(() => { loadPane(); refreshMailStatus(); }, 2000);
439
+ }
440
+
441
+ function refreshMailStatus() {
442
+ apiGet("/api/agent?agent=" + encodeURIComponent(state.agent)).then((d) => {
443
+ const a = d.agent; if (!a) return;
444
+ const s = $("mailStatus"); if (s) s.innerHTML = statusPills(a);
445
+ }).catch(() => {});
446
+ }
447
+
448
+ function loadContacts() {
449
+ apiGet("/api/contacts?agent=" + encodeURIComponent(state.agent)).then((d) => {
450
+ state.contacts = orderedContacts(d.contacts || []);
451
+ renderContacts();
452
+ }).catch((e) => banner(e.message));
453
+ }
454
+
455
+ function renderContacts() {
456
+ const box = $("contacts"); if (!box) return;
457
+ box.innerHTML = state.contacts.map((c) => {
458
+ const label = c.name === "user" ? "You (operator)" : c.name;
459
+ const time = c.last_time ? `<span class="t">${esc(fmtTime(c.last_time))}</span>` : "";
460
+ const badge = c.unread ? `<span class="badge">${c.unread}</span>` : "";
461
+ return `
462
+ <div class="contact ${c.name === state.peer ? "active" : ""}" data-peer="${esc(c.name)}">
463
+ ${avatar(c.name, "sm")}
464
+ <div class="info">
465
+ <div class="cn"><b>${esc(label)}</b>${time}</div>
466
+ <div class="prev">${esc(c.last_preview || (c.count ? "" : "no messages yet"))}</div>
467
+ </div>
468
+ ${badge}
469
+ </div>`;
470
+ }).join("") || '<p class="empty">No contacts.</p>';
471
+ for (const node of box.querySelectorAll(".contact"))
472
+ node.onclick = () => selectPeer(node.dataset.peer);
473
+ }
474
+
475
+ function selectPeer(peer) {
476
+ state.peer = peer;
477
+ document.body.dataset.pane = "thread";
478
+ renderContacts();
479
+ loadThread();
480
+ }
481
+
482
+ function loadThread() {
483
+ if (!state.peer) return;
484
+ apiGet(`/api/thread?agent=${encodeURIComponent(state.agent)}&peer=${encodeURIComponent(state.peer)}`)
485
+ .then((d) => { state.thread = d.messages || []; renderThread(); })
486
+ .catch((e) => banner(e.message));
487
+ }
488
+
489
+ function renderThread() {
490
+ const scroll = $("threadScroll"); if (!scroll) return;
491
+ const atBottom = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 60;
492
+ if (!state.thread.length) {
493
+ scroll.innerHTML = `<p class="empty">No messages between <b>${esc(state.agent)}</b> and <b>${esc(state.peer === "user" ? "you" : state.peer)}</b> yet.</p>`;
494
+ } else {
495
+ scroll.innerHTML = state.thread.map((m) => {
496
+ const cls = m.from === "system" ? "system" : m.direction === "out" ? "out" : "in";
497
+ const head = cls === "system" ? "system" : `${esc(m.from)} → ${esc(m.to)} · ${esc(fmtTime(m.time))}`;
498
+ const status = cls === "system" ? "" : statusTag(m.status);
499
+ return `<div class="msg ${cls}"><div class="m-head">${head}</div><div class="m-body">${md(m.body.trim())}</div>${status}</div>`;
500
+ }).join("");
501
+ }
502
+ if (atBottom) scroll.scrollTop = scroll.scrollHeight;
503
+ renderCompose();
504
+ }
505
+
506
+ // Delivery status of one message, from where it currently sits in the mailroom.
507
+ function statusTag(s) {
508
+ const map = {
509
+ queued: ["◷", "waiting"], delivered: ["✓", "delivered"],
510
+ read: ["✓✓", "read"], archived: ["⤓", "archived"],
511
+ };
512
+ const e = map[s];
513
+ return e ? `<div class="m-status s-${s}">${e[0]} ${e[1]}</div>` : "";
514
+ }
515
+
516
+ function renderCompose() {
517
+ const area = $("composeArea"); if (!area) return;
518
+ const mode = state.peer === "user" ? "compose" : "note";
519
+ // Idempotent: background polls call this every few seconds. Rebuilding the
520
+ // textarea would wipe whatever the user is typing (and drop focus), so only
521
+ // touch the DOM when the mode actually changes.
522
+ if (area.dataset.mode === mode) return;
523
+ area.dataset.mode = mode;
524
+ if (mode === "compose") {
525
+ area.innerHTML = `
526
+ <div class="compose">
527
+ <textarea class="field" id="reply" rows="1" placeholder="Message ${esc(state.agent)} as the user…"></textarea>
528
+ <button class="btn" id="sendReply">Send</button>
529
+ </div>`;
530
+ const ta = $("reply");
531
+ ta.addEventListener("input", () => { ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight, 240) + "px"; });
532
+ ta.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendReply(); } });
533
+ $("sendReply").onclick = sendReply;
534
+ } else {
535
+ area.innerHTML = `<div class="composenote">You correspond as the <b>user</b>. Open <b>You (operator)</b> to write to ${esc(state.agent)}, or use <b>Terminal</b> to type straight into its session.</div>`;
536
+ }
537
+ }
538
+
539
+ function sendReply() {
540
+ const text = $("reply").value;
541
+ if (!text.trim()) return;
542
+ apiPost("/api/send", { to: state.agent, text }).then((res) => {
543
+ if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
544
+ $("reply").value = ""; $("reply").style.height = "auto";
545
+ toast("sent to " + state.agent);
546
+ loadThread(); loadContacts();
547
+ });
548
+ }
549
+
550
+ // ---- terminal (live pane + direct type-in) -----------------------------
551
+
552
+ // Control keys offered under the pane. `key` is a tmux key name the backend
553
+ // whitelists (lib/tmux.py ALLOWED_KEYS).
554
+ const KEYS = [
555
+ { key: "Escape", label: "Esc", title: "Escape" },
556
+ { key: "Enter", label: "Enter", title: "Enter / Return" },
557
+ { key: "Tab", label: "Tab", title: "Tab" },
558
+ { key: "Up", label: "↑", title: "Up arrow" },
559
+ { key: "Down", label: "↓", title: "Down arrow" },
560
+ { key: "Left", label: "←", title: "Left arrow" },
561
+ { key: "Right", label: "→", title: "Right arrow" },
562
+ { key: "C-c", label: "Ctrl-C", title: "Interrupt (Ctrl-C)" },
563
+ { key: "C-u", label: "Ctrl-U", title: "Clear line (Ctrl-U)" },
564
+ { key: "C-l", label: "Ctrl-L", title: "Redraw / clear (Ctrl-L)" },
565
+ ];
566
+
567
+ function sendKey(key) {
568
+ apiPost("/api/key", { agent: state.agent, key }).then((res) => {
569
+ if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
570
+ toast(key + " → " + state.agent);
571
+ setTimeout(loadPane, 300);
572
+ });
573
+ }
574
+
575
+ function loadPane() {
576
+ apiGet("/api/pane?agent=" + encodeURIComponent(state.agent)).then((d) => {
577
+ const p = $("pane"); if (p) p.textContent = d.pane || "— (empty / session down) —";
578
+ }).catch(() => {});
579
+ }
580
+
581
+ function sendType() {
582
+ const text = $("typeText").value;
583
+ if (!text.trim()) return;
584
+ apiPost("/api/type", { agent: state.agent, text }).then((res) => {
585
+ if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
586
+ $("typeText").value = "";
587
+ toast(res.j.ok ? "typed into " + state.agent : "sent (not confirmed in pane)");
588
+ setTimeout(loadPane, 400);
589
+ });
590
+ }
591
+
592
+ // ---- settings ----------------------------------------------------------
593
+
594
+ const SWARM_SCHEMA = [
595
+ ["name", "Swarm name", "text"],
596
+ ["session_prefix", "tmux session prefix", "text"],
597
+ ["supervise", "Liveness supervisor", "bool"],
598
+ ["supervise_interval_ms", "Supervise interval (ms)", "num"],
599
+ ["ready_timeout_ms", "Ready timeout (ms)", "num"],
600
+ ["busy_timeout_ms", "Busy timeout (ms)", "num"],
601
+ ["resume", "Resume sessions on up", "bool"],
602
+ ["pane_idle_ms", "Pane idle (ms)", "num"],
603
+ ["pane_poll_ms", "Pane poll (ms)", "num"],
604
+ ["pane_scrollback", "Pane scrollback lines", "num"],
605
+ ["enter_delay_ms", "Enter delay (ms)", "num"],
606
+ ["send_delay_ms", "Send delay (ms)", "num"],
607
+ ["tmux_mouse", "tmux mouse mode", "bool"],
608
+ ];
609
+ const SWARM_DEFAULTS = {
610
+ supervise: true, supervise_interval_ms: 15000, ready_timeout_ms: 60000,
611
+ busy_timeout_ms: 900000, resume: false, pane_idle_ms: 2500, pane_poll_ms: 700,
612
+ pane_scrollback: 400, enter_delay_ms: 250, send_delay_ms: 150, tmux_mouse: true,
613
+ session_prefix: "", name: "",
614
+ };
615
+
616
+ function renderSettings() {
617
+ Promise.all([apiGet("/api/config"), apiGet("/api/telegram").catch(() => null)]).then(([cfg, tg]) => {
618
+ state.config = cfg;
619
+ state.telegram = tg;
620
+ const sw = cfg.swarm || {};
621
+ const fields = SWARM_SCHEMA.map(([key, label, type]) => {
622
+ const val = key in sw ? sw[key] : SWARM_DEFAULTS[key];
623
+ if (type === "bool")
624
+ return `<div class="fld"><label>${esc(label)}</label>
625
+ <label class="row"><input type="checkbox" data-swarm="${key}" ${val ? "checked" : ""}/> <span class="muted">${key}</span></label></div>`;
626
+ return `<div class="fld"><label>${esc(label)}</label>
627
+ <input class="field" data-swarm="${key}" type="${type === "num" ? "number" : "text"}" value="${esc(val == null ? "" : val)}"/></div>`;
628
+ }).join("");
629
+ const agents = (cfg.agents || []).map((a) => `
630
+ <div class="agentrow">
631
+ ${avatar(String(a.name), "sm")}
632
+ <div class="info">
633
+ <b>${esc(a.name)}</b> <span class="muted">${esc(a.type || "claude")}</span>
634
+ <div class="muted" style="font-size:.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">talks to: ${esc(fmtCanTalk(a.can_talk_to))}</div>
635
+ </div>
636
+ <button class="btn ghost sm" data-edit="${esc(a.name)}">Edit</button>
637
+ <button class="btn danger sm" data-del="${esc(a.name)}">Delete</button>
638
+ </div>`).join("");
639
+ $("view").innerHTML = `
640
+ <div class="settings">
641
+ <div class="card panel">
642
+ <h3>Swarm settings</h3>
643
+ <p class="muted" style="margin-top:.1rem">Saved straight to <code>${esc(cfg.path || "agentainer.yaml")}</code>.</p>
644
+ <div class="formgrid">${fields}</div>
645
+ <div class="rowend"><button class="btn" id="saveSwarm">Save settings</button></div>
646
+ </div>
647
+ ${telegramCard(tg, cfg.agents || [])}
648
+ <div class="card panel">
649
+ <div class="sectiontitle"><h3>Agents</h3><button class="btn sm" id="addAgent">+ Add agent</button></div>
650
+ ${agents || '<p class="muted">No agents yet.</p>'}
651
+ </div>
652
+ </div>`;
653
+ $("saveSwarm").onclick = saveSwarm;
654
+ $("addAgent").onclick = () => openAgentForm(null);
655
+ for (const b of document.querySelectorAll("[data-edit]")) b.onclick = () => openAgentForm(b.dataset.edit);
656
+ for (const b of document.querySelectorAll("[data-del]")) b.onclick = () => deleteAgent(b.dataset.del);
657
+ wireTelegram();
658
+ }).catch((e) => banner(e.message));
659
+ }
660
+
661
+ function telegramCard(tg, agents) {
662
+ if (!tg) return "";
663
+ const allScope = tg.mirror === "*" || (Array.isArray(tg.mirror) && tg.mirror.includes("*"));
664
+ const sel = Array.isArray(tg.mirror) ? tg.mirror : [];
665
+ const checks = agents.map((a) => `
666
+ <label class="row" style="gap:.3rem"><input type="checkbox" class="tg-agent" value="${esc(a.name)}" ${sel.includes(a.name) ? "checked" : ""} ${allScope ? "disabled" : ""}/> ${esc(a.name)}</label>`).join("");
667
+ return `
668
+ <div class="card panel">
669
+ <div class="sectiontitle"><h3>Telegram bridge</h3>
670
+ <span class="pill ${tg.enabled ? "ok" : "mute"}">${tg.enabled ? "on" : "off"}</span></div>
671
+ <p class="muted" style="margin:.1rem 0 .6rem">Mirror the swarm's mail to a Telegram chat and reply from your phone. Uses the Bot API over HTTPS — zero dependencies. Create a bot with <b>@BotFather</b>, then get your numeric chat id from <b>@userinfobot</b>.</p>
672
+ <div class="formgrid">
673
+ <div class="fld"><label>Enabled</label><label class="row"><input type="checkbox" id="tg_enabled" ${tg.enabled ? "checked" : ""}/> <span class="muted">mirror on</span></label></div>
674
+ <div class="fld"><label>Bot token</label><input class="field" id="tg_token" type="password" placeholder="${tg.has_token ? "•••• stored — blank keeps it" : "123456:ABC-DEF…"}"/></div>
675
+ <div class="fld"><label>Chat ID</label><input class="field" id="tg_chat" value="${esc(tg.chat_id || "")}" placeholder="e.g. 123456789"/></div>
676
+ <div class="fld"><label>Mirror your mail</label><label class="row"><input type="checkbox" id="tg_muser" ${tg.mirror_user ? "checked" : ""}/> <span class="muted">mail to you</span></label></div>
677
+ <div class="fld"><label>Mirror system</label><label class="row"><input type="checkbox" id="tg_msys" ${tg.mirror_system ? "checked" : ""}/> <span class="muted">pings/bounces</span></label></div>
678
+ </div>
679
+ <div class="fld" style="margin-top:.6rem"><label>Which agents to mirror</label>
680
+ <div class="row">
681
+ <label class="row" style="gap:.3rem"><input type="radio" name="tgscope" value="all" ${allScope ? "checked" : ""}/> all agents</label>
682
+ <label class="row" style="gap:.3rem"><input type="radio" name="tgscope" value="sel" ${allScope ? "" : "checked"}/> selected</label>
683
+ </div>
684
+ <div class="row" id="tg_agents" style="margin-top:.4rem;opacity:${allScope ? ".5" : "1"}">${checks || '<span class="muted">no agents</span>'}</div>
685
+ </div>
686
+ <div class="rowend">
687
+ <button class="btn ghost" id="tg_test">Send test</button>
688
+ <button class="btn ghost" id="tg_poll">${tg.polling ? "Stop replies" : "Receive replies"}</button>
689
+ <button class="btn" id="tg_save">Save Telegram</button>
690
+ </div>
691
+ </div>`;
692
+ }
693
+
694
+ function wireTelegram() {
695
+ if (!$("tg_save")) return;
696
+ for (const r of document.querySelectorAll('input[name="tgscope"]'))
697
+ r.onchange = () => {
698
+ const all = document.querySelector('input[name="tgscope"]:checked').value === "all";
699
+ $("tg_agents").style.opacity = all ? ".5" : "1";
700
+ for (const c of document.querySelectorAll(".tg-agent")) c.disabled = all;
701
+ };
702
+ $("tg_save").onclick = saveTelegram;
703
+ $("tg_test").onclick = testTelegram;
704
+ $("tg_poll").onclick = toggleTelegramPolling;
705
+ }
706
+
707
+ function collectTelegram() {
708
+ const all = document.querySelector('input[name="tgscope"]:checked').value === "all";
709
+ const mirror = all ? "*" : Array.from(document.querySelectorAll(".tg-agent:checked")).map((c) => c.value);
710
+ const body = {
711
+ enabled: $("tg_enabled").checked,
712
+ chat_id: $("tg_chat").value.trim(),
713
+ mirror,
714
+ mirror_user: $("tg_muser").checked,
715
+ mirror_system: $("tg_msys").checked,
716
+ };
717
+ const tok = $("tg_token").value.trim();
718
+ if (tok) body.bot_token = tok;
719
+ return body;
720
+ }
721
+
722
+ function saveTelegram() {
723
+ apiPost("/api/telegram", collectTelegram()).then((res) => {
724
+ if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
725
+ toast("Telegram settings saved");
726
+ renderSettings();
727
+ });
728
+ }
729
+
730
+ function testTelegram() {
731
+ // Persist first (so the token/chat just typed are used), then send a test.
732
+ apiPost("/api/telegram", collectTelegram()).then(() =>
733
+ apiPost("/api/telegram/test", {}).then((res) => {
734
+ toast(res.ok ? "test message sent" : "error: " + (res.j.error || "failed"));
735
+ }));
736
+ }
737
+
738
+ function toggleTelegramPolling() {
739
+ const run = !(state.telegram && state.telegram.polling);
740
+ apiPost("/api/telegram/poll", { run }).then((res) => {
741
+ if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
742
+ toast(res.j.polling ? "listening for Telegram replies" : "stopped listening");
743
+ renderSettings();
744
+ });
745
+ }
746
+
747
+ function fmtCanTalk(v) { return Array.isArray(v) ? v.join(", ") : (v || "—"); }
748
+
749
+ function saveSwarm() {
750
+ const swarm = {};
751
+ for (const node of document.querySelectorAll("[data-swarm]")) {
752
+ const key = node.dataset.swarm;
753
+ if (node.type === "checkbox") swarm[key] = node.checked;
754
+ else if (node.type === "number") { if (node.value !== "") swarm[key] = Number(node.value); }
755
+ else if (node.value !== "") swarm[key] = node.value;
756
+ }
757
+ apiPost("/api/config", { swarm }).then((res) => {
758
+ if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
759
+ toast("settings saved");
760
+ });
761
+ }
762
+
763
+ function deleteAgent(name) {
764
+ if (!confirm(`Delete agent "${name}"? This stops its session and removes it from the config.`)) return;
765
+ apiPost("/api/agent/remove", { name }).then((res) => {
766
+ if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
767
+ toast("removed " + name);
768
+ renderSettings(); pollStatus();
769
+ });
770
+ }
771
+
772
+ // ---- add / edit agent modal -------------------------------------------
773
+
774
+ const AGENT_TYPES = ["claude", "codex", "gemini", "hermes"];
775
+
776
+ function openAgentForm(name) {
777
+ const editing = !!name;
778
+ const a = editing ? (state.config.agents || []).find((x) => String(x.name) === name) || {} : {};
779
+ const typeOpts = AGENT_TYPES.map((t) => `<option value="${t}" ${((a.type || "claude") === t) ? "selected" : ""}>${t}</option>`).join("");
780
+ const captureOpts = ["auto", "hook", "pane", "none"].map((c) => `<option value="${c}" ${((a.capture || "auto") === c) ? "selected" : ""}>${c}</option>`).join("");
781
+ const modal = el(`
782
+ <div class="modal-back"><div class="card modal">
783
+ <h3>${editing ? "Edit " + esc(name) : "Add agent"}</h3>
784
+ <div class="formgrid">
785
+ <div class="fld"><label>Name</label><input class="field" id="f_name" value="${esc(a.name || "")}" ${editing ? "disabled" : ""} placeholder="developer"/></div>
786
+ <div class="fld"><label>Type</label><select class="field" id="f_type">${typeOpts}</select></div>
787
+ <div class="fld"><label>Capture</label><select class="field" id="f_capture">${captureOpts}</select></div>
788
+ <div class="fld"><label>Ping every (s, 0=off)</label><input class="field" id="f_ping" type="number" value="${esc(a.periodically_ping_seconds || 0)}"/></div>
789
+ </div>
790
+ <div class="fld" style="margin-top:.6rem"><label>Command (may embed secrets — stays local)</label><input class="field" id="f_command" value="${esc(a.command || "")}" placeholder="claude --dangerously-skip-permissions"/></div>
791
+ <div class="fld" style="margin-top:.6rem"><label>Can talk to (comma list, or *)</label><input class="field" id="f_talk" value="${esc(fmtCanTalk(a.can_talk_to))}" placeholder="orchestrator, user"/></div>
792
+ <div class="fld" style="margin-top:.6rem"><label>Workdir (optional)</label><input class="field" id="f_workdir" value="${esc(a.workdir || "")}" placeholder="leave blank for default"/></div>
793
+ <div class="fld" style="margin-top:.6rem"><label>Role / standing instructions</label><textarea class="field" id="f_role" rows="4" placeholder="You are the developer…">${esc(a.role || "")}</textarea></div>
794
+ <div class="rowend">
795
+ <button class="btn ghost" id="f_cancel">Cancel</button>
796
+ <button class="btn" id="f_save">${editing ? "Save" : "Add agent"}</button>
797
+ </div>
798
+ </div></div>`);
799
+ $("modalRoot").appendChild(modal);
800
+ const close = () => modal.remove();
801
+ modal.addEventListener("click", (e) => { if (e.target === modal) close(); });
802
+ modal.querySelector("#f_cancel").onclick = close;
803
+ modal.querySelector("#f_save").onclick = () => saveAgentForm(editing, name, close);
804
+ }
805
+
806
+ function parseTalk(raw) {
807
+ raw = (raw || "").trim();
808
+ if (raw === "*") return "*";
809
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
810
+ }
811
+
812
+ function saveAgentForm(editing, name, close) {
813
+ const g = (id) => document.getElementById(id).value;
814
+ const payload = {
815
+ type: g("f_type"),
816
+ command: g("f_command").trim(),
817
+ can_talk_to: parseTalk(g("f_talk")),
818
+ role: g("f_role"),
819
+ capture: g("f_capture"),
820
+ periodically_ping_seconds: Number(g("f_ping")) || 0,
821
+ };
822
+ const workdir = g("f_workdir").trim();
823
+ if (workdir) payload.workdir = workdir;
824
+
825
+ let req;
826
+ if (editing) {
827
+ req = apiPost("/api/agent/edit", { name, fields: payload });
828
+ } else {
829
+ const n = g("f_name").trim();
830
+ if (!n) { toast("name is required"); return; }
831
+ if (!payload.command) { toast("command is required"); return; }
832
+ req = apiPost("/api/agent/add", Object.assign({ name: n }, payload));
833
+ }
834
+ req.then((res) => {
835
+ if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
836
+ toast(editing ? "saved " + name : "added " + res.j.name);
837
+ close(); renderSettings(); pollStatus();
838
+ });
839
+ }
840
+
841
+ // ---- availability ------------------------------------------------------
842
+
843
+ function toggleAvailability() {
844
+ const val = $("availToggle").checked;
845
+ apiPost("/api/availability", { available: val }).then((res) => {
846
+ if (!res.ok) { $("availToggle").checked = !val; toast("error: " + (res.j.error || "failed")); return; }
847
+ syncAvailability(val);
848
+ toast(val ? "you're available for mail" : "you're away");
849
+ });
850
+ }
851
+
852
+ // ---- wire up -----------------------------------------------------------
853
+
854
+ $("connect").addEventListener("click", connect);
855
+ $("token").addEventListener("keydown", (e) => { if (e.key === "Enter") connect(); });
856
+ $("availToggle").addEventListener("change", toggleAvailability);
857
+ for (const b of document.querySelectorAll(".navbtn"))
858
+ b.addEventListener("click", () => go(b.dataset.view));
859
+ // Delegated: Start buttons are re-created by status polls, so listen once here
860
+ // instead of re-wiring on every render. (The card's own onclick skips these
861
+ // buttons directly, since this document-level handler bubbles too late to.)
862
+ document.addEventListener("click", (e) => {
863
+ if (!e.target.closest) return;
864
+ const up = e.target.closest("[data-up]");
865
+ if (up) { e.stopPropagation(); startAgent(up.dataset.up, up); return; }
866
+ const down = e.target.closest("[data-down]");
867
+ if (down) { e.stopPropagation(); stopAgent(down.dataset.down, down); }
868
+ });
869
+ })();