agentainer 0.1.7 → 2.0.1
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/README.md +248 -677
- package/agentainer +16 -18
- package/agentainer.example.yaml +86 -0
- package/bin/agentainer.js +9 -8
- package/examples/academic-coauthor.yaml +123 -0
- package/examples/accessibility-audit.yaml +152 -0
- package/examples/affiliate-product-reviews.yaml +106 -0
- package/examples/api-design.yaml +157 -0
- package/examples/app-store-optimization.yaml +108 -0
- package/examples/brainstorm.yaml +27 -128
- package/examples/brand-voice-style-guide.yaml +109 -0
- package/examples/bug-hunt.yaml +51 -96
- package/examples/candidate-screen.yaml +122 -0
- package/examples/case-study-writer.yaml +100 -0
- package/examples/changelog-release-notes.yaml +114 -0
- package/examples/chatbot-builder.yaml +138 -0
- package/examples/code-review.yaml +73 -0
- package/examples/comparison-guide-writer.yaml +106 -0
- package/examples/competitive-intel.yaml +126 -0
- package/examples/content-studio.yaml +91 -0
- package/examples/course-creator.yaml +133 -0
- package/examples/customer-support-triage.yaml +118 -0
- package/examples/daily-briefing.yaml +119 -0
- package/examples/data-pipeline-builder.yaml +135 -0
- package/examples/debate.yaml +16 -90
- package/examples/design-system.yaml +138 -0
- package/examples/ebook-generator.yaml +90 -0
- package/examples/ecommerce-listing-optimizer.yaml +126 -0
- package/examples/email-newsletter.yaml +103 -0
- package/examples/faq-knowledge-sync.yaml +107 -0
- package/examples/game-design.yaml +122 -0
- package/examples/glossary-term-writer.yaml +103 -0
- package/examples/incident-response.yaml +52 -109
- package/examples/knowledge-base.yaml +115 -0
- package/examples/landing-page-converter.yaml +103 -0
- package/examples/legal-contract-review.yaml +118 -0
- package/examples/linkedin-ghostwriter.yaml +93 -0
- package/examples/localization.yaml +56 -123
- package/examples/meeting-notes.yaml +111 -0
- package/examples/migration-planner.yaml +127 -0
- package/examples/onboarding-buddy.yaml +111 -0
- package/examples/performance-audit.yaml +123 -0
- package/examples/podcast-production.yaml +117 -0
- package/examples/postmortem.yaml +119 -0
- package/examples/pr-review-gate.yaml +123 -0
- package/examples/press-release-wire.yaml +96 -0
- package/examples/product-spec.yaml +107 -0
- package/examples/prompt-engineering-lab.yaml +109 -0
- package/examples/quickstart.yaml +48 -0
- package/examples/rag-builder.yaml +145 -0
- package/examples/refactor-planner.yaml +127 -0
- package/examples/research.yaml +25 -0
- package/examples/resume-tailor.yaml +116 -0
- package/examples/rfp-response.yaml +124 -0
- package/examples/sales-coach.yaml +123 -0
- package/examples/security-audit.yaml +120 -0
- package/examples/seo-audit-and-fix.yaml +138 -0
- package/examples/seo-content-factory.yaml +103 -0
- package/examples/social-media.yaml +103 -0
- package/examples/software-company.yaml +71 -128
- package/examples/startup-validator.yaml +115 -0
- package/examples/tdd-pingpong.yaml +36 -68
- package/examples/technical-documentation.yaml +112 -0
- package/examples/test-factory.yaml +114 -0
- package/examples/tutorial-howto-creator.yaml +111 -0
- package/examples/twitter-x-thread-factory.yaml +91 -0
- package/examples/white-paper-research.yaml +96 -0
- package/examples/writers-room.yaml +49 -111
- package/examples/youtube-script-studio.yaml +107 -0
- package/hooks/claude_stop.sh +5 -3
- package/hooks/codex_notify.sh +4 -3
- package/lib/cli.py +933 -0
- package/lib/config.py +267 -308
- package/lib/hooks.py +246 -0
- package/lib/lock.py +75 -0
- package/lib/log.py +64 -0
- package/lib/mail.py +699 -0
- package/lib/minyaml.py +1 -39
- package/lib/reconcile.py +544 -0
- package/lib/sessions.py +223 -0
- package/lib/supervisor.py +216 -0
- package/lib/telegram.py +372 -0
- package/lib/tmux.py +355 -0
- package/lib/turn.py +167 -0
- package/lib/ui.py +1219 -0
- package/llms.txt +145 -429
- package/package.json +9 -7
- package/scripts/check-deps.js +18 -61
- package/ui/app.js +1136 -0
- package/ui/index.html +404 -0
- package/agents.example.yaml +0 -257
- package/examples/code-review-broadcast.yaml +0 -109
- package/examples/existing-repo.yaml +0 -74
- package/examples/multi-language-broadcast.yaml +0 -127
- package/examples/ping-pong.yaml +0 -89
- package/examples/red-team.yaml +0 -117
- package/examples/research-swarm.yaml +0 -129
- package/lib/swarm.py +0 -2461
package/ui/app.js
ADDED
|
@@ -0,0 +1,1136 @@
|
|
|
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
|
+
rate: (() => { try { return !!localStorage.getItem("showRate"); } catch (_) { return false; } })(),
|
|
25
|
+
notify: (() => { try { return !!localStorage.getItem("notifyOptIn"); } catch (_) { return false; } })(),
|
|
26
|
+
rates: {}, // last /api/rate {name: msgs_per_min}
|
|
27
|
+
lastAttention: 0, // for the 0 -> >0 notification transition
|
|
28
|
+
};
|
|
29
|
+
const timers = {};
|
|
30
|
+
|
|
31
|
+
// ---- tiny utilities ----------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function esc(s) {
|
|
34
|
+
return String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
|
|
35
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
36
|
+
}
|
|
37
|
+
function initials(name) {
|
|
38
|
+
if (name === "user") return "You";
|
|
39
|
+
return String(name).slice(0, 2).toUpperCase();
|
|
40
|
+
}
|
|
41
|
+
function hueFor(name) {
|
|
42
|
+
let h = 0;
|
|
43
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
|
44
|
+
return h;
|
|
45
|
+
}
|
|
46
|
+
function avatar(name, cls) {
|
|
47
|
+
const special = name === "user" ? 215 : name === "system" ? 0 : hueFor(name);
|
|
48
|
+
const sat = name === "system" ? "0%" : "62%";
|
|
49
|
+
return `<span class="avatar ${cls || ""}" style="background:hsl(${special} ${sat} 48%)">${esc(initials(name))}</span>`;
|
|
50
|
+
}
|
|
51
|
+
function fmtTime(iso) {
|
|
52
|
+
if (!iso) return "";
|
|
53
|
+
const d = new Date(iso);
|
|
54
|
+
if (isNaN(d)) return "";
|
|
55
|
+
const now = new Date();
|
|
56
|
+
const sameDay = d.toDateString() === now.toDateString();
|
|
57
|
+
return sameDay
|
|
58
|
+
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
59
|
+
: d.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
60
|
+
}
|
|
61
|
+
// Seconds -> short human duration ("42s", "3m", "1h4m").
|
|
62
|
+
function fmtDur(s) {
|
|
63
|
+
s = Math.max(0, Math.floor(s || 0));
|
|
64
|
+
if (s < 60) return s + "s";
|
|
65
|
+
if (s < 3600) return Math.floor(s / 60) + "m";
|
|
66
|
+
return Math.floor(s / 3600) + "h" + Math.floor((s % 3600) / 60) + "m";
|
|
67
|
+
}
|
|
68
|
+
// Minimal, dependency-free, XSS-safe markdown -> HTML. Everything is escaped
|
|
69
|
+
// first (so agent/user text can never inject markup); we then emit only our
|
|
70
|
+
// own tags, and only allow http(s)/mailto links. Good enough for mail bodies.
|
|
71
|
+
function md(src) {
|
|
72
|
+
const lines = esc(src).replace(/\r/g, "").split("\n");
|
|
73
|
+
const inline = (t) => t
|
|
74
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
75
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
76
|
+
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
|
77
|
+
.replace(/~~([^~]+)~~/g, "<del>$1</del>")
|
|
78
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
79
|
+
let html = "", i = 0;
|
|
80
|
+
const isBlock = (l) => /^\s*(```|#{1,6}\s|[-*+]\s|\d+\.\s|>)/.test(l);
|
|
81
|
+
while (i < lines.length) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
if (/^\s*```/.test(line)) {
|
|
84
|
+
const buf = []; i++;
|
|
85
|
+
while (i < lines.length && !/^\s*```/.test(lines[i])) buf.push(lines[i++]);
|
|
86
|
+
i++;
|
|
87
|
+
html += `<pre class="md-pre"><code>${buf.join("\n")}</code></pre>`;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const h = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
|
|
91
|
+
if (h) { html += `<h${h[1].length} class="md-h">${inline(h[2])}</h${h[1].length}>`; i++; continue; }
|
|
92
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
93
|
+
const items = [];
|
|
94
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) items.push(inline(lines[i++].replace(/^\s*[-*+]\s+/, "")));
|
|
95
|
+
html += "<ul>" + items.map((x) => `<li>${x}</li>`).join("") + "</ul>";
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
99
|
+
const items = [];
|
|
100
|
+
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(inline(lines[i++].replace(/^\s*\d+\.\s+/, "")));
|
|
101
|
+
html += "<ol>" + items.map((x) => `<li>${x}</li>`).join("") + "</ol>";
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (/^\s*>\s?/.test(line)) {
|
|
105
|
+
const buf = [];
|
|
106
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) buf.push(inline(lines[i++].replace(/^\s*>\s?/, "")));
|
|
107
|
+
html += `<blockquote>${buf.join("<br>")}</blockquote>`;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (/^\s*$/.test(line)) { i++; continue; }
|
|
111
|
+
const buf = [];
|
|
112
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && !isBlock(lines[i])) buf.push(inline(lines[i++]));
|
|
113
|
+
html += `<p>${buf.join("<br>")}</p>`;
|
|
114
|
+
}
|
|
115
|
+
return html;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clearTimers() { Object.values(timers).forEach(clearInterval); Object.keys(timers).forEach((k) => delete timers[k]); }
|
|
119
|
+
function banner(msg) { $("banner").textContent = msg || ""; }
|
|
120
|
+
let toastT = null;
|
|
121
|
+
function toast(msg) {
|
|
122
|
+
const t = $("toast"); t.textContent = msg; t.classList.add("show");
|
|
123
|
+
clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove("show"), 2200);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- API ---------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function withToken(path) { return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN); }
|
|
129
|
+
|
|
130
|
+
// ---- connection honesty (#1) -------------------------------------------
|
|
131
|
+
// A monitoring UI must never show frozen data as if it were live. Every
|
|
132
|
+
// request goes through `rawFetch`, which timestamps the last SERVER response
|
|
133
|
+
// (any HTTP status counts -- the box answered) and flags a network REJECTION.
|
|
134
|
+
// The indicator then goes stale from BOTH signals: an explicit down-mark and
|
|
135
|
+
// a 1s ticker (so a silently-hung server, which never rejects, still ages out).
|
|
136
|
+
const conn = { lastOk: 0, down: false, ticker: null };
|
|
137
|
+
function markConn(ok) {
|
|
138
|
+
if (ok) { conn.lastOk = Date.now(); conn.down = false; }
|
|
139
|
+
else { conn.down = true; }
|
|
140
|
+
renderConn();
|
|
141
|
+
}
|
|
142
|
+
function renderConn() {
|
|
143
|
+
const node = $("connind"); if (!node || node.hidden) return;
|
|
144
|
+
const age = conn.lastOk ? Date.now() - conn.lastOk : Infinity;
|
|
145
|
+
if (!conn.down && age < 8000) {
|
|
146
|
+
node.className = "connind live";
|
|
147
|
+
node.textContent = "● Live · " + fmtDur(age / 1000) + " ago";
|
|
148
|
+
} else {
|
|
149
|
+
node.className = "connind stale";
|
|
150
|
+
node.textContent = "◌ Reconnecting…";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function rawFetch(path, opts) {
|
|
154
|
+
return fetch(withToken(path), opts).then(
|
|
155
|
+
(r) => { markConn(true); return r; },
|
|
156
|
+
(err) => { markConn(false); throw err; },
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function apiGet(path) {
|
|
161
|
+
return rawFetch(path, { headers: { Accept: "application/json" } }).then((r) => {
|
|
162
|
+
if (r.status === 401) throw new Error("unauthorized");
|
|
163
|
+
return r.json();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function apiPost(path, body) {
|
|
167
|
+
return rawFetch(path, {
|
|
168
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}),
|
|
169
|
+
}).then((r) => r.json().then((j) => ({ ok: r.ok, status: r.status, j })).catch(() => ({ ok: r.ok, status: r.status, j: {} })));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- shell / navigation ------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function connect() {
|
|
175
|
+
TOKEN = $("token").value.trim();
|
|
176
|
+
if (!TOKEN) { banner("enter a token first"); return; }
|
|
177
|
+
apiGet("/api/status").then((data) => {
|
|
178
|
+
state.status = data;
|
|
179
|
+
$("login").hidden = true;
|
|
180
|
+
$("view").hidden = false;
|
|
181
|
+
$("nav").hidden = false;
|
|
182
|
+
$("availWrap").hidden = false;
|
|
183
|
+
$("connind").hidden = false;
|
|
184
|
+
// A GLOBAL heartbeat (kept out of `timers`, so clearTimers/navigation never
|
|
185
|
+
// stops it) re-renders the indicator every second so it ages out on its own.
|
|
186
|
+
if (!conn.ticker) conn.ticker = setInterval(renderConn, 1000);
|
|
187
|
+
renderConn();
|
|
188
|
+
banner("");
|
|
189
|
+
syncAvailability(data.user_available);
|
|
190
|
+
state.lastAttention = data.attention || 0;
|
|
191
|
+
go("agents");
|
|
192
|
+
}).catch((e) => banner("connect failed: " + e.message));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function syncAvailability(available) {
|
|
196
|
+
$("availToggle").checked = !!available;
|
|
197
|
+
$("availLbl").textContent = available ? "You: available" : "You: away";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function go(view) {
|
|
201
|
+
state.view = view;
|
|
202
|
+
clearTimers();
|
|
203
|
+
const nav = view === "settings" ? "settings" : view === "activity" ? "activity" : "agents";
|
|
204
|
+
for (const b of document.querySelectorAll(".navbtn"))
|
|
205
|
+
b.classList.toggle("active", b.dataset.view === nav);
|
|
206
|
+
if (view === "agents") renderAgents();
|
|
207
|
+
else if (view === "mail") renderMail();
|
|
208
|
+
else if (view === "activity") renderActivity();
|
|
209
|
+
else if (view === "settings") renderSettings();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- agents overview ---------------------------------------------------
|
|
213
|
+
|
|
214
|
+
// Truthful state vocabulary (see /api/status `state`): what the agent is
|
|
215
|
+
// actually doing, not just whether tmux is up.
|
|
216
|
+
const STATE_LABEL = {
|
|
217
|
+
working: "working", waiting: "waiting", attention: "needs you",
|
|
218
|
+
stalled: "stalled", stopped: "stopped",
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
function statusPills(a) {
|
|
222
|
+
// Fall back to the old running/busy signals if `state` is absent.
|
|
223
|
+
const s = a.state || (a.running ? (a.busy ? "working" : "waiting") : "stopped");
|
|
224
|
+
let label = STATE_LABEL[s] || s;
|
|
225
|
+
if (s === "working" && a.working_s) label = "working " + fmtDur(a.working_s);
|
|
226
|
+
const dot = s === "working" ? '<span class="dotpulse"></span>' : "";
|
|
227
|
+
const stp = `<span class="pill st-${s}" title="${esc(STATE_LABEL[s] || s)}">${dot}${esc(label)}</span>`;
|
|
228
|
+
const un = a.unread ? `<span class="pill busy">${a.unread} unread</span>` : "";
|
|
229
|
+
const q = a.queue_depth ? `<span class="pill mute">${a.queue_depth} queued</span>` : "";
|
|
230
|
+
// A down agent gets a Start button; a running one gets a Stop button.
|
|
231
|
+
const act = a.running
|
|
232
|
+
? `<button class="pill downbtn" data-down="${esc(a.name)}">■ Stop</button>`
|
|
233
|
+
: `<button class="pill upbtn" data-up="${esc(a.name)}">▶ Start</button>`;
|
|
234
|
+
// A stalled agent (busy past its timeout -- completion signal lost) gets the
|
|
235
|
+
// fix inline: nudge it with Escape, or restart it, without digging into the
|
|
236
|
+
// Terminal tab. Wired through the delegated document listener (data-esc /
|
|
237
|
+
// data-restart) so poll-recreated buttons keep working.
|
|
238
|
+
const recover = s === "stalled"
|
|
239
|
+
? `<button class="pill recover" data-esc="${esc(a.name)}" title="Send Escape to unstick the turn">⎋ Esc</button>`
|
|
240
|
+
+ `<button class="pill recover" data-restart="${esc(a.name)}" title="Restart this agent's session">↻ Restart</button>`
|
|
241
|
+
: "";
|
|
242
|
+
return stp + act + recover + un + q;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function renderAgents() {
|
|
246
|
+
// Clear any prior poll timers so a poll-triggered re-render can't stack them.
|
|
247
|
+
if (timers.status) { clearInterval(timers.status); delete timers.status; }
|
|
248
|
+
if (timers.rate) { clearInterval(timers.rate); delete timers.rate; }
|
|
249
|
+
const agents = (state.status && state.status.agents) || [];
|
|
250
|
+
const cards = agents.map((a) => `
|
|
251
|
+
<div class="card agentcard" data-agent="${esc(a.name)}">
|
|
252
|
+
<div class="top">
|
|
253
|
+
${avatar(a.name)}
|
|
254
|
+
<div style="min-width:0">
|
|
255
|
+
<div class="name">${esc(a.name)}</div>
|
|
256
|
+
<div class="muted" style="font-size:.8rem">${esc(a.type)}</div>
|
|
257
|
+
</div>
|
|
258
|
+
${state.rate ? `<span class="rateline" data-rateline="${esc(a.name)}"></span>` : ""}
|
|
259
|
+
</div>
|
|
260
|
+
<div class="role" data-role="${esc(a.name)}">${esc(a.role_preview || "")}</div>
|
|
261
|
+
<div class="meta">${statusPills(a)}</div>
|
|
262
|
+
<div class="muted" style="font-size:.78rem">talks to: ${esc((a.can_talk_to || []).join(", ") || "—")}</div>
|
|
263
|
+
</div>`).join("");
|
|
264
|
+
const notifyTgl = ("Notification" in window)
|
|
265
|
+
? `<label class="tgl"><input type="checkbox" id="notifyTgl" ${state.notify ? "checked" : ""}/> Notify</label>` : "";
|
|
266
|
+
// Bulk controls only make sense once the swarm has agents to act on.
|
|
267
|
+
const bulk = agents.length ? `
|
|
268
|
+
<button class="btn ghost sm bulk-btn" id="startAll" title="Start every stopped agent">Start all</button>
|
|
269
|
+
<button class="btn ghost sm bulk-btn" id="stopAll" title="Stop every running agent">Stop all</button>
|
|
270
|
+
<button class="btn ghost sm bulk-btn" id="restartAll" title="Restart every agent">Restart all</button>` : "";
|
|
271
|
+
const body = cards
|
|
272
|
+
? `<div class="grid">${cards}</div>`
|
|
273
|
+
: `<div id="emptyAgents"><p class="empty">Loading templates…</p></div>`;
|
|
274
|
+
$("view").innerHTML = `
|
|
275
|
+
<div class="sectiontitle">
|
|
276
|
+
<h2>Agents <span class="muted" style="font-weight:500">(${agents.length})</span></h2>
|
|
277
|
+
<div class="agents-tools">
|
|
278
|
+
<label class="tgl" title="Show each agent's recent message rate"><input type="checkbox" id="rateTgl" ${state.rate ? "checked" : ""}/> Show rate</label>
|
|
279
|
+
${notifyTgl}
|
|
280
|
+
${bulk}
|
|
281
|
+
<button class="btn ghost sm" id="refreshBtn">Refresh</button>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
${topologyCard(agents)}
|
|
285
|
+
${body}`;
|
|
286
|
+
$("refreshBtn").onclick = pollStatus;
|
|
287
|
+
if ($("startAll")) $("startAll").onclick = () => bulkAction("up");
|
|
288
|
+
if ($("stopAll")) $("stopAll").onclick = () => bulkAction("down");
|
|
289
|
+
if ($("restartAll")) $("restartAll").onclick = () => bulkAction("restart");
|
|
290
|
+
$("rateTgl").onchange = (e) => toggleRate(e.target.checked);
|
|
291
|
+
if ($("notifyTgl")) $("notifyTgl").onchange = (e) => toggleNotify(e.target.checked);
|
|
292
|
+
if (!cards) loadTemplates();
|
|
293
|
+
if (state.rate) { loadRates(); timers.rate = setInterval(loadRates, 5000); }
|
|
294
|
+
for (const c of document.querySelectorAll(".agentcard"))
|
|
295
|
+
c.onclick = (e) => {
|
|
296
|
+
// The Start/Stop and stalled-recovery pills live inside the card; the
|
|
297
|
+
// delegated document listener handles them. Its stopPropagation fires too
|
|
298
|
+
// late to stop this (closer) handler, so skip opening the mail page for
|
|
299
|
+
// those clicks here.
|
|
300
|
+
if (e.target.closest("[data-up],[data-down],[data-esc],[data-restart]")) return;
|
|
301
|
+
openAgent(c.dataset.agent);
|
|
302
|
+
};
|
|
303
|
+
for (const g of document.querySelectorAll(".gnode")) {
|
|
304
|
+
g.onclick = () => openAgent(g.dataset.agent);
|
|
305
|
+
g.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAgent(g.dataset.agent); } };
|
|
306
|
+
}
|
|
307
|
+
// Lazily enrich each card's role text (kept out of /api/status to stay light).
|
|
308
|
+
agents.forEach((a) => apiGet("/api/agent?agent=" + encodeURIComponent(a.name))
|
|
309
|
+
.then((d) => { const n = document.querySelector(`[data-role="${cssq(a.name)}"]`); if (n) n.textContent = (d.agent && d.agent.role) || "(no role set)"; })
|
|
310
|
+
.catch(() => {}));
|
|
311
|
+
timers.status = setInterval(pollStatus, 5000);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function cssq(s) { return String(s).replace(/"/g, '\\"'); }
|
|
315
|
+
|
|
316
|
+
// ---- "the swarm needs you" signal (#3) ---------------------------------
|
|
317
|
+
|
|
318
|
+
// Fire a browser notification only on the 0 -> >0 edge, and only when opted in.
|
|
319
|
+
function maybeNotify(attention) {
|
|
320
|
+
const prev = state.lastAttention || 0;
|
|
321
|
+
state.lastAttention = attention;
|
|
322
|
+
if (!state.notify || !("Notification" in window)) return;
|
|
323
|
+
if (attention > 0 && prev === 0 && Notification.permission === "granted") {
|
|
324
|
+
try {
|
|
325
|
+
new Notification("Agentainer", {
|
|
326
|
+
body: attention + " message" + (attention > 1 ? "s" : "") + " awaiting your reply",
|
|
327
|
+
});
|
|
328
|
+
} catch (_) {}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function toggleNotify(on) {
|
|
333
|
+
if (on && "Notification" in window && Notification.permission === "default") {
|
|
334
|
+
Notification.requestPermission().then((p) => {
|
|
335
|
+
if (p !== "granted") {
|
|
336
|
+
state.notify = false;
|
|
337
|
+
try { localStorage.setItem("notifyOptIn", ""); } catch (_) {}
|
|
338
|
+
const t = $("notifyTgl"); if (t) t.checked = false;
|
|
339
|
+
toast("notifications blocked by the browser");
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
state.notify = on;
|
|
344
|
+
try { localStorage.setItem("notifyOptIn", on ? "1" : ""); } catch (_) {}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- optional message rate (#6, off by default) ------------------------
|
|
348
|
+
|
|
349
|
+
function toggleRate(on) {
|
|
350
|
+
state.rate = on;
|
|
351
|
+
try { localStorage.setItem("showRate", on ? "1" : ""); } catch (_) {}
|
|
352
|
+
if (state.view === "agents") renderAgents();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function loadRates() {
|
|
356
|
+
if (!state.rate) return;
|
|
357
|
+
apiGet("/api/rate?window=5").then((d) => {
|
|
358
|
+
state.rates = (d && d.rates) || {};
|
|
359
|
+
for (const el of document.querySelectorAll("[data-rateline]")) {
|
|
360
|
+
const v = state.rates[el.dataset.rateline] || 0;
|
|
361
|
+
el.textContent = v.toFixed(1) + "/min";
|
|
362
|
+
}
|
|
363
|
+
}).catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- onboarding: start from a template (#4) ----------------------------
|
|
367
|
+
|
|
368
|
+
function loadTemplates() {
|
|
369
|
+
const box = $("emptyAgents"); if (!box) return;
|
|
370
|
+
const fallback = '<p class="empty">No agents configured. Add one in Settings.</p>';
|
|
371
|
+
apiGet("/api/templates").then((d) => {
|
|
372
|
+
const tpls = (d && d.templates) || [];
|
|
373
|
+
if (!tpls.length) { box.innerHTML = fallback; return; }
|
|
374
|
+
box.innerHTML = `
|
|
375
|
+
<div class="tpl-intro"><b>Start from a template</b><span class="muted"> — or add your own in Settings.</span></div>
|
|
376
|
+
<div class="tplgrid">${tpls.map((x) => `
|
|
377
|
+
<button class="card tpl" data-tpl="${esc(x.name)}">
|
|
378
|
+
<div class="tpl-title">${esc(x.title || x.name)}</div>
|
|
379
|
+
<div class="muted tpl-sum">${esc(x.summary || "")}</div>
|
|
380
|
+
<div class="tpl-meta muted">${esc(String(x.agents || 0))} agent${x.agents === 1 ? "" : "s"}</div>
|
|
381
|
+
</button>`).join("")}</div>`;
|
|
382
|
+
for (const b of box.querySelectorAll("[data-tpl]"))
|
|
383
|
+
b.onclick = () => applyTemplate(b.dataset.tpl, b);
|
|
384
|
+
}).catch(() => { box.innerHTML = fallback; });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function applyTemplate(name, btn) {
|
|
388
|
+
if (btn) btn.disabled = true;
|
|
389
|
+
apiPost("/api/templates/apply", { name }).then((res) => {
|
|
390
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); if (btn) btn.disabled = false; return; }
|
|
391
|
+
toast("added " + ((res.j.added || []).length) + " agents from " + name);
|
|
392
|
+
pollStatus();
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Bring one agent up (from a `.upbtn` on a card or the mail header).
|
|
397
|
+
function startAgent(name, btn) {
|
|
398
|
+
if (btn) { btn.disabled = true; btn.textContent = "starting…"; }
|
|
399
|
+
apiPost("/api/up", { agent: name }).then((res) => {
|
|
400
|
+
if (!res.ok) {
|
|
401
|
+
toast("error: " + (res.j.error || "failed"));
|
|
402
|
+
if (btn) { btn.disabled = false; btn.textContent = "▶ Start"; }
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
toast(res.j.started ? "starting " + name : name + " already running");
|
|
406
|
+
// Give the CLI a moment to open its tmux session, then refresh.
|
|
407
|
+
setTimeout(() => { pollStatus(); refreshMailStatus(); }, 800);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Take one agent down (kill its tmux session). Config is left untouched.
|
|
412
|
+
function stopAgent(name, btn) {
|
|
413
|
+
if (!confirm("Stop " + name + "? Its tmux session (and any in-flight turn) will be killed.")) return;
|
|
414
|
+
if (btn) { btn.disabled = true; btn.textContent = "stopping…"; }
|
|
415
|
+
apiPost("/api/down", { agent: name }).then((res) => {
|
|
416
|
+
if (!res.ok) {
|
|
417
|
+
toast("error: " + (res.j.error || "failed"));
|
|
418
|
+
if (btn) { btn.disabled = false; btn.textContent = "■ Stop"; }
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
toast(res.j.stopped ? "stopped " + name : name + " already down");
|
|
422
|
+
setTimeout(() => { pollStatus(); refreshMailStatus(); }, 400);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Start / stop / restart every agent at once (buttons in the Agents header).
|
|
427
|
+
// Buttons are disabled for the duration so a double-click can't fire twice.
|
|
428
|
+
function bulkAction(kind) {
|
|
429
|
+
if (kind === "down" && !confirm("Stop ALL running agents? Each tmux session (and any in-flight turn) will be killed.")) return;
|
|
430
|
+
if (kind === "restart" && !confirm("Restart ALL agents? Running sessions are killed, then every configured agent is relaunched.")) return;
|
|
431
|
+
const btns = Array.from(document.querySelectorAll(".bulk-btn"));
|
|
432
|
+
btns.forEach((b) => { b.disabled = true; });
|
|
433
|
+
let p;
|
|
434
|
+
if (kind === "up") {
|
|
435
|
+
p = apiPost("/api/up_all", {}).then((res) => bulkToast(res, "started"));
|
|
436
|
+
} else if (kind === "down") {
|
|
437
|
+
p = apiPost("/api/down_all", {}).then((res) => bulkToast(res, "stopped"));
|
|
438
|
+
} else {
|
|
439
|
+
p = apiPost("/api/down_all", {}).then((res) => {
|
|
440
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
441
|
+
return apiPost("/api/up_all", {}).then((r2) => bulkToast(r2, "started", "restarted"));
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
p.catch(() => toast("network error"))
|
|
445
|
+
.finally(() => { btns.forEach((b) => { b.disabled = false; }); pollStatus(); });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function bulkToast(res, key, verb) {
|
|
449
|
+
if (!res || !res.ok) { toast("error: " + ((res && res.j.error) || "failed")); return; }
|
|
450
|
+
toast((verb || key) + " " + ((res.j[key] || []).length));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function pollStatus() {
|
|
454
|
+
apiGet("/api/status").then((data) => {
|
|
455
|
+
state.status = data;
|
|
456
|
+
banner(""); // recovered: clear any stale "connect failed" / poll error
|
|
457
|
+
syncAvailability(data.user_available);
|
|
458
|
+
maybeNotify(data.attention || 0);
|
|
459
|
+
$("swarmMeta").textContent = (data.name || "swarm") + " · " + ((data.agents || []).length) + " agents";
|
|
460
|
+
if (state.view === "agents") {
|
|
461
|
+
const agents = data.agents || [];
|
|
462
|
+
const shown = Array.from(document.querySelectorAll(".agentcard")).map((c) => c.dataset.agent);
|
|
463
|
+
const same = shown.length === agents.length && agents.every((a, i) => shown[i] === a.name);
|
|
464
|
+
if (!same) {
|
|
465
|
+
// Agent set changed (added / removed / brought up elsewhere): rebuild
|
|
466
|
+
// the whole view so new cards and the topology graph appear.
|
|
467
|
+
renderAgents();
|
|
468
|
+
} else {
|
|
469
|
+
// Same set: patch pill rows in place so we don't stomp scroll / role text.
|
|
470
|
+
agents.forEach((a) => {
|
|
471
|
+
const card = document.querySelector(`.agentcard[data-agent="${cssq(a.name)}"] .meta`);
|
|
472
|
+
if (card) card.innerHTML = statusPills(a);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}).catch((e) => banner(e.message));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---- topology graph (who-talks-to-whom) --------------------------------
|
|
480
|
+
|
|
481
|
+
function topologyCard(agents) {
|
|
482
|
+
if (!agents.length) return "";
|
|
483
|
+
return `<div class="card panel" style="margin-bottom:1rem">
|
|
484
|
+
<h3 style="margin:0 0 .4rem">Who talks to whom</h3>
|
|
485
|
+
<div style="overflow-x:auto">${drawTopology(agents)}</div></div>`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function drawTopology(agents) {
|
|
489
|
+
const byName = {};
|
|
490
|
+
agents.forEach((a) => { byName[a.name] = a; });
|
|
491
|
+
const nodes = agents.map((a) => a.name);
|
|
492
|
+
if (agents.some((a) => (a.can_talk_to || []).includes("user"))) nodes.push("user");
|
|
493
|
+
const W = 560, H = 300, cx = W / 2, cy = H / 2, r = Math.min(W, H) / 2 - 48, N = nodes.length;
|
|
494
|
+
const pos = {};
|
|
495
|
+
nodes.forEach((n, i) => {
|
|
496
|
+
const ang = -Math.PI / 2 + (i * 2 * Math.PI) / N;
|
|
497
|
+
pos[n] = { x: cx + r * Math.cos(ang), y: cy + r * Math.sin(ang) };
|
|
498
|
+
});
|
|
499
|
+
let edges = "";
|
|
500
|
+
agents.forEach((a) => (a.can_talk_to || []).forEach((p) => {
|
|
501
|
+
if (pos[a.name] && pos[p]) {
|
|
502
|
+
const s = pos[a.name], t = pos[p];
|
|
503
|
+
const dx = t.x - s.x, dy = t.y - s.y, len = Math.hypot(dx, dy) || 1, R = 21;
|
|
504
|
+
// Live flow: an edge INTO a node that has unread mail is "carrying" mail.
|
|
505
|
+
const live = byName[p] && byName[p].unread > 0;
|
|
506
|
+
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${live ? " edge-live" : ""}" marker-end="url(#arrow)"/>`;
|
|
507
|
+
}
|
|
508
|
+
}));
|
|
509
|
+
const circles = nodes.map((n) => {
|
|
510
|
+
const p = pos[n];
|
|
511
|
+
const isUser = n === "user";
|
|
512
|
+
const st = isUser ? null : ((byName[n] && byName[n].state) || "waiting");
|
|
513
|
+
const fill = isUser ? "hsl(215 62% 48%)" : `hsl(${hueFor(n)} 62% 48%)`;
|
|
514
|
+
const clickable = !isUser;
|
|
515
|
+
// A status ring around the node, colored by live state (waiting = none).
|
|
516
|
+
const ring = (st && st !== "waiting")
|
|
517
|
+
? `<circle cx="${p.x}" cy="${p.y}" r="23" fill="none" class="gring gring-${st}"/>` : "";
|
|
518
|
+
const dim = st === "stopped" ? ' opacity="0.55"' : "";
|
|
519
|
+
return `<g${clickable ? ` class="gnode" data-agent="${esc(n)}" role="button" tabindex="0" aria-label="open ${esc(n)}"` : ""}${dim}>
|
|
520
|
+
${ring}
|
|
521
|
+
<circle cx="${p.x}" cy="${p.y}" r="19" fill="${fill}"/>
|
|
522
|
+
<text x="${p.x}" y="${p.y + 4}" text-anchor="middle" class="gnode-t">${esc(initials(n))}</text>
|
|
523
|
+
<text x="${p.x}" y="${p.y + 36}" text-anchor="middle" class="gnode-l">${esc(n === "user" ? "you" : n)}</text></g>`;
|
|
524
|
+
}).join("");
|
|
525
|
+
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">
|
|
526
|
+
<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
527
|
+
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker></defs>
|
|
528
|
+
<g class="edges">${edges}</g>${circles}</svg>`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---- activity timeline (global event log) ------------------------------
|
|
532
|
+
|
|
533
|
+
const KIND_CLASS = {
|
|
534
|
+
route: "ok", delivered: "ok", "user-send": "ok", read: "mute", "read-receipt": "mute",
|
|
535
|
+
bounce: "no", "rate-limited": "no", ping: "busy", "user-held": "busy",
|
|
536
|
+
"user-available": "ok", "user-away": "mute",
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
function renderActivity() {
|
|
540
|
+
$("view").innerHTML = `
|
|
541
|
+
<div class="sectiontitle">
|
|
542
|
+
<h2>Activity</h2>
|
|
543
|
+
<button class="btn ghost sm" id="refreshBtn">Refresh</button>
|
|
544
|
+
</div>
|
|
545
|
+
<div class="card" style="padding:.3rem .2rem"><div id="timeline" class="timeline"><p class="empty">Loading…</p></div></div>`;
|
|
546
|
+
$("refreshBtn").onclick = loadTimeline;
|
|
547
|
+
loadTimeline();
|
|
548
|
+
timers.activity = setInterval(loadTimeline, 5000);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function loadTimeline() {
|
|
552
|
+
apiGet("/api/logs?n=250").then((d) => {
|
|
553
|
+
const logs = (d.logs || []).slice().reverse(); // newest first
|
|
554
|
+
const box = $("timeline"); if (!box) return;
|
|
555
|
+
box.innerHTML = logs.map((r) => {
|
|
556
|
+
const kind = r.kind || "?";
|
|
557
|
+
const cls = KIND_CLASS[kind] || "mute";
|
|
558
|
+
const route = [r.from_, r.to].filter(Boolean).join(" → ");
|
|
559
|
+
const extra = [route, r.id, r.reason].filter(Boolean).map(esc).join(" · ");
|
|
560
|
+
return `<div class="event">
|
|
561
|
+
<span class="t">${esc(fmtTime(r.ts))}</span>
|
|
562
|
+
<span class="pill ${cls}">${esc(kind)}</span>
|
|
563
|
+
<b>${esc(r.agent || "")}</b>
|
|
564
|
+
<span class="muted">${extra}</span></div>`;
|
|
565
|
+
}).join("") || '<p class="empty">No events yet.</p>';
|
|
566
|
+
}).catch((e) => banner(e.message));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---- mail app ----------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
function openAgent(name) {
|
|
572
|
+
state.agent = name;
|
|
573
|
+
state.peer = "user";
|
|
574
|
+
state.tab = "mail";
|
|
575
|
+
go("mail");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function orderedContacts(contacts) {
|
|
579
|
+
const rank = (c) => (c.name === "user" ? 0 : c.kind === "agent" ? 1 : 2);
|
|
580
|
+
return contacts.slice().sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function renderMail() {
|
|
584
|
+
clearTimers();
|
|
585
|
+
const agent = state.agent;
|
|
586
|
+
$("view").innerHTML = `
|
|
587
|
+
<div class="mailhead">
|
|
588
|
+
<button class="btn ghost sm" id="backBtn">← Agents</button>
|
|
589
|
+
<div class="who">${avatar(agent, "sm")}<h2>${esc(agent)}</h2><span id="mailStatus"></span></div>
|
|
590
|
+
<span style="flex:1"></span>
|
|
591
|
+
<div class="tabs">
|
|
592
|
+
<button class="tab ${state.tab === "mail" ? "active" : ""}" data-tab="mail">Mail</button>
|
|
593
|
+
<button class="tab ${state.tab === "terminal" ? "active" : ""}" data-tab="terminal">Terminal</button>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
<div id="agentBody"></div>`;
|
|
597
|
+
$("backBtn").onclick = () => go("agents");
|
|
598
|
+
for (const b of document.querySelectorAll(".tab"))
|
|
599
|
+
b.onclick = () => { state.tab = b.dataset.tab; renderMail(); };
|
|
600
|
+
refreshMailStatus();
|
|
601
|
+
if (state.tab === "terminal") renderTerminalTab();
|
|
602
|
+
else renderMailTab();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function renderMailTab() {
|
|
606
|
+
document.body.dataset.pane = "list";
|
|
607
|
+
$("agentBody").innerHTML = `
|
|
608
|
+
<div class="mail">
|
|
609
|
+
<div class="card contacts" id="contacts"></div>
|
|
610
|
+
<div class="thread-wrap">
|
|
611
|
+
<div class="card thread">
|
|
612
|
+
<div class="scroll" id="threadScroll"><p class="empty">Select a contact.</p></div>
|
|
613
|
+
<div id="composeArea"></div>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
</div>`;
|
|
617
|
+
loadContacts();
|
|
618
|
+
loadThread();
|
|
619
|
+
timers.mail = setInterval(() => { loadContacts(); loadThread(); refreshMailStatus(); }, 4000);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function renderTerminalTab() {
|
|
623
|
+
document.body.dataset.pane = "thread";
|
|
624
|
+
$("agentBody").innerHTML = `
|
|
625
|
+
<div class="card terminal">
|
|
626
|
+
<div class="thead">
|
|
627
|
+
<b>Live terminal · ${esc(state.agent)}</b>
|
|
628
|
+
<label class="wrapchk"><input type="checkbox" id="wrapPane" ${state.wrap ? "checked" : ""}/> Wrap text</label>
|
|
629
|
+
<span class="muted" style="font-size:.78rem">capture-pane · refreshes 2s</span>
|
|
630
|
+
</div>
|
|
631
|
+
<pre class="pane${state.wrap ? " wrap" : ""}" id="pane">— loading —</pre>
|
|
632
|
+
<div class="keyrow">${KEYS.map((k) => `<button class="keybtn" data-key="${esc(k.key)}" title="${esc(k.title)}">${esc(k.label)}</button>`).join("")}</div>
|
|
633
|
+
<div class="typerow">
|
|
634
|
+
<input class="field" id="typeText" placeholder="Type straight into ${esc(state.agent)}'s session, press Enter…" />
|
|
635
|
+
<button class="btn" id="typeSend">Send</button>
|
|
636
|
+
</div>
|
|
637
|
+
<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>
|
|
638
|
+
</div>`;
|
|
639
|
+
const inp = $("typeText");
|
|
640
|
+
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); sendType(); } });
|
|
641
|
+
$("typeSend").onclick = sendType;
|
|
642
|
+
for (const b of document.querySelectorAll(".keybtn")) b.onclick = () => sendKey(b.dataset.key);
|
|
643
|
+
$("wrapPane").onchange = (e) => {
|
|
644
|
+
state.wrap = e.target.checked;
|
|
645
|
+
try { localStorage.setItem("paneWrap", state.wrap ? "1" : ""); } catch (_) {}
|
|
646
|
+
$("pane").classList.toggle("wrap", state.wrap);
|
|
647
|
+
};
|
|
648
|
+
loadPane();
|
|
649
|
+
timers.pane = setInterval(() => { loadPane(); refreshMailStatus(); }, 2000);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function refreshMailStatus() {
|
|
653
|
+
apiGet("/api/agent?agent=" + encodeURIComponent(state.agent)).then((d) => {
|
|
654
|
+
const a = d.agent; if (!a) return;
|
|
655
|
+
const s = $("mailStatus"); if (s) s.innerHTML = statusPills(a);
|
|
656
|
+
}).catch(() => {});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function loadContacts() {
|
|
660
|
+
apiGet("/api/contacts?agent=" + encodeURIComponent(state.agent)).then((d) => {
|
|
661
|
+
state.contacts = orderedContacts(d.contacts || []);
|
|
662
|
+
renderContacts();
|
|
663
|
+
}).catch((e) => banner(e.message));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function renderContacts() {
|
|
667
|
+
const box = $("contacts"); if (!box) return;
|
|
668
|
+
box.innerHTML = state.contacts.map((c) => {
|
|
669
|
+
const label = c.name === "user" ? "You (operator)" : c.name;
|
|
670
|
+
const time = c.last_time ? `<span class="t">${esc(fmtTime(c.last_time))}</span>` : "";
|
|
671
|
+
const badge = c.unread ? `<span class="badge">${c.unread}</span>` : "";
|
|
672
|
+
return `
|
|
673
|
+
<div class="contact ${c.name === state.peer ? "active" : ""}" data-peer="${esc(c.name)}">
|
|
674
|
+
${avatar(c.name, "sm")}
|
|
675
|
+
<div class="info">
|
|
676
|
+
<div class="cn"><b>${esc(label)}</b>${time}</div>
|
|
677
|
+
<div class="prev">${esc(c.last_preview || (c.count ? "" : "no messages yet"))}</div>
|
|
678
|
+
</div>
|
|
679
|
+
${badge}
|
|
680
|
+
</div>`;
|
|
681
|
+
}).join("") || '<p class="empty">No contacts.</p>';
|
|
682
|
+
for (const node of box.querySelectorAll(".contact"))
|
|
683
|
+
node.onclick = () => selectPeer(node.dataset.peer);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function selectPeer(peer) {
|
|
687
|
+
state.peer = peer;
|
|
688
|
+
document.body.dataset.pane = "thread";
|
|
689
|
+
renderContacts();
|
|
690
|
+
loadThread();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function loadThread() {
|
|
694
|
+
if (!state.peer) return;
|
|
695
|
+
apiGet(`/api/thread?agent=${encodeURIComponent(state.agent)}&peer=${encodeURIComponent(state.peer)}`)
|
|
696
|
+
.then((d) => { state.thread = d.messages || []; renderThread(); })
|
|
697
|
+
.catch((e) => banner(e.message));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function renderThread() {
|
|
701
|
+
const scroll = $("threadScroll"); if (!scroll) return;
|
|
702
|
+
// Idempotent: the 4s mail poll calls this even when nothing changed.
|
|
703
|
+
// Reassigning innerHTML rebuilds every node and wipes any text the user
|
|
704
|
+
// has selected, so bail out when the thread is byte-for-byte the same.
|
|
705
|
+
const sig = JSON.stringify(state.thread.map((m) => [m.from, m.to, m.time, m.status, m.body]));
|
|
706
|
+
if (scroll.dataset.sig === sig) { renderCompose(); return; }
|
|
707
|
+
scroll.dataset.sig = sig;
|
|
708
|
+
const atBottom = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 60;
|
|
709
|
+
if (!state.thread.length) {
|
|
710
|
+
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>`;
|
|
711
|
+
} else {
|
|
712
|
+
scroll.innerHTML = state.thread.map((m) => {
|
|
713
|
+
const cls = m.from === "system" ? "system" : m.direction === "out" ? "out" : "in";
|
|
714
|
+
const head = cls === "system" ? "system" : `${esc(m.from)} → ${esc(m.to)} · ${esc(fmtTime(m.time))}`;
|
|
715
|
+
const status = cls === "system" ? "" : statusTag(m.status);
|
|
716
|
+
return `<div class="msg ${cls}"><div class="m-head">${head}</div>${mailBody(m.body)}${status}</div>`;
|
|
717
|
+
}).join("");
|
|
718
|
+
for (const btn of scroll.querySelectorAll(".m-more")) btn.onclick = () => toggleCollapse(btn);
|
|
719
|
+
}
|
|
720
|
+
if (atBottom) scroll.scrollTop = scroll.scrollHeight;
|
|
721
|
+
renderCompose();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Long mails are hard to scroll past, so bodies over COLLAPSE_LINES lines are
|
|
725
|
+
// clipped to the first COLLAPSE_LINES with a "Show N more lines" toggle. Both
|
|
726
|
+
// the short and full renders are prebuilt; the button just swaps which shows.
|
|
727
|
+
const COLLAPSE_LINES = 10;
|
|
728
|
+
function mailBody(raw) {
|
|
729
|
+
const src = (raw || "").trim();
|
|
730
|
+
const lines = src.split("\n");
|
|
731
|
+
if (lines.length <= COLLAPSE_LINES)
|
|
732
|
+
return `<div class="m-body">${md(src)}</div>`;
|
|
733
|
+
const hidden = lines.length - COLLAPSE_LINES;
|
|
734
|
+
const short = md(lines.slice(0, COLLAPSE_LINES).join("\n"));
|
|
735
|
+
const full = md(src);
|
|
736
|
+
return `<div class="m-body collapsible" data-collapsed="1">`
|
|
737
|
+
+ `<div class="m-short">${short}</div>`
|
|
738
|
+
+ `<div class="m-full" hidden>${full}</div>`
|
|
739
|
+
+ `<button class="m-more" type="button" data-more="${hidden}">Show ${hidden} more line${hidden > 1 ? "s" : ""}</button>`
|
|
740
|
+
+ `</div>`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function toggleCollapse(btn) {
|
|
744
|
+
const box = btn.closest(".collapsible"); if (!box) return;
|
|
745
|
+
const collapse = box.dataset.collapsed !== "1"; // flip current state
|
|
746
|
+
box.dataset.collapsed = collapse ? "1" : "0";
|
|
747
|
+
box.querySelector(".m-short").hidden = !collapse;
|
|
748
|
+
box.querySelector(".m-full").hidden = collapse;
|
|
749
|
+
const n = btn.dataset.more;
|
|
750
|
+
btn.textContent = collapse ? `Show ${n} more line${n > 1 ? "s" : ""}` : "Show less";
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Delivery status of one message, from where it currently sits in the mailroom.
|
|
754
|
+
function statusTag(s) {
|
|
755
|
+
const map = {
|
|
756
|
+
queued: ["◷", "waiting"], delivered: ["✓", "delivered"],
|
|
757
|
+
read: ["✓✓", "read"], archived: ["⤓", "archived"],
|
|
758
|
+
};
|
|
759
|
+
const e = map[s];
|
|
760
|
+
return e ? `<div class="m-status s-${s}">${e[0]} ${e[1]}</div>` : "";
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function renderCompose() {
|
|
764
|
+
const area = $("composeArea"); if (!area) return;
|
|
765
|
+
const mode = state.peer === "user" ? "compose" : "note";
|
|
766
|
+
// Idempotent: background polls call this every few seconds. Rebuilding the
|
|
767
|
+
// textarea would wipe whatever the user is typing (and drop focus), so only
|
|
768
|
+
// touch the DOM when the mode actually changes.
|
|
769
|
+
if (area.dataset.mode === mode) return;
|
|
770
|
+
area.dataset.mode = mode;
|
|
771
|
+
if (mode === "compose") {
|
|
772
|
+
area.innerHTML = `
|
|
773
|
+
<div class="compose">
|
|
774
|
+
<textarea class="field" id="reply" rows="1" placeholder="Message ${esc(state.agent)} as the user…"></textarea>
|
|
775
|
+
<button class="btn" id="sendReply">Send</button>
|
|
776
|
+
</div>`;
|
|
777
|
+
const ta = $("reply");
|
|
778
|
+
ta.addEventListener("input", () => { ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight, 240) + "px"; });
|
|
779
|
+
ta.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendReply(); } });
|
|
780
|
+
$("sendReply").onclick = sendReply;
|
|
781
|
+
} else {
|
|
782
|
+
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>`;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function sendReply() {
|
|
787
|
+
const text = $("reply").value;
|
|
788
|
+
if (!text.trim()) return;
|
|
789
|
+
apiPost("/api/send", { to: state.agent, text }).then((res) => {
|
|
790
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
791
|
+
$("reply").value = ""; $("reply").style.height = "auto";
|
|
792
|
+
toast("sent to " + state.agent);
|
|
793
|
+
loadThread(); loadContacts();
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ---- terminal (live pane + direct type-in) -----------------------------
|
|
798
|
+
|
|
799
|
+
// Control keys offered under the pane. `key` is a tmux key name the backend
|
|
800
|
+
// whitelists (lib/tmux.py ALLOWED_KEYS).
|
|
801
|
+
const KEYS = [
|
|
802
|
+
{ key: "Escape", label: "Esc", title: "Escape" },
|
|
803
|
+
{ key: "Enter", label: "Enter", title: "Enter / Return" },
|
|
804
|
+
{ key: "Tab", label: "Tab", title: "Tab" },
|
|
805
|
+
{ key: "Up", label: "↑", title: "Up arrow" },
|
|
806
|
+
{ key: "Down", label: "↓", title: "Down arrow" },
|
|
807
|
+
{ key: "Left", label: "←", title: "Left arrow" },
|
|
808
|
+
{ key: "Right", label: "→", title: "Right arrow" },
|
|
809
|
+
{ key: "C-c", label: "Ctrl-C", title: "Interrupt (Ctrl-C)" },
|
|
810
|
+
{ key: "C-u", label: "Ctrl-U", title: "Clear line (Ctrl-U)" },
|
|
811
|
+
{ key: "C-l", label: "Ctrl-L", title: "Redraw / clear (Ctrl-L)" },
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
// `agent` is optional: the Terminal keypad omits it (targets the open agent),
|
|
815
|
+
// the stalled-recovery ⎋ Esc button passes the card's name explicitly.
|
|
816
|
+
function sendKey(key, agent) {
|
|
817
|
+
const name = agent || state.agent;
|
|
818
|
+
apiPost("/api/key", { agent: name, key }).then((res) => {
|
|
819
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
820
|
+
toast(key + " → " + name);
|
|
821
|
+
if (name === state.agent) setTimeout(loadPane, 300);
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Restart one agent by name (stop, then start) -- the stalled-recovery action.
|
|
826
|
+
function restartAgent(name) {
|
|
827
|
+
toast("restarting " + name + "…");
|
|
828
|
+
apiPost("/api/down", { agent: name }).then((res) => {
|
|
829
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
830
|
+
apiPost("/api/up", { agent: name }).then((r2) => {
|
|
831
|
+
if (!r2.ok) { toast("error: " + (r2.j.error || "failed")); return; }
|
|
832
|
+
toast("restarted " + name);
|
|
833
|
+
setTimeout(() => { pollStatus(); refreshMailStatus(); }, 800);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function loadPane() {
|
|
839
|
+
apiGet("/api/pane?agent=" + encodeURIComponent(state.agent)).then((d) => {
|
|
840
|
+
const p = $("pane"); if (p) p.textContent = d.pane || "— (empty / session down) —";
|
|
841
|
+
}).catch(() => {});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function sendType() {
|
|
845
|
+
const text = $("typeText").value;
|
|
846
|
+
if (!text.trim()) return;
|
|
847
|
+
apiPost("/api/type", { agent: state.agent, text }).then((res) => {
|
|
848
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
849
|
+
$("typeText").value = "";
|
|
850
|
+
toast(res.j.ok ? "typed into " + state.agent : "sent (not confirmed in pane)");
|
|
851
|
+
setTimeout(loadPane, 400);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ---- settings ----------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
const SWARM_SCHEMA = [
|
|
858
|
+
["name", "Swarm name", "text"],
|
|
859
|
+
["session_prefix", "tmux session prefix", "text"],
|
|
860
|
+
["supervise", "Liveness supervisor", "bool"],
|
|
861
|
+
["supervise_interval_ms", "Supervise interval (ms)", "num"],
|
|
862
|
+
["ready_timeout_ms", "Ready timeout (ms)", "num"],
|
|
863
|
+
["busy_timeout_ms", "Busy timeout (ms)", "num"],
|
|
864
|
+
["resume", "Resume sessions on up", "bool"],
|
|
865
|
+
["pane_idle_ms", "Pane idle (ms)", "num"],
|
|
866
|
+
["pane_poll_ms", "Pane poll (ms)", "num"],
|
|
867
|
+
["pane_scrollback", "Pane scrollback lines", "num"],
|
|
868
|
+
["enter_delay_ms", "Enter delay (ms)", "num"],
|
|
869
|
+
["send_delay_ms", "Send delay (ms)", "num"],
|
|
870
|
+
["tmux_mouse", "tmux mouse mode", "bool"],
|
|
871
|
+
];
|
|
872
|
+
const SWARM_DEFAULTS = {
|
|
873
|
+
supervise: true, supervise_interval_ms: 15000, ready_timeout_ms: 60000,
|
|
874
|
+
busy_timeout_ms: 900000, resume: false, pane_idle_ms: 2500, pane_poll_ms: 700,
|
|
875
|
+
pane_scrollback: 400, enter_delay_ms: 250, send_delay_ms: 150, tmux_mouse: true,
|
|
876
|
+
session_prefix: "", name: "",
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
function renderSettings() {
|
|
880
|
+
Promise.all([apiGet("/api/config"), apiGet("/api/telegram").catch(() => null)]).then(([cfg, tg]) => {
|
|
881
|
+
state.config = cfg;
|
|
882
|
+
state.telegram = tg;
|
|
883
|
+
const sw = cfg.swarm || {};
|
|
884
|
+
const fields = SWARM_SCHEMA.map(([key, label, type]) => {
|
|
885
|
+
const val = key in sw ? sw[key] : SWARM_DEFAULTS[key];
|
|
886
|
+
if (type === "bool")
|
|
887
|
+
return `<div class="fld"><label>${esc(label)}</label>
|
|
888
|
+
<label class="row"><input type="checkbox" data-swarm="${key}" ${val ? "checked" : ""}/> <span class="muted">${key}</span></label></div>`;
|
|
889
|
+
return `<div class="fld"><label>${esc(label)}</label>
|
|
890
|
+
<input class="field" data-swarm="${key}" type="${type === "num" ? "number" : "text"}" value="${esc(val == null ? "" : val)}"/></div>`;
|
|
891
|
+
}).join("");
|
|
892
|
+
const agents = (cfg.agents || []).map((a) => `
|
|
893
|
+
<div class="agentrow">
|
|
894
|
+
${avatar(String(a.name), "sm")}
|
|
895
|
+
<div class="info">
|
|
896
|
+
<b>${esc(a.name)}</b> <span class="muted">${esc(a.type || "claude")}</span>
|
|
897
|
+
<div class="muted" style="font-size:.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">talks to: ${esc(fmtCanTalk(a.can_talk_to))}</div>
|
|
898
|
+
</div>
|
|
899
|
+
<button class="btn ghost sm" data-edit="${esc(a.name)}">Edit</button>
|
|
900
|
+
<button class="btn danger sm" data-del="${esc(a.name)}">Delete</button>
|
|
901
|
+
</div>`).join("");
|
|
902
|
+
$("view").innerHTML = `
|
|
903
|
+
<div class="settings">
|
|
904
|
+
<div class="card panel">
|
|
905
|
+
<h3>Swarm settings</h3>
|
|
906
|
+
<p class="muted" style="margin-top:.1rem">Saved straight to <code>${esc(cfg.path || "agentainer.yaml")}</code>.</p>
|
|
907
|
+
<div class="formgrid">${fields}</div>
|
|
908
|
+
<div class="rowend"><button class="btn" id="saveSwarm">Save settings</button></div>
|
|
909
|
+
</div>
|
|
910
|
+
${telegramCard(tg, cfg.agents || [])}
|
|
911
|
+
<div class="card panel">
|
|
912
|
+
<div class="sectiontitle"><h3>Agents</h3><button class="btn sm" id="addAgent">+ Add agent</button></div>
|
|
913
|
+
${agents || '<p class="muted">No agents yet.</p>'}
|
|
914
|
+
</div>
|
|
915
|
+
</div>`;
|
|
916
|
+
$("saveSwarm").onclick = saveSwarm;
|
|
917
|
+
$("addAgent").onclick = () => openAgentForm(null);
|
|
918
|
+
for (const b of document.querySelectorAll("[data-edit]")) b.onclick = () => openAgentForm(b.dataset.edit);
|
|
919
|
+
for (const b of document.querySelectorAll("[data-del]")) b.onclick = () => deleteAgent(b.dataset.del);
|
|
920
|
+
wireTelegram();
|
|
921
|
+
}).catch((e) => banner(e.message));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function telegramCard(tg, agents) {
|
|
925
|
+
if (!tg) return "";
|
|
926
|
+
const allScope = tg.mirror === "*" || (Array.isArray(tg.mirror) && tg.mirror.includes("*"));
|
|
927
|
+
const sel = Array.isArray(tg.mirror) ? tg.mirror : [];
|
|
928
|
+
const checks = agents.map((a) => `
|
|
929
|
+
<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("");
|
|
930
|
+
return `
|
|
931
|
+
<div class="card panel">
|
|
932
|
+
<div class="sectiontitle"><h3>Telegram bridge</h3>
|
|
933
|
+
<span class="pill ${tg.enabled ? "ok" : "mute"}">${tg.enabled ? "on" : "off"}</span></div>
|
|
934
|
+
<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>
|
|
935
|
+
<div class="formgrid">
|
|
936
|
+
<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>
|
|
937
|
+
<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>
|
|
938
|
+
<div class="fld"><label>Chat ID</label><input class="field" id="tg_chat" value="${esc(tg.chat_id || "")}" placeholder="e.g. 123456789"/></div>
|
|
939
|
+
<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>
|
|
940
|
+
<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>
|
|
941
|
+
</div>
|
|
942
|
+
<div class="fld" style="margin-top:.6rem"><label>Which agents to mirror</label>
|
|
943
|
+
<div class="row">
|
|
944
|
+
<label class="row" style="gap:.3rem"><input type="radio" name="tgscope" value="all" ${allScope ? "checked" : ""}/> all agents</label>
|
|
945
|
+
<label class="row" style="gap:.3rem"><input type="radio" name="tgscope" value="sel" ${allScope ? "" : "checked"}/> selected</label>
|
|
946
|
+
</div>
|
|
947
|
+
<div class="row" id="tg_agents" style="margin-top:.4rem;opacity:${allScope ? ".5" : "1"}">${checks || '<span class="muted">no agents</span>'}</div>
|
|
948
|
+
</div>
|
|
949
|
+
<div class="rowend">
|
|
950
|
+
<button class="btn ghost" id="tg_test">Send test</button>
|
|
951
|
+
<button class="btn ghost" id="tg_poll">${tg.polling ? "Stop replies" : "Receive replies"}</button>
|
|
952
|
+
<button class="btn" id="tg_save">Save Telegram</button>
|
|
953
|
+
</div>
|
|
954
|
+
</div>`;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function wireTelegram() {
|
|
958
|
+
if (!$("tg_save")) return;
|
|
959
|
+
for (const r of document.querySelectorAll('input[name="tgscope"]'))
|
|
960
|
+
r.onchange = () => {
|
|
961
|
+
const all = document.querySelector('input[name="tgscope"]:checked').value === "all";
|
|
962
|
+
$("tg_agents").style.opacity = all ? ".5" : "1";
|
|
963
|
+
for (const c of document.querySelectorAll(".tg-agent")) c.disabled = all;
|
|
964
|
+
};
|
|
965
|
+
$("tg_save").onclick = saveTelegram;
|
|
966
|
+
$("tg_test").onclick = testTelegram;
|
|
967
|
+
$("tg_poll").onclick = toggleTelegramPolling;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function collectTelegram() {
|
|
971
|
+
const all = document.querySelector('input[name="tgscope"]:checked').value === "all";
|
|
972
|
+
const mirror = all ? "*" : Array.from(document.querySelectorAll(".tg-agent:checked")).map((c) => c.value);
|
|
973
|
+
const body = {
|
|
974
|
+
enabled: $("tg_enabled").checked,
|
|
975
|
+
chat_id: $("tg_chat").value.trim(),
|
|
976
|
+
mirror,
|
|
977
|
+
mirror_user: $("tg_muser").checked,
|
|
978
|
+
mirror_system: $("tg_msys").checked,
|
|
979
|
+
};
|
|
980
|
+
const tok = $("tg_token").value.trim();
|
|
981
|
+
if (tok) body.bot_token = tok;
|
|
982
|
+
return body;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function saveTelegram() {
|
|
986
|
+
apiPost("/api/telegram", collectTelegram()).then((res) => {
|
|
987
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
988
|
+
toast("Telegram settings saved");
|
|
989
|
+
renderSettings();
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function testTelegram() {
|
|
994
|
+
// Persist first (so the token/chat just typed are used), then send a test.
|
|
995
|
+
apiPost("/api/telegram", collectTelegram()).then(() =>
|
|
996
|
+
apiPost("/api/telegram/test", {}).then((res) => {
|
|
997
|
+
toast(res.ok ? "test message sent" : "error: " + (res.j.error || "failed"));
|
|
998
|
+
}));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function toggleTelegramPolling() {
|
|
1002
|
+
const run = !(state.telegram && state.telegram.polling);
|
|
1003
|
+
apiPost("/api/telegram/poll", { run }).then((res) => {
|
|
1004
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
1005
|
+
toast(res.j.polling ? "listening for Telegram replies" : "stopped listening");
|
|
1006
|
+
renderSettings();
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function fmtCanTalk(v) { return Array.isArray(v) ? v.join(", ") : (v || "—"); }
|
|
1011
|
+
|
|
1012
|
+
function saveSwarm() {
|
|
1013
|
+
const swarm = {};
|
|
1014
|
+
for (const node of document.querySelectorAll("[data-swarm]")) {
|
|
1015
|
+
const key = node.dataset.swarm;
|
|
1016
|
+
if (node.type === "checkbox") swarm[key] = node.checked;
|
|
1017
|
+
else if (node.type === "number") { if (node.value !== "") swarm[key] = Number(node.value); }
|
|
1018
|
+
else if (node.value !== "") swarm[key] = node.value;
|
|
1019
|
+
}
|
|
1020
|
+
apiPost("/api/config", { swarm }).then((res) => {
|
|
1021
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
1022
|
+
toast("settings saved");
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function deleteAgent(name) {
|
|
1027
|
+
if (!confirm(`Delete agent "${name}"? This stops its session and removes it from the config.`)) return;
|
|
1028
|
+
apiPost("/api/agent/remove", { name }).then((res) => {
|
|
1029
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
1030
|
+
toast("removed " + name);
|
|
1031
|
+
renderSettings(); pollStatus();
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ---- add / edit agent modal -------------------------------------------
|
|
1036
|
+
|
|
1037
|
+
const AGENT_TYPES = ["claude", "codex", "gemini", "hermes"];
|
|
1038
|
+
|
|
1039
|
+
function openAgentForm(name) {
|
|
1040
|
+
const editing = !!name;
|
|
1041
|
+
const a = editing ? (state.config.agents || []).find((x) => String(x.name) === name) || {} : {};
|
|
1042
|
+
const typeOpts = AGENT_TYPES.map((t) => `<option value="${t}" ${((a.type || "claude") === t) ? "selected" : ""}>${t}</option>`).join("");
|
|
1043
|
+
const captureOpts = ["auto", "hook", "pane", "none"].map((c) => `<option value="${c}" ${((a.capture || "auto") === c) ? "selected" : ""}>${c}</option>`).join("");
|
|
1044
|
+
const modal = el(`
|
|
1045
|
+
<div class="modal-back"><div class="card modal">
|
|
1046
|
+
<h3>${editing ? "Edit " + esc(name) : "Add agent"}</h3>
|
|
1047
|
+
<div class="formgrid">
|
|
1048
|
+
<div class="fld"><label>Name</label><input class="field" id="f_name" value="${esc(a.name || "")}" ${editing ? "disabled" : ""} placeholder="developer"/></div>
|
|
1049
|
+
<div class="fld"><label>Type</label><select class="field" id="f_type">${typeOpts}</select></div>
|
|
1050
|
+
<div class="fld"><label>Capture</label><select class="field" id="f_capture">${captureOpts}</select></div>
|
|
1051
|
+
<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>
|
|
1052
|
+
</div>
|
|
1053
|
+
<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>
|
|
1054
|
+
<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>
|
|
1055
|
+
<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>
|
|
1056
|
+
<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>
|
|
1057
|
+
<div class="rowend">
|
|
1058
|
+
<button class="btn ghost" id="f_cancel">Cancel</button>
|
|
1059
|
+
<button class="btn" id="f_save">${editing ? "Save" : "Add agent"}</button>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div></div>`);
|
|
1062
|
+
$("modalRoot").appendChild(modal);
|
|
1063
|
+
const close = () => modal.remove();
|
|
1064
|
+
modal.addEventListener("click", (e) => { if (e.target === modal) close(); });
|
|
1065
|
+
modal.querySelector("#f_cancel").onclick = close;
|
|
1066
|
+
modal.querySelector("#f_save").onclick = () => saveAgentForm(editing, name, close);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function parseTalk(raw) {
|
|
1070
|
+
raw = (raw || "").trim();
|
|
1071
|
+
if (raw === "*") return "*";
|
|
1072
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function saveAgentForm(editing, name, close) {
|
|
1076
|
+
const g = (id) => document.getElementById(id).value;
|
|
1077
|
+
const payload = {
|
|
1078
|
+
type: g("f_type"),
|
|
1079
|
+
command: g("f_command").trim(),
|
|
1080
|
+
can_talk_to: parseTalk(g("f_talk")),
|
|
1081
|
+
role: g("f_role"),
|
|
1082
|
+
capture: g("f_capture"),
|
|
1083
|
+
periodically_ping_seconds: Number(g("f_ping")) || 0,
|
|
1084
|
+
};
|
|
1085
|
+
const workdir = g("f_workdir").trim();
|
|
1086
|
+
if (workdir) payload.workdir = workdir;
|
|
1087
|
+
|
|
1088
|
+
let req;
|
|
1089
|
+
if (editing) {
|
|
1090
|
+
req = apiPost("/api/agent/edit", { name, fields: payload });
|
|
1091
|
+
} else {
|
|
1092
|
+
const n = g("f_name").trim();
|
|
1093
|
+
if (!n) { toast("name is required"); return; }
|
|
1094
|
+
if (!payload.command) { toast("command is required"); return; }
|
|
1095
|
+
req = apiPost("/api/agent/add", Object.assign({ name: n }, payload));
|
|
1096
|
+
}
|
|
1097
|
+
req.then((res) => {
|
|
1098
|
+
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
1099
|
+
toast(editing ? "saved " + name : "added " + res.j.name);
|
|
1100
|
+
close(); renderSettings(); pollStatus();
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// ---- availability ------------------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
function toggleAvailability() {
|
|
1107
|
+
const val = $("availToggle").checked;
|
|
1108
|
+
apiPost("/api/availability", { available: val }).then((res) => {
|
|
1109
|
+
if (!res.ok) { $("availToggle").checked = !val; toast("error: " + (res.j.error || "failed")); return; }
|
|
1110
|
+
syncAvailability(val);
|
|
1111
|
+
toast(val ? "you're available for mail" : "you're away");
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---- wire up -----------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
$("connect").addEventListener("click", connect);
|
|
1118
|
+
$("token").addEventListener("keydown", (e) => { if (e.key === "Enter") connect(); });
|
|
1119
|
+
$("availToggle").addEventListener("change", toggleAvailability);
|
|
1120
|
+
for (const b of document.querySelectorAll(".navbtn"))
|
|
1121
|
+
b.addEventListener("click", () => go(b.dataset.view));
|
|
1122
|
+
// Delegated: Start buttons are re-created by status polls, so listen once here
|
|
1123
|
+
// instead of re-wiring on every render. (The card's own onclick skips these
|
|
1124
|
+
// buttons directly, since this document-level handler bubbles too late to.)
|
|
1125
|
+
document.addEventListener("click", (e) => {
|
|
1126
|
+
if (!e.target.closest) return;
|
|
1127
|
+
const up = e.target.closest("[data-up]");
|
|
1128
|
+
if (up) { e.stopPropagation(); startAgent(up.dataset.up, up); return; }
|
|
1129
|
+
const down = e.target.closest("[data-down]");
|
|
1130
|
+
if (down) { e.stopPropagation(); stopAgent(down.dataset.down, down); return; }
|
|
1131
|
+
const escBtn = e.target.closest("[data-esc]");
|
|
1132
|
+
if (escBtn) { e.stopPropagation(); sendKey("Escape", escBtn.dataset.esc); return; }
|
|
1133
|
+
const restart = e.target.closest("[data-restart]");
|
|
1134
|
+
if (restart) { e.stopPropagation(); restartAgent(restart.dataset.restart); }
|
|
1135
|
+
});
|
|
1136
|
+
})();
|