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.
Files changed (63) hide show
  1. package/examples/academic-coauthor.yaml +123 -0
  2. package/examples/accessibility-audit.yaml +152 -0
  3. package/examples/affiliate-product-reviews.yaml +106 -0
  4. package/examples/api-design.yaml +157 -0
  5. package/examples/app-store-optimization.yaml +108 -0
  6. package/examples/brand-voice-style-guide.yaml +109 -0
  7. package/examples/candidate-screen.yaml +122 -0
  8. package/examples/case-study-writer.yaml +100 -0
  9. package/examples/changelog-release-notes.yaml +114 -0
  10. package/examples/chatbot-builder.yaml +138 -0
  11. package/examples/comparison-guide-writer.yaml +106 -0
  12. package/examples/competitive-intel.yaml +126 -0
  13. package/examples/content-studio.yaml +91 -0
  14. package/examples/course-creator.yaml +133 -0
  15. package/examples/customer-support-triage.yaml +118 -0
  16. package/examples/daily-briefing.yaml +119 -0
  17. package/examples/data-pipeline-builder.yaml +135 -0
  18. package/examples/design-system.yaml +138 -0
  19. package/examples/ebook-generator.yaml +90 -0
  20. package/examples/ecommerce-listing-optimizer.yaml +126 -0
  21. package/examples/email-newsletter.yaml +103 -0
  22. package/examples/faq-knowledge-sync.yaml +107 -0
  23. package/examples/game-design.yaml +122 -0
  24. package/examples/glossary-term-writer.yaml +103 -0
  25. package/examples/knowledge-base.yaml +115 -0
  26. package/examples/landing-page-converter.yaml +103 -0
  27. package/examples/legal-contract-review.yaml +118 -0
  28. package/examples/linkedin-ghostwriter.yaml +93 -0
  29. package/examples/meeting-notes.yaml +111 -0
  30. package/examples/migration-planner.yaml +127 -0
  31. package/examples/onboarding-buddy.yaml +111 -0
  32. package/examples/performance-audit.yaml +123 -0
  33. package/examples/podcast-production.yaml +117 -0
  34. package/examples/postmortem.yaml +119 -0
  35. package/examples/pr-review-gate.yaml +123 -0
  36. package/examples/press-release-wire.yaml +96 -0
  37. package/examples/product-spec.yaml +107 -0
  38. package/examples/prompt-engineering-lab.yaml +109 -0
  39. package/examples/rag-builder.yaml +145 -0
  40. package/examples/refactor-planner.yaml +127 -0
  41. package/examples/resume-tailor.yaml +116 -0
  42. package/examples/rfp-response.yaml +124 -0
  43. package/examples/sales-coach.yaml +123 -0
  44. package/examples/security-audit.yaml +120 -0
  45. package/examples/seo-audit-and-fix.yaml +138 -0
  46. package/examples/seo-content-factory.yaml +103 -0
  47. package/examples/social-media.yaml +103 -0
  48. package/examples/startup-validator.yaml +115 -0
  49. package/examples/technical-documentation.yaml +112 -0
  50. package/examples/test-factory.yaml +114 -0
  51. package/examples/tutorial-howto-creator.yaml +111 -0
  52. package/examples/twitter-x-thread-factory.yaml +91 -0
  53. package/examples/white-paper-research.yaml +96 -0
  54. package/examples/youtube-script-studio.yaml +107 -0
  55. package/lib/cli.py +6 -2
  56. package/lib/config.py +28 -11
  57. package/lib/mail.py +78 -13
  58. package/lib/reconcile.py +80 -9
  59. package/lib/turn.py +14 -6
  60. package/lib/ui.py +212 -13
  61. package/package.json +1 -1
  62. package/ui/app.js +290 -23
  63. 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 fetch(withToken(path), { headers: { Accept: "application/json" } }).then((r) => {
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 fetch(withToken(path), {
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
- const run = a.running ? '<span class="pill ok"><span class="dotpulse"></span>running</span>'
168
- : '<span class="pill no">stopped</span>';
169
- const st = a.busy ? '<span class="pill busy">busy</span>' : '<span class="pill mute">idle</span>';
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
- return run + st + act + un + q;
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 timer so a poll-triggered re-render can't stack them.
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
- <button class="btn ghost sm" id="refreshBtn">Refresh</button>
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
- <div class="grid">${cards || '<p class="empty">No agents configured. Add one in Settings.</p>'}</div>`;
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 delegated document
207
- // listener handles them. Its stopPropagation fires too late to stop this
208
- // (closer) handler, so skip opening the mail page for those clicks here.
209
- if (e.target.closest("[data-up],[data-down]")) return;
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
- edges += `<line x1="${s.x + (dx / len) * R}" y1="${s.y + (dy / len) * R}" x2="${t.x - (dx / len) * R}" y2="${t.y - (dy / len) * R}" class="edge" marker-end="url(#arrow)"/>`;
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 fill = n === "user" ? "hsl(215 62% 48%)" : `hsl(${hueFor(n)} 62% 48%)`;
308
- const clickable = n !== "user";
309
- return `<g${clickable ? ` class="gnode" data-agent="${esc(n)}" role="button" tabindex="0" aria-label="open ${esc(n)}"` : ""}>
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><div class="m-body">${md(m.body.trim())}</div>${status}</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
- function sendKey(key) {
568
- apiPost("/api/key", { agent: state.agent, key }).then((res) => {
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
- toast(key + " " + state.agent);
571
- setTimeout(loadPane, 300);
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 UI token printed by <code>agentainer serve</code>.</p>
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>