agentainer 2.0.0 → 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/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/brand-voice-style-guide.yaml +109 -0
- 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/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/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/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/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/rag-builder.yaml +145 -0
- package/examples/refactor-planner.yaml +127 -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/startup-validator.yaml +115 -0
- 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/youtube-script-studio.yaml +107 -0
- package/lib/cli.py +6 -2
- package/lib/config.py +28 -11
- package/lib/mail.py +78 -13
- package/lib/reconcile.py +80 -9
- package/lib/turn.py +14 -6
- package/lib/ui.py +212 -13
- package/package.json +1 -1
- package/ui/app.js +290 -23
- package/ui/index.html +58 -2
package/ui/app.js
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
config: null, // last /api/config
|
|
22
22
|
telegram: null, // last /api/telegram
|
|
23
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
|
|
24
28
|
};
|
|
25
29
|
const timers = {};
|
|
26
30
|
|
|
@@ -54,6 +58,13 @@
|
|
|
54
58
|
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
55
59
|
: d.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
56
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
|
+
}
|
|
57
68
|
// Minimal, dependency-free, XSS-safe markdown -> HTML. Everything is escaped
|
|
58
69
|
// first (so agent/user text can never inject markup); we then emit only our
|
|
59
70
|
// own tags, and only allow http(s)/mailto links. Good enough for mail bodies.
|
|
@@ -115,14 +126,45 @@
|
|
|
115
126
|
// ---- API ---------------------------------------------------------------
|
|
116
127
|
|
|
117
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
|
+
|
|
118
160
|
function apiGet(path) {
|
|
119
|
-
return
|
|
161
|
+
return rawFetch(path, { headers: { Accept: "application/json" } }).then((r) => {
|
|
120
162
|
if (r.status === 401) throw new Error("unauthorized");
|
|
121
163
|
return r.json();
|
|
122
164
|
});
|
|
123
165
|
}
|
|
124
166
|
function apiPost(path, body) {
|
|
125
|
-
return
|
|
167
|
+
return rawFetch(path, {
|
|
126
168
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}),
|
|
127
169
|
}).then((r) => r.json().then((j) => ({ ok: r.ok, status: r.status, j })).catch(() => ({ ok: r.ok, status: r.status, j: {} })));
|
|
128
170
|
}
|
|
@@ -138,8 +180,14 @@
|
|
|
138
180
|
$("view").hidden = false;
|
|
139
181
|
$("nav").hidden = false;
|
|
140
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();
|
|
141
188
|
banner("");
|
|
142
189
|
syncAvailability(data.user_available);
|
|
190
|
+
state.lastAttention = data.attention || 0;
|
|
143
191
|
go("agents");
|
|
144
192
|
}).catch((e) => banner("connect failed: " + e.message));
|
|
145
193
|
}
|
|
@@ -163,22 +211,41 @@
|
|
|
163
211
|
|
|
164
212
|
// ---- agents overview ---------------------------------------------------
|
|
165
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
|
+
|
|
166
221
|
function statusPills(a) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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>`;
|
|
170
228
|
const un = a.unread ? `<span class="pill busy">${a.unread} unread</span>` : "";
|
|
171
229
|
const q = a.queue_depth ? `<span class="pill mute">${a.queue_depth} queued</span>` : "";
|
|
172
230
|
// A down agent gets a Start button; a running one gets a Stop button.
|
|
173
231
|
const act = a.running
|
|
174
232
|
? `<button class="pill downbtn" data-down="${esc(a.name)}">■ Stop</button>`
|
|
175
233
|
: `<button class="pill upbtn" data-up="${esc(a.name)}">▶ Start</button>`;
|
|
176
|
-
|
|
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;
|
|
177
243
|
}
|
|
178
244
|
|
|
179
245
|
function renderAgents() {
|
|
180
|
-
// Clear any prior poll
|
|
246
|
+
// Clear any prior poll timers so a poll-triggered re-render can't stack them.
|
|
181
247
|
if (timers.status) { clearInterval(timers.status); delete timers.status; }
|
|
248
|
+
if (timers.rate) { clearInterval(timers.rate); delete timers.rate; }
|
|
182
249
|
const agents = (state.status && state.status.agents) || [];
|
|
183
250
|
const cards = agents.map((a) => `
|
|
184
251
|
<div class="card agentcard" data-agent="${esc(a.name)}">
|
|
@@ -188,25 +255,49 @@
|
|
|
188
255
|
<div class="name">${esc(a.name)}</div>
|
|
189
256
|
<div class="muted" style="font-size:.8rem">${esc(a.type)}</div>
|
|
190
257
|
</div>
|
|
258
|
+
${state.rate ? `<span class="rateline" data-rateline="${esc(a.name)}"></span>` : ""}
|
|
191
259
|
</div>
|
|
192
260
|
<div class="role" data-role="${esc(a.name)}">${esc(a.role_preview || "")}</div>
|
|
193
261
|
<div class="meta">${statusPills(a)}</div>
|
|
194
262
|
<div class="muted" style="font-size:.78rem">talks to: ${esc((a.can_talk_to || []).join(", ") || "—")}</div>
|
|
195
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>`;
|
|
196
274
|
$("view").innerHTML = `
|
|
197
275
|
<div class="sectiontitle">
|
|
198
276
|
<h2>Agents <span class="muted" style="font-weight:500">(${agents.length})</span></h2>
|
|
199
|
-
<
|
|
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>
|
|
200
283
|
</div>
|
|
201
284
|
${topologyCard(agents)}
|
|
202
|
-
|
|
285
|
+
${body}`;
|
|
203
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); }
|
|
204
294
|
for (const c of document.querySelectorAll(".agentcard"))
|
|
205
295
|
c.onclick = (e) => {
|
|
206
|
-
// The Start/Stop pills live inside the card; the
|
|
207
|
-
// listener handles them. Its stopPropagation fires too
|
|
208
|
-
// (closer) handler, so skip opening the mail page for
|
|
209
|
-
|
|
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;
|
|
210
301
|
openAgent(c.dataset.agent);
|
|
211
302
|
};
|
|
212
303
|
for (const g of document.querySelectorAll(".gnode")) {
|
|
@@ -222,6 +313,86 @@
|
|
|
222
313
|
|
|
223
314
|
function cssq(s) { return String(s).replace(/"/g, '\\"'); }
|
|
224
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
|
+
|
|
225
396
|
// Bring one agent up (from a `.upbtn` on a card or the mail header).
|
|
226
397
|
function startAgent(name, btn) {
|
|
227
398
|
if (btn) { btn.disabled = true; btn.textContent = "starting…"; }
|
|
@@ -252,10 +423,39 @@
|
|
|
252
423
|
});
|
|
253
424
|
}
|
|
254
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
|
+
|
|
255
453
|
function pollStatus() {
|
|
256
454
|
apiGet("/api/status").then((data) => {
|
|
257
455
|
state.status = data;
|
|
456
|
+
banner(""); // recovered: clear any stale "connect failed" / poll error
|
|
258
457
|
syncAvailability(data.user_available);
|
|
458
|
+
maybeNotify(data.attention || 0);
|
|
259
459
|
$("swarmMeta").textContent = (data.name || "swarm") + " · " + ((data.agents || []).length) + " agents";
|
|
260
460
|
if (state.view === "agents") {
|
|
261
461
|
const agents = data.agents || [];
|
|
@@ -286,6 +486,8 @@
|
|
|
286
486
|
}
|
|
287
487
|
|
|
288
488
|
function drawTopology(agents) {
|
|
489
|
+
const byName = {};
|
|
490
|
+
agents.forEach((a) => { byName[a.name] = a; });
|
|
289
491
|
const nodes = agents.map((a) => a.name);
|
|
290
492
|
if (agents.some((a) => (a.can_talk_to || []).includes("user"))) nodes.push("user");
|
|
291
493
|
const W = 560, H = 300, cx = W / 2, cy = H / 2, r = Math.min(W, H) / 2 - 48, N = nodes.length;
|
|
@@ -299,14 +501,23 @@
|
|
|
299
501
|
if (pos[a.name] && pos[p]) {
|
|
300
502
|
const s = pos[a.name], t = pos[p];
|
|
301
503
|
const dx = t.x - s.x, dy = t.y - s.y, len = Math.hypot(dx, dy) || 1, R = 21;
|
|
302
|
-
|
|
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)"/>`;
|
|
303
507
|
}
|
|
304
508
|
}));
|
|
305
509
|
const circles = nodes.map((n) => {
|
|
306
510
|
const p = pos[n];
|
|
307
|
-
const
|
|
308
|
-
const
|
|
309
|
-
|
|
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}
|
|
310
521
|
<circle cx="${p.x}" cy="${p.y}" r="19" fill="${fill}"/>
|
|
311
522
|
<text x="${p.x}" y="${p.y + 4}" text-anchor="middle" class="gnode-t">${esc(initials(n))}</text>
|
|
312
523
|
<text x="${p.x}" y="${p.y + 36}" text-anchor="middle" class="gnode-l">${esc(n === "user" ? "you" : n)}</text></g>`;
|
|
@@ -488,6 +699,12 @@
|
|
|
488
699
|
|
|
489
700
|
function renderThread() {
|
|
490
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;
|
|
491
708
|
const atBottom = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 60;
|
|
492
709
|
if (!state.thread.length) {
|
|
493
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>`;
|
|
@@ -496,13 +713,43 @@
|
|
|
496
713
|
const cls = m.from === "system" ? "system" : m.direction === "out" ? "out" : "in";
|
|
497
714
|
const head = cls === "system" ? "system" : `${esc(m.from)} → ${esc(m.to)} · ${esc(fmtTime(m.time))}`;
|
|
498
715
|
const status = cls === "system" ? "" : statusTag(m.status);
|
|
499
|
-
return `<div class="msg ${cls}"><div class="m-head">${head}</div
|
|
716
|
+
return `<div class="msg ${cls}"><div class="m-head">${head}</div>${mailBody(m.body)}${status}</div>`;
|
|
500
717
|
}).join("");
|
|
718
|
+
for (const btn of scroll.querySelectorAll(".m-more")) btn.onclick = () => toggleCollapse(btn);
|
|
501
719
|
}
|
|
502
720
|
if (atBottom) scroll.scrollTop = scroll.scrollHeight;
|
|
503
721
|
renderCompose();
|
|
504
722
|
}
|
|
505
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
|
+
|
|
506
753
|
// Delivery status of one message, from where it currently sits in the mailroom.
|
|
507
754
|
function statusTag(s) {
|
|
508
755
|
const map = {
|
|
@@ -564,11 +811,27 @@
|
|
|
564
811
|
{ key: "C-l", label: "Ctrl-L", title: "Redraw / clear (Ctrl-L)" },
|
|
565
812
|
];
|
|
566
813
|
|
|
567
|
-
|
|
568
|
-
|
|
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) => {
|
|
569
829
|
if (!res.ok) { toast("error: " + (res.j.error || "failed")); return; }
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
});
|
|
572
835
|
});
|
|
573
836
|
}
|
|
574
837
|
|
|
@@ -864,6 +1127,10 @@
|
|
|
864
1127
|
const up = e.target.closest("[data-up]");
|
|
865
1128
|
if (up) { e.stopPropagation(); startAgent(up.dataset.up, up); return; }
|
|
866
1129
|
const down = e.target.closest("[data-down]");
|
|
867
|
-
if (down) { e.stopPropagation(); stopAgent(down.dataset.down, 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); }
|
|
868
1135
|
});
|
|
869
1136
|
})();
|
package/ui/index.html
CHANGED
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
}
|
|
87
87
|
.navbtn.active { color: var(--text); background: var(--surface-2); border-color: var(--border); }
|
|
88
88
|
.navbtn:hover { color: var(--text); }
|
|
89
|
+
.navbtn { position: relative; }
|
|
89
90
|
|
|
90
91
|
/* availability switch */
|
|
91
92
|
.avail { display: inline-flex; align-items: center; gap: .5rem; }
|
|
@@ -141,12 +142,35 @@
|
|
|
141
142
|
.pill.busy { background: var(--busy-bg); color: var(--busy-fg); }
|
|
142
143
|
.pill.no { background: var(--no-bg); color: var(--no-fg); }
|
|
143
144
|
.pill.mute { background: var(--surface-2); color: var(--muted); }
|
|
145
|
+
/* truthful state pills */
|
|
146
|
+
.pill.st-working { background: var(--ok-bg); color: var(--ok-fg); }
|
|
147
|
+
.pill.st-waiting { background: var(--surface-2); color: var(--muted); }
|
|
148
|
+
.pill.st-attention { background: var(--busy-bg); color: var(--busy-fg); }
|
|
149
|
+
.pill.st-stalled { background: var(--no-bg); color: var(--no-fg); }
|
|
150
|
+
.pill.st-stopped { background: var(--surface-2); color: var(--muted); opacity: .7; }
|
|
144
151
|
.dotpulse { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
|
152
|
+
.pill.st-working .dotpulse { animation: dotpulse 1.2s ease-in-out infinite; }
|
|
153
|
+
@keyframes dotpulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .3; transform: scale(.6); } }
|
|
154
|
+
@media (prefers-reduced-motion: reduce) { .pill.st-working .dotpulse { animation: none; } }
|
|
145
155
|
.pill.upbtn { border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; font-family: inherit; }
|
|
146
156
|
.pill.upbtn:hover:not(:disabled) { background: var(--accent); color: #fff; }
|
|
147
157
|
.pill.upbtn:disabled, .pill.downbtn:disabled { opacity: .6; cursor: default; }
|
|
148
158
|
.pill.downbtn { border: 1px solid var(--no-fg); background: transparent; color: var(--no-fg); cursor: pointer; font-family: inherit; }
|
|
149
159
|
.pill.downbtn:hover:not(:disabled) { background: var(--no-fg); color: #fff; }
|
|
160
|
+
/* stalled-recovery quick actions (Esc / Restart) on a card */
|
|
161
|
+
.pill.recover { border: 1px solid var(--border); background: var(--surface-2); color: var(--text); cursor: pointer; font-family: inherit; }
|
|
162
|
+
.pill.recover:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
163
|
+
.pill.recover:disabled { opacity: .6; cursor: default; }
|
|
164
|
+
|
|
165
|
+
/* connection-honesty indicator in the app bar */
|
|
166
|
+
.connind {
|
|
167
|
+
display: inline-flex; align-items: center; gap: .35rem; white-space: nowrap;
|
|
168
|
+
font-size: .76rem; font-weight: 650; padding: .2rem .55rem; border-radius: 999px;
|
|
169
|
+
border: 1px solid var(--border); background: var(--surface-2);
|
|
170
|
+
font-variant-numeric: tabular-nums;
|
|
171
|
+
}
|
|
172
|
+
.connind.live { color: var(--ok-fg); border-color: color-mix(in srgb, var(--ok-fg) 40%, var(--border)); }
|
|
173
|
+
.connind.stale { color: var(--busy-fg); border-color: color-mix(in srgb, var(--busy-fg) 45%, var(--border)); }
|
|
150
174
|
|
|
151
175
|
/* avatar */
|
|
152
176
|
.avatar {
|
|
@@ -163,8 +187,22 @@
|
|
|
163
187
|
.agentcard .name { font-weight: 700; }
|
|
164
188
|
.agentcard .role { color: var(--muted); font-size: .86rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 2.6em; }
|
|
165
189
|
.agentcard .meta { display: flex; flex-wrap: wrap; gap: .4rem; }
|
|
166
|
-
.sectiontitle { display: flex; align-items: center; justify-content: space-between; margin: .3rem 0 .8rem; }
|
|
190
|
+
.sectiontitle { display: flex; align-items: center; justify-content: space-between; gap: .6rem; margin: .3rem 0 .8rem; flex-wrap: wrap; }
|
|
167
191
|
.sectiontitle h2 { margin: 0; font-size: 1.05rem; }
|
|
192
|
+
.agents-tools { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
|
|
193
|
+
.tgl { display: inline-flex; align-items: center; gap: .3rem; font-size: .82rem; color: var(--muted); cursor: pointer; white-space: nowrap; }
|
|
194
|
+
.tgl input { margin: 0; }
|
|
195
|
+
.rateline { margin-left: auto; font-size: .74rem; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
196
|
+
|
|
197
|
+
/* onboarding: template picker (empty state) */
|
|
198
|
+
.tpl-intro { margin: .2rem 0 .7rem; }
|
|
199
|
+
.tplgrid { display: grid; gap: .8rem; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
|
200
|
+
.tpl { text-align: left; padding: .9rem; display: flex; flex-direction: column; gap: .3rem; cursor: pointer; transition: transform .12s, box-shadow .12s, border-color .12s; }
|
|
201
|
+
.tpl:hover:not(:disabled) { transform: translateY(-2px); box-shadow: var(--shadow-lg); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
|
|
202
|
+
.tpl:disabled { opacity: .6; cursor: default; }
|
|
203
|
+
.tpl-title { font-weight: 700; }
|
|
204
|
+
.tpl-sum { font-size: .84rem; }
|
|
205
|
+
.tpl-meta { font-size: .74rem; }
|
|
168
206
|
|
|
169
207
|
/* ---- mail app -------------------------------------------------------- */
|
|
170
208
|
.mailhead { display: flex; align-items: center; gap: .6rem; margin-bottom: .8rem; flex-wrap: wrap; }
|
|
@@ -202,6 +240,9 @@
|
|
|
202
240
|
.msg blockquote { margin: .3rem 0; padding-left: .6rem; border-left: 3px solid rgba(127,127,127,.4); opacity: .9; }
|
|
203
241
|
.msg a { color: inherit; text-decoration: underline; }
|
|
204
242
|
.msg.out code, .msg.out pre.md-pre { background: rgba(255,255,255,.2); }
|
|
243
|
+
.m-more { display: block; margin-top: .3rem; padding: 0; background: none; border: none; cursor: pointer; font: inherit; font-size: .72rem; color: var(--accent); text-decoration: underline; opacity: .85; }
|
|
244
|
+
.m-more:hover { opacity: 1; }
|
|
245
|
+
.msg.out .m-more { color: color-mix(in srgb, var(--accent-fg) 85%, transparent); }
|
|
205
246
|
.m-status { font-size: .66rem; margin-top: .25rem; text-align: right; color: var(--muted); letter-spacing: .01em; }
|
|
206
247
|
.msg.out .m-status { color: color-mix(in srgb, var(--accent-fg) 78%, transparent); }
|
|
207
248
|
.m-status.s-read { color: var(--ok-fg); }
|
|
@@ -275,6 +316,17 @@
|
|
|
275
316
|
|
|
276
317
|
/* topology graph */
|
|
277
318
|
.edge { stroke: currentColor; stroke-width: 1.6; opacity: .55; }
|
|
319
|
+
/* live flow: an edge into a node that currently holds unread mail */
|
|
320
|
+
.edge-live { stroke: var(--accent); opacity: .95; stroke-width: 2.4; stroke-dasharray: 5 4; animation: edgeflow .6s linear infinite; }
|
|
321
|
+
@keyframes edgeflow { to { stroke-dashoffset: -9; } }
|
|
322
|
+
/* status rings, colored by the agent's live state */
|
|
323
|
+
.gring { stroke-width: 2.5; }
|
|
324
|
+
.gring-working { stroke: var(--ok-fg); animation: ringpulse 1.4s ease-in-out infinite; }
|
|
325
|
+
.gring-attention { stroke: var(--busy-fg); }
|
|
326
|
+
.gring-stalled { stroke: var(--no-fg); }
|
|
327
|
+
.gring-stopped { stroke: var(--muted); }
|
|
328
|
+
@keyframes ringpulse { 0%, 100% { opacity: .9; } 50% { opacity: .3; } }
|
|
329
|
+
@media (prefers-reduced-motion: reduce) { .edge-live, .gring-working { animation: none; } }
|
|
278
330
|
.gnode-t { fill: #fff; font-weight: 700; font-size: 11px; }
|
|
279
331
|
.gnode-l { fill: var(--muted); font-size: 10px; }
|
|
280
332
|
.gnode { cursor: pointer; }
|
|
@@ -289,6 +341,9 @@
|
|
|
289
341
|
@media (max-width: 760px) {
|
|
290
342
|
.brand small { display: none; }
|
|
291
343
|
.avail .lbl { display: none; }
|
|
344
|
+
/* keep the app bar from overflowing on phones; the banner still reports
|
|
345
|
+
a dropped connection, so the chip is redundant here. */
|
|
346
|
+
.connind { display: none; }
|
|
292
347
|
.mail { grid-template-columns: 1fr; }
|
|
293
348
|
/* On mobile show either the contacts list OR the open thread. */
|
|
294
349
|
body[data-pane="thread"] .contacts { display: none; }
|
|
@@ -305,6 +360,7 @@
|
|
|
305
360
|
<span>Agentainer<small id="swarmMeta">control plane</small></span>
|
|
306
361
|
</div>
|
|
307
362
|
<span class="spacer"></span>
|
|
363
|
+
<span class="connind" id="connind" hidden></span>
|
|
308
364
|
<div class="avail" id="availWrap" hidden>
|
|
309
365
|
<span class="lbl" id="availLbl">You: away</span>
|
|
310
366
|
<label class="switch" title="Receive mail from agents">
|
|
@@ -326,7 +382,7 @@
|
|
|
326
382
|
<section id="login" class="center">
|
|
327
383
|
<div class="card">
|
|
328
384
|
<h2>Connect</h2>
|
|
329
|
-
<p class="muted" style="margin-top:0">Enter the
|
|
385
|
+
<p class="muted" style="margin-top:0">Enter the access token printed when you run <code>agentainer up</code> (also shown by <code>agentainer serve</code>).</p>
|
|
330
386
|
<div class="fld">
|
|
331
387
|
<input id="token" class="field" type="password" placeholder="UI token" autocomplete="off" />
|
|
332
388
|
</div>
|