@join-pantheon/cli 0.23.1 → 0.23.2

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/dist/relay.js CHANGED
@@ -76,41 +76,59 @@ function fingerprint_(from, act, body, goal, ref) {
76
76
  return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body].join("\u0000")).digest("hex").slice(0, 32);
77
77
  }
78
78
  const ADMIN_CSS = `<style>
79
- :root{--bg:#0b0c0e;--card:#15171b;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff;--warn:#e8b45c;--bad:#ef8b80;--ok:#7bd39a}
80
- @media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--line:#e6e8eb;--fg:#1a1c1f;--dim:#59616e;--accent:#2563eb;--warn:#b45309;--bad:#b91c1c;--ok:#15803d}}
81
- *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,sans-serif}
82
- header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
83
- h1{font-size:18px;margin:0}.dim{color:var(--dim)}
84
- .stats{display:flex;gap:10px;flex-wrap:wrap;padding:16px 24px}
85
- .stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 16px;min-width:110px}
86
- .stat b{font-size:22px;display:block;font-variant-numeric:tabular-nums}.stat span{color:var(--dim);font-size:12px}
87
- .stat.warn b{color:var(--warn)}
88
- section{padding:8px 24px 28px}h2{font-size:16px;color:var(--dim);margin:22px 0 8px;display:flex;align-items:center;gap:12px}
89
- h2 input{margin-left:auto;font:13px system-ui;padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);width:220px}
79
+ :root{--bg:#0b0c0e;--card:#14161a;--card2:#1b1e24;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff;--warn:#e8b45c;--bad:#ef8b80;--ok:#7bd39a;color-scheme:dark}
80
+ @media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--card2:#f1f3f6;--line:#e3e6ea;--fg:#1a1c1f;--dim:#59616e;--accent:#2563eb;--warn:#b45309;--bad:#b91c1c;--ok:#15803d;color-scheme:light}}
81
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
82
+ a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
83
+ header{position:sticky;top:0;z-index:5;background:var(--bg);padding:14px 28px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
84
+ h1{font-size:17px;margin:0;font-weight:650}.dim{color:var(--dim)}.meta{color:var(--dim);font-size:12.5px}
85
+ .links{margin-left:auto;display:flex;gap:14px;font-size:13px;flex-wrap:wrap}
86
+ nav.jump{display:flex;gap:4px;flex-wrap:wrap;padding:8px 22px;border-bottom:1px solid var(--line)}
87
+ nav.jump a{color:var(--dim);font-size:13px;padding:5px 10px;border-radius:6px}nav.jump a:hover{background:var(--card2);color:var(--fg);text-decoration:none}
88
+ main{padding:0 28px 48px;max-width:1560px}
89
+ section{margin-top:34px;scroll-margin-top:70px}
90
+ .sec{display:flex;align-items:baseline;gap:12px;margin:0 0 10px;flex-wrap:wrap}.sec h2{font-size:16px;margin:0;font-weight:650}.sec .count{color:var(--dim);font-size:13px}
91
+ .sec input[type=search]{margin-left:auto;font:13px system-ui;padding:6px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);width:240px}
92
+ .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:12px;margin-top:22px}
93
+ .kpi{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px 12px}
94
+ .kpi .l{color:var(--dim);font-size:11.5px;letter-spacing:.04em;text-transform:uppercase;font-weight:600}
95
+ .kpi .v{font-size:26px;font-weight:650;font-variant-numeric:tabular-nums;line-height:1.2;margin:6px 0 3px}
96
+ .kpi .v small{font-size:13.5px;color:var(--dim);font-weight:500;margin-left:5px}
97
+ .kpi .s{color:var(--dim);font-size:12.5px}.kpi.warn .v{color:var(--warn)}.kpi.bad .v{color:var(--bad)}
90
98
  .wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:var(--card)}
91
- table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
92
- th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:middle}
93
- th{color:var(--dim);font-weight:600;font-size:12px}tr:last-child td{border-bottom:0}
94
- code{font-family:ui-monospace,monospace;font-size:12px}a{color:var(--accent)}
95
- .pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid var(--line)}.pill.bad{color:var(--bad);border-color:var(--bad)}.pill.ok{color:var(--ok);border-color:var(--ok)}
96
- .inl{display:inline}.act{font:12px system-ui;padding:4px 9px;border-radius:6px;border:1px solid var(--line);background:transparent;color:var(--fg);cursor:pointer;margin-left:6px}
99
+ table{border-collapse:separate;border-spacing:0;width:100%;font-variant-numeric:tabular-nums}
100
+ th{position:sticky;top:0;z-index:1;background:var(--card2);color:var(--dim);font-weight:600;font-size:12px;text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
101
+ th.group{text-align:center;color:var(--fg);border-left:1px solid var(--line);font-weight:650}th.first{border-left:1px solid var(--line)}
102
+ td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top;white-space:nowrap}
103
+ tr:last-child>td{border-bottom:0}tbody tr:hover>td{background:var(--card2)}
104
+ td.num,th.num{text-align:right}td.wrapc{white-space:normal;min-width:280px;max-width:760px;overflow-wrap:anywhere}
105
+ .cell b{font-weight:600}.sub{display:block;color:var(--dim);font-size:12px;margin-top:2px;white-space:nowrap}
106
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
107
+ .pill{display:inline-block;font-size:11px;line-height:1.7;padding:0 7px;border-radius:999px;border:1px solid var(--line);color:var(--dim);white-space:nowrap;vertical-align:middle}
108
+ .pill.ok{color:var(--ok);border-color:var(--ok)}.pill.bad{color:var(--bad);border-color:var(--bad)}.pill.warn{color:var(--warn);border-color:var(--warn)}.pill.info{color:var(--accent);border-color:var(--accent)}.pill.dim{color:var(--dim)}
109
+ .inl{display:inline}.act{font:12px system-ui;padding:4px 9px;border-radius:6px;border:1px solid var(--line);background:transparent;color:var(--fg);cursor:pointer}
97
110
  .act:hover{border-color:var(--accent)}.act.danger{color:var(--bad)}.act.danger:hover{border-color:var(--bad)}
98
- .blockform{display:flex;gap:8px;align-items:center;margin:8px 0 0}.blockform input{font:13px ui-monospace,monospace;padding:6px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);width:280px}
99
- .done{margin:0 24px;padding:10px 14px;border:1px solid var(--ok);color:var(--ok);border-radius:10px;background:var(--card)}
100
- .note{color:var(--dim);font-size:12px;margin:6px 0 0}
101
- :root{color-scheme:dark} @media(prefers-color-scheme:light){:root{color-scheme:light}}
102
- :focus-visible{outline:2px solid var(--accent);outline-offset:3px}
103
- input::placeholder{color:var(--dim);opacity:1}
104
- button,input,select,summary{min-height:36px}button,input,select{max-width:100%}
105
- h1,h2{overflow-wrap:anywhere;text-wrap:balance}h2{flex-wrap:wrap}
106
- .note{max-width:75ch}.done{margin-top:16px}
107
- .diagnostics{margin:16px 24px;border:1px solid var(--line);border-radius:10px;background:var(--card)}
108
- .diagnostics>summary{padding:12px 16px;cursor:pointer;font-weight:600}
109
- .diagnostics>section{padding:0 16px 16px}
110
- .jump{display:flex;flex-wrap:wrap;gap:16px;padding:12px 24px}
111
- .wrap:focus-visible{outline-offset:-3px}table{scroll-margin-top:16px}
112
- @media(max-width:600px){header{padding:16px;gap:8px}section{padding:8px 16px 20px}.stats{padding:12px 16px}.diagnostics{margin:12px 16px}.jump{padding:12px 16px}h2 input{width:100%;margin-left:0}.blockform{flex-wrap:wrap}}
111
+ td.actions{text-align:right;white-space:nowrap}td.actions .inl+.inl,td.actions .act+.act{margin-left:6px}
112
+ input[type=number],input[type=text],input[type=search],select{font:13px system-ui;padding:5px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg)}input[type=number]{width:74px}
113
+ .note{color:var(--dim);font-size:12.5px;margin:8px 0 0;max-width:84ch}
114
+ .done{margin:16px 28px 0;padding:10px 14px;border:1px solid var(--ok);color:var(--ok);border-radius:10px;background:var(--card)}
115
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}input::placeholder{color:var(--dim);opacity:1}
116
+ details.limits>summary{cursor:pointer;color:var(--accent);font-size:12px;list-style:none;display:inline}details.limits>summary::-webkit-details-marker{display:none}
117
+ .limitform{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;background:var(--card2);padding:8px 10px;border-radius:8px;margin-top:8px}
118
+ .limitform label{display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--dim)}.limitform label.chk{flex-direction:row;align-items:center;gap:6px;padding-bottom:7px}
119
+ .filters{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin:0 0 12px}
120
+ .filters label{display:flex;flex-direction:column;gap:3px;font-size:12px;color:var(--dim)}.filters label.chk{flex-direction:row;align-items:center;gap:6px;padding-bottom:7px}
121
+ .more{font:12px system-ui;color:var(--accent);background:none;border:0;padding:0;cursor:pointer;white-space:nowrap}
122
+ tr.detail>td{background:var(--card2);white-space:normal;padding:14px 16px 16px}.body{white-space:pre-wrap;max-width:92ch;font-size:13.5px;line-height:1.6}
123
+ .kv{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;font-size:12.5px;color:var(--dim);margin-top:12px;max-width:92ch}.kv b{color:var(--fg);font-weight:500;overflow-wrap:anywhere}
124
+ .pager{display:flex;justify-content:space-between;margin:12px 0}
125
+ .empty{padding:22px;color:var(--dim)}
126
+ .tone-warn{color:var(--warn)}.tone-bad{color:var(--bad)}.tone-ok{color:var(--ok)}
127
+ .crumbs a{color:var(--dim)}
128
+ @media(max-width:700px){header,nav.jump{padding-left:16px;padding-right:16px}main{padding:0 16px 40px}.sec input[type=search]{width:100%;margin-left:0}.done{margin:12px 16px 0}}
113
129
  </style>`;
130
+ const clipText = (v, max) => { const t = v.replace(/\s+/g, " ").trim(); if (t.length <= max)
131
+ return t; const cut = t.slice(0, max - 1); const sp = cut.lastIndexOf(" "); return `${(sp > max * 0.6 ? cut.slice(0, sp) : cut).trimEnd()}…`; };
114
132
  const escHtml = (v) => String(v ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
115
133
  const fmtWhen = (t) => t ? new Date(t).toISOString().slice(0, 19).replace("T", " ") : "—";
116
134
  const fmtAgo = (t) => {
@@ -868,11 +886,22 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
868
886
  const target = A.accountById(id);
869
887
  if (!target)
870
888
  return this.json(res, 404, { error: "no such account" });
871
- const raw = (body.json ?? "").trim();
872
- if (!A.setLimits(target.id, raw || null))
889
+ let raw;
890
+ if (body.clear)
891
+ raw = null;
892
+ else if (body.json !== undefined)
893
+ raw = body.json.trim() || null;
894
+ else {
895
+ const lim = parseLimits({ spaces: body.spaces, members: body.members, wake: body.wake ?? "off" });
896
+ if (!lim)
897
+ return this.json(res, 400, { error: "spaces must be 0 or more and people per space 1 or more" });
898
+ raw = JSON.stringify(lim);
899
+ }
900
+ if (!A.setLimits(target.id, raw))
873
901
  return this.json(res, 400, { error: 'custom limits must look like {"spaces":3,"members":5,"wake":true}' });
874
- A.audit({ event: "admin_set_limits", target: target.username, detail: raw || "(cleared)", ip });
875
- return done(raw ? `${target.username} now has custom limits ${raw}` : `${target.username} is back on plain ${target.plan} limits`);
902
+ const lim = raw ? parseLimits(JSON.parse(raw)) : null;
903
+ A.audit({ event: "admin_set_limits", target: target.username, detail: raw ?? "(cleared)", ip });
904
+ return done(lim ? `${target.username} now has custom limits: ${fmtLimit(lim.spaces)} spaces · ${fmtLimit(lim.members)} people per space · unattended workers ${lim.wake ? "on" : "off"}` : `${target.username} is back on plain ${target.plan} limits`);
876
905
  }
877
906
  case "set_plan_limits": {
878
907
  if (!PLAN_NAME.test(id))
@@ -989,90 +1018,139 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
989
1018
  const done = url?.searchParams.get("done");
990
1019
  const rows = (arr) => arr.join("");
991
1020
  const form = (action, id, label, extra = "", cls = "") => `<form method=POST action="/admin/action" class=inl onsubmit="return confirmAct(this)"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value="${action}"><input type=hidden name=id value="${esc(id)}">${extra}<button class="act ${cls}">${label}</button></form>`;
1021
+ const paid = Object.entries(planCounts).filter(([n]) => n !== "free").reduce((s, [, v]) => s + v, 0);
1022
+ const beating = sessions.filter((s) => Date.now() - s.last_seen <= 15 * 60_000).length;
1023
+ const obj24 = countActs(act.by_act_24h, ["OBJECT"]), esc24 = countActs(act.by_act_24h, ESCALATIONS), del24 = countActs(act.by_act_24h, ["DELIVERED"]);
1024
+ const staleBackup = !lastBackupPull || Date.now() - lastBackupPull.ts > 36 * 3_600_000;
1025
+ const GROUPS = [
1026
+ ["Talk", ["NOTE", "ASK", "ANSWER"]], ["Agreements", ["PROPOSE", "ACCEPT", "OBJECT", "CONTRACT", "COMMIT"]],
1027
+ ["Delivery", ["DELIVERED"]], ["Needs a human", ["ESCALATE", "HUMAN_REQUIRED"]],
1028
+ ["Housekeeping", MESSAGE_ACTS.filter((x) => !["NOTE", "ASK", "ANSWER", "PROPOSE", "ACCEPT", "OBJECT", "CONTRACT", "COMMIT", "DELIVERED", "ESCALATE", "HUMAN_REQUIRED"].includes(x))],
1029
+ ];
1030
+ const cols = GROUPS.flatMap(([, xs]) => xs);
1031
+ const actRow = (label, win, by, refused) => `<tr><td class=dim>${label}</td>${cols.map((x, i) => `<td class="num${GROUPS.some(([, xs]) => xs[0] === x) && i > 0 ? " first" : ""}" data-act="${x}" data-window="${win}">${by[x] ?? 0}</td>`).join("")}<td class="num first${refused ? " tone-warn" : ""}">${refused}</td></tr>`;
1032
+ const limitsOf = (a) => A.limitsFor(a);
1033
+ const limitText = (l) => `${fmtLimit(l.spaces)} space${l.spaces === 1 ? "" : "s"} · ${fmtLimit(l.members)} ${l.members === 1 ? "person" : "people"} per space · unattended workers ${l.wake ? "on" : "off"}`;
992
1034
  const html = `<!doctype html><html lang=en><meta charset=utf-8><title>pantheon · admin</title>
993
1035
  <meta name=viewport content="width=device-width,initial-scale=1">
994
1036
  ${ADMIN_CSS}
995
1037
  <header>
996
1038
  <h1>pantheon · admin</h1>
997
- <span class=dim>${esc(this.opts.host ?? "relay")} · pkg ${VERSION} · up ${upMin < 60 ? upMin + "m" : Math.round(upMin / 60) + "h"}</span>
998
- <span style="margin-left:auto"><a href="/admin/export?table=audit&format=csv">export audit CSV</a> · <a href="/admin/export?table=accounts&format=json">accounts JSON</a> · <a href="/admin/backup">download backup</a> · <a href="/admin/logout">sign out</a></span>
1039
+ <span class=meta>${esc(this.opts.host ?? "relay")} · pkg ${VERSION} · up ${upMin < 60 ? upMin + "m" : Math.round(upMin / 60) + "h"}</span>
1040
+ <span class=links><a href="/admin/export?table=audit&format=csv">export audit CSV</a><a href="/admin/export?table=accounts&format=json">accounts JSON</a><a href="/admin/backup">download backup</a><a href="/admin/logout">sign out</a></span>
999
1041
  </header>
1042
+ <nav class=jump aria-label="Sections"><a href="#s-activity">Activity</a><a href="#s-spaces">Spaces</a><a href="#s-accounts">Accounts</a><a href="#s-plans">Plans</a><a href="#s-sessions">Sessions</a><a href="#s-blocks">Blocked</a><a href="#s-codes">Codes</a><a href="#s-audit">Audit log</a></nav>
1000
1043
  ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
1001
- <main><nav class=jump aria-label="Dashboard sections"><a href="#t-spaces">Spaces</a><a href="#t-accounts">Accounts</a><a href="#t-sessions">Sessions</a><a href="#t-audit">Audit log</a></nav>
1002
- <details class=diagnostics open><summary>Activity and service metrics</summary>
1003
- <div class=stats>
1004
- <div class=stat><b>${c.accounts}</b><span>accounts</span></div>
1005
- <div class=stat><b>${c.devices}</b><span>devices</span></div>
1006
- <div class=stat><b>${Object.entries(planCounts).filter(([n]) => n !== "free").reduce((s, [, v]) => s + v, 0)}<span class=dim style="font-size:14px"> / ${c.accounts}</span></b><span>paid plans / accounts</span></div>
1007
- <div class=stat><b>${sessions.filter((s) => Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>connections heartbeating / open</span></div>
1008
- <div class=stat><b>${spaces.filter((s) => s.live).length}<span class=dim style="font-size:14px"> / ${spaces.length}</span></b><span>spaces live / total · cap ${this.limits.maxSpaces}</span></div>
1009
- <div class="stat ${emailsHour > emailCap * 0.8 ? "warn" : ""}"><b>${emailsHour}<span class=dim style="font-size:14px"> / ${emailCap}</span></b><span>emails this hour</span></div>
1010
- <div class=stat><b>${snapshots.length}</b><span>snapshots · latest ${snapshots.length ? esc(snapshots[snapshots.length - 1].replace(/^accounts-|\\.db$/g, "").replace(/T/, " ").slice(0, 16)) : "—"}</span></div>
1011
- <div class="stat ${!lastBackupPull || Date.now() - lastBackupPull.ts > 36 * 3_600_000 ? "warn" : ""}"><b>${lastBackupPull ? ago(lastBackupPull.ts) : "never"}</b><span>last off-host backup pull</span></div>
1012
- <div class=stat><b>${c.audit}</b><span>audit events</span></div>
1013
- <div class=stat><b>${act.messages_24h}<span class=dim style="font-size:14px"> / ${act.messages}</span></b><span>messages today / retained</span></div>
1014
- <div class=stat><b>${act.senders_24h.length}<span class=dim style="font-size:14px"> · ${act.sessions_24h} sessions</span></b><span>delegates active today</span></div>
1015
- <div class="stat ${countActs(act.by_act_24h, ["OBJECT"]) ? "warn" : ""}"><b>${countActs(act.by_act_24h, ["OBJECT"])}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ["OBJECT"])}</span></b><span>objections today / retained</span></div>
1016
- <div class="stat ${countActs(act.by_act_24h, ESCALATIONS) ? "warn" : ""}"><b>${countActs(act.by_act_24h, ESCALATIONS)}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ESCALATIONS)}</span></b><span>escalations today / retained</span></div>
1017
- <div class=stat><b>${countActs(act.by_act_24h, ["DELIVERED"])}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ["DELIVERED"])}</span></b><span>deliveries today / retained</span></div>
1018
- <div class="stat ${refused24 ? "warn" : ""}"><b data-refused="${refusedAll}">${refused24}<span class=dim style="font-size:14px"> / ${refusedAll}</span></b><span>refused by the relay today / total</span></div>
1044
+ <main>
1045
+ <div class=kpis>
1046
+ <div class=kpi><div class=l>People</div><div class=v>${c.accounts}<small>accounts</small></div><div class=s>${c.devices} devices · ${paid} on a paid plan</div></div>
1047
+ <div class=kpi><div class=l>Teams</div><div class=v>${spaces.filter((s) => s.live).length}<small>/ ${spaces.length} live</small></div><div class=s>capacity ${this.limits.maxSpaces} spaces</div></div>
1048
+ <div class=kpi><div class=l>Connections</div><div class=v>${beating}<small>/ ${c.live_sessions} open</small></div><div class=s>${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours</div></div>
1049
+ <div class=kpi><div class=l>Messages today</div><div class=v>${act.messages_24h}<small>/ ${act.messages} retained</small></div><div class=s>${act.senders_24h.length} delegates active today · ${act.sessions_24h} sessions</div></div>
1050
+ <div class="kpi${obj24 + esc24 ? " warn" : ""}"><div class=l>Needs a look today</div><div class=v>${obj24 + esc24}</div><div class=s>${obj24} objection${obj24 === 1 ? "" : "s"} · ${esc24} escalation${esc24 === 1 ? "" : "s"} · ${del24} deliver${del24 === 1 ? "y" : "ies"}</div></div>
1051
+ <div class="kpi${refused24 ? " warn" : ""}"><div class=l>Refused by the relay</div><div class=v><span data-refused="${refusedAll}">${refused24}</span><small>today / ${refusedAll} total</small></div><div class=s>wrong turn, missing reference, foreign repo, plan or rate limit</div></div>
1052
+ <div class="kpi${staleBackup ? " warn" : ""}"><div class=l>Last backup</div><div class=v>${lastBackupPull ? ago(lastBackupPull.ts) : "never"}<small>off-host</small></div><div class=s>${snapshots.length} snapshots · ${emailsHour}/${emailCap} emails this hour · ${c.audit} audit events</div></div>
1019
1053
  </div>
1020
- <section>
1021
- <h2>Activity <span class=dim>(every space · today and retained history)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-activity>
1022
- <tr><th scope=col></th>${MESSAGE_ACTS.map((x) => `<th scope=col>${x}</th>`).join("")}<th scope=col>refused</th></tr>
1023
- <tr><td class=dim>today</td>${MESSAGE_ACTS.map((x) => `<td data-act="${x}" data-window="24h">${act.by_act_24h[x] ?? 0}</td>`).join("")}<td>${refused24}</td></tr>
1024
- <tr><td class=dim>retained</td>${MESSAGE_ACTS.map((x) => `<td data-act="${x}" data-window="all">${act.by_act[x] ?? 0}</td>`).join("")}<td>${refusedAll}</td></tr>
1054
+
1055
+ <section id=s-activity>
1056
+ <div class=sec><h2>Activity</h2><span class=count>every space · today and retained history</span></div>
1057
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-activity>
1058
+ <thead>
1059
+ <tr><th></th>${GROUPS.map(([g, xs]) => `<th class=group colspan=${xs.length}>${g}</th>`).join("")}<th class="group">Refused</th></tr>
1060
+ <tr><th></th>${cols.map((x, i) => `<th class="num${GROUPS.some(([, xs]) => xs[0] === x) && i > 0 ? " first" : ""}">${x}</th>`).join("")}<th class="num first">by relay</th></tr>
1061
+ </thead>
1062
+ <tbody>${actRow("today", "24h", act.by_act_24h, refused24)}${actRow("retained", "all", act.by_act, refusedAll)}</tbody>
1025
1063
  </table></div>
1026
- <p class=note>Messages are acts delegates sent; JOIN/LEAVE presence is not counted. Retained is what each space's ledger still holds — the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts plus everything in an open goal. Refused is what the relay handed back: wrong turn, missing reference, foreign repo, rate limit. Click a space for its conversation.</p>
1064
+ <p class=note>Messages are acts delegates sent; arrivals and departures are not counted. Retained is what each space's ledger still holds: the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts plus everything in an open goal.</p>
1065
+ </section>
1027
1066
 
1028
- </section></details>
1029
- <section>
1030
- <h2>Spaces <input type=search aria-label="Filter spaces" placeholder="Filter spaces" data-filter="t-spaces"></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-spaces>
1031
- <tr><th scope=col>space</th><th scope=col>state</th><th scope=col>owner</th><th scope=col>members</th><th scope=col>online</th><th scope=col>acts</th><th scope=col>msgs today</th><th scope=col>delegates today</th><th scope=col>objections</th><th scope=col>refused</th><th scope=col>goals</th><th scope=col>owed</th><th scope=col>last activity</th><th scope=col>on disk</th><th scope=col></th></tr>
1032
- ${rows(spaces.map((s) => `<tr><td><a href="/admin/space?name=${encodeURIComponent(s.name)}" title="members, refusals and the conversation"><code>${esc(s.name)}</code></a></td><td><span class="pill ${s.live ? "ok" : ""}">${s.live ? "live" : "sleeping"}</span></td><td>${(() => { const o = owners.get(s.name) ? acctById.get(owners.get(s.name)) : undefined; return o ? `<code>${esc(o.username)}</code> <span class=pill data-plan="${esc(o.plan)}">${esc(o.plan)}</span>` : "<span class=dim title=\"no owner on record — no plan limits apply\">—</span>"; })()}</td><td>${s.members}</td><td>${s.online}</td><td>${s.acts}</td><td>${s.activity.messages_24h}</td><td>${s.activity.senders_24h.length}</td><td>${s.activity.by_act_24h.OBJECT ?? 0}<span class=dim> / ${s.activity.by_act.OBJECT ?? 0}</span></td><td>${refusalsSince(s.refusals, dayAgo)}<span class=dim> / ${s.refusals?.total ?? 0}</span></td><td>${s.open_goals} open / ${s.goals}</td><td>${s.live ? s.obligations : "<span class=dim>—</span>"}</td><td class=dim title="${when(s.last)}">${ago(s.last)}</td><td class=dim>${kb(s.bytes)}</td><td>${form("delete_space", s.name, "delete", `<input type=hidden name=confirm value="">`, "danger")}</td></tr>`))}
1033
- ${spaces.length ? "" : "<tr><td colspan=15 class=dim>no spaces yet</td></tr>"}
1067
+ <section id=s-spaces>
1068
+ <div class=sec><h2>Spaces</h2><span class=count>${spaces.length} · click a space for its goals, members and every message</span><input type=search aria-label="Filter spaces" placeholder="Filter spaces" data-filter="t-spaces"></div>
1069
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-spaces>
1070
+ <thead><tr><th>space</th><th>people</th><th>activity today</th><th>needs a look</th><th>goals</th><th>last activity</th><th class=num>on disk</th><th></th></tr></thead>
1071
+ <tbody>${rows(spaces.map((s) => {
1072
+ const o = owners.get(s.name) ? acctById.get(owners.get(s.name)) : undefined;
1073
+ const obj = s.activity.by_act_24h.OBJECT ?? 0, ref = refusalsSince(s.refusals, dayAgo);
1074
+ return `<tr><td class=cell><a href="/admin/space?name=${encodeURIComponent(s.name)}"><code>${esc(s.name)}</code></a> <span class="pill ${s.live ? "ok" : ""}">${s.live ? "live" : "sleeping"}</span><span class=sub>${o ? `owner <code>${esc(o.username)}</code> <span class=pill data-plan="${esc(o.plan)}">${esc(o.plan)}</span>` : "no owner on record — no plan limits apply"}</span></td>`
1075
+ + `<td class=cell><b>${s.members}</b> member${s.members === 1 ? "" : "s"}<span class=sub>${s.online} online</span></td>`
1076
+ + `<td class=cell><b>${s.activity.messages_24h}</b> message${s.activity.messages_24h === 1 ? "" : "s"}<span class=sub>${s.activity.senders_24h.length} delegate${s.activity.senders_24h.length === 1 ? "" : "s"} · ${s.acts} acts retained</span></td>`
1077
+ + `<td class="cell${obj || ref ? " tone-warn" : ""}"><b>${obj}</b> objection${obj === 1 ? "" : "s"} · <b>${ref}</b> refused<span class=sub>${s.live ? `${s.obligations} owed right now` : "asleep"} · ${s.activity.by_act.OBJECT ?? 0} objections, ${s.refusals?.total ?? 0} refused all time</span></td>`
1078
+ + `<td class=cell><b>${s.open_goals}</b> open<span class=sub>${s.goals} total</span></td>`
1079
+ + `<td class=dim title="${when(s.last)}">${ago(s.last)}</td><td class="num dim">${kb(s.bytes)}</td>`
1080
+ + `<td class=actions>${form("delete_space", s.name, "delete", `<input type=hidden name=confirm value="">`, "danger")}</td></tr>`;
1081
+ }))}${spaces.length ? "" : "<tr><td colspan=8 class=dim>no spaces yet</td></tr>"}</tbody>
1034
1082
  </table></div>
1035
- <p class=note>Sleeping spaces are on disk but idle; they reload on the next connect. Delete is irreversible and disconnects every member of that space.</p>
1083
+ <p class=note>Sleeping spaces are on disk but idle and reload on the next connect. Delete is irreversible and disconnects every member of that space.</p>
1084
+ </section>
1036
1085
 
1037
- <h2>Accounts <input type=search aria-label="Filter accounts" placeholder="Filter accounts" data-filter="t-accounts"></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-accounts>
1038
- <tr><th scope=col>username</th><th scope=col>email</th><th scope=col>status</th><th scope=col>plan</th><th scope=col>spaces</th><th scope=col>limit hits</th><th scope=col>devices</th><th scope=col>created</th><th scope=col></th></tr>
1039
- ${rows(accounts.map((a) => `<tr><td><code>${esc(a.username)}</code></td><td>${esc(a.email)}</td><td><span class="pill ${a.status === "suspended" ? "bad" : ""}">${esc(a.status)}</span></td><td><span class="pill ${a.plan === "free" ? "" : "ok"}" data-plan="${esc(a.plan)}">${esc(a.plan)}</span>${a.limits_json ? ' <span class="pill warn" title="custom limits">custom</span>' : ""} <form method=POST action="/admin/action" class=inl onsubmit="return confirmAct(this)"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan><input type=hidden name=id value="${esc(a.id)}"><select name=plan aria-label="Plan for ${esc(a.username)}" class=act style="margin-left:4px">${planOptions(a.plan)}</select><button class=act>set</button></form><details class=inl style="margin-left:6px"><summary class="act dim" style="display:inline;list-style:none;cursor:pointer">custom…</summary><form method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_limits><input type=hidden name=id value="${esc(a.id)}"><input name=json aria-label="Custom limits for ${esc(a.username)}" placeholder='{"spaces":3,"members":5,"wake":true}' value="${esc(a.limits_json ?? "")}" size=34 style="font:12px ui-monospace,monospace;padding:3px 6px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)"><button class=act>save</button></form></details></td><td>${ownedCounts.get(a.id) ?? 0}<span class=dim> / ${fmtLimit(A.limitsFor(a).spaces)}</span></td><td>${limitHits.get(a.id) ? `<span class="pill warn">${limitHits.get(a.id)}</span>` : "<span class=dim>0</span>"}</td><td>${devByAcct.get(a.id) ?? 0}</td><td class=dim>${when(a.created_at)}</td><td>${a.status === "suspended" ? form("reinstate", a.id, "reinstate") : form("suspend", a.id, "suspend", "", "danger")} ${form("block", a.email, "block email", "", "danger")}</td></tr>`))}
1040
- ${accounts.length ? "" : "<tr><td colspan=9 class=dim>no accounts yet</td></tr>"}
1086
+ <section id=s-accounts>
1087
+ <div class=sec><h2>Accounts</h2><span class=count>${accounts.length}</span><input type=search aria-label="Filter accounts" placeholder="Filter accounts" data-filter="t-accounts"></div>
1088
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-accounts>
1089
+ <thead><tr><th>account</th><th>status</th><th>plan</th><th>limits</th><th class=num>spaces</th><th class=num>limit hits</th><th class=num>devices</th><th>created</th><th></th></tr></thead>
1090
+ <tbody>${rows(accounts.map((a) => {
1091
+ const lim = limitsOf(a);
1092
+ const custom = !!a.limits_json;
1093
+ return `<tr><td class=cell><code>${esc(a.username)}</code><span class=sub>${esc(a.email)}</span></td>`
1094
+ + `<td><span class="pill ${a.status === "suspended" ? "bad" : "ok"}">${esc(a.status)}</span></td>`
1095
+ + `<td><form method=POST action="/admin/action" class=inl onsubmit="return confirmAct(this)"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan><input type=hidden name=id value="${esc(a.id)}"><select name=plan aria-label="Plan for ${esc(a.username)}">${planOptions(a.plan)}</select> <button class=act>set</button></form></td>`
1096
+ + `<td class=cell>${custom ? `<span class="pill warn">custom</span> ` : ""}${limitText(lim)}<details class=limits><summary>${custom ? "edit custom limits" : "set custom limits"}</summary>`
1097
+ + `<form method=POST action="/admin/action" class=limitform><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_limits><input type=hidden name=id value="${esc(a.id)}">`
1098
+ + `<label>spaces<input type=number name=spaces min=0 value="${lim.spaces}" aria-label="Spaces for ${esc(a.username)}"></label>`
1099
+ + `<label>people per space<input type=number name=members min=1 value="${lim.members}" aria-label="People per space for ${esc(a.username)}"></label>`
1100
+ + `<label class=chk><input type=checkbox name=wake value=on${lim.wake ? " checked" : ""}> unattended workers</label>`
1101
+ + `<button class=act>save</button>${custom ? `<button class=act name=clear value=1>back to ${esc(a.plan)} plan</button>` : ""}</form></details></td>`
1102
+ + `<td class=num>${ownedCounts.get(a.id) ?? 0}<span class=dim> / ${fmtLimit(lim.spaces)}</span></td>`
1103
+ + `<td class=num>${limitHits.get(a.id) ? `<span class="pill warn">${limitHits.get(a.id)}</span>` : "<span class=dim>0</span>"}</td>`
1104
+ + `<td class=num>${devByAcct.get(a.id) ?? 0}</td><td class=dim>${when(a.created_at)}</td>`
1105
+ + `<td class=actions>${a.status === "suspended" ? form("reinstate", a.id, "reinstate") : form("suspend", a.id, "suspend", "", "danger")} ${form("block", a.email, "block email", "", "danger")}</td></tr>`;
1106
+ }))}${accounts.length ? "" : "<tr><td colspan=9 class=dim>no accounts yet</td></tr>"}</tbody>
1041
1107
  </table></div>
1042
- <p class=note>Suspending signs the account out everywhere and refuses its handshakes until reinstated. Its messages stay in the ledgers. A plan change takes effect at the account's next space, invite or admit; nothing they already have is removed. "Limit hits" counts the times the relay refused them for a plan limit — the people to talk to about upgrading.</p>
1108
+ <p class=note>Suspending signs the account out everywhere and refuses its handshakes until reinstated; its messages stay in the ledgers. A plan or limits change takes effect at the account's next space, invite or admit; nothing they already have is removed. Limit hits count the times the relay refused them for a plan limit.</p>
1109
+ </section>
1043
1110
 
1044
- <h2>Plans <span class=dim>(limits the relay enforces; the owner's plan governs a space)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-plans>
1045
- <tr><th scope=col>plan</th><th scope=col>accounts</th><th scope=col>spaces created</th><th scope=col>people per space</th><th scope=col>unattended workers</th><th scope=col></th></tr>
1046
- ${rows(Object.entries(plans).map(([n, l]) => { const fid = `plan-${esc(n)}`; return `<tr><td><form id="${fid}" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits><input type=hidden name=id value="${esc(n)}"></form><code>${esc(n)}</code></td><td>${planCounts[n] ?? 0}</td><td><input form="${fid}" name=spaces aria-label="Maximum spaces" type=number min=0 value="${l.spaces}" class=act style="width:90px"></td><td><input form="${fid}" name=members aria-label="Maximum people per space" type=number min=1 value="${l.members}" class=act style="width:90px"></td><td><label><input form="${fid}" type=checkbox name=wake value=on${l.wake ? " checked" : ""}> allowed</label></td><td><button form="${fid}" class=act>save</button> ${n === "free" ? "" : form("delete_plan", n, "remove", "", "danger")}</td></tr>`; }))}
1047
- <tr><td><form id="plan-new" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits></form><input form="plan-new" name=id aria-label="New plan name" placeholder="new plan name" pattern="[a-z][a-z0-9_-]{0,23}" required class=act style="width:130px"></td><td class=dim>—</td><td><input form="plan-new" name=spaces aria-label="Maximum spaces" type=number min=0 value=3 class=act style="width:90px"></td><td><input form="plan-new" name=members aria-label="Maximum people per space" type=number min=1 value=5 class=act style="width:90px"></td><td><label><input form="plan-new" type=checkbox name=wake value=on checked> allowed</label></td><td><button form="plan-new" class=act>add plan</button></td></tr>
1111
+ <section id=s-plans>
1112
+ <div class=sec><h2>Plans</h2><span class=count>limits the relay enforces · the owner's plan governs a space</span></div>
1113
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-plans>
1114
+ <thead><tr><th>plan</th><th class=num>accounts</th><th>spaces created</th><th>people per space</th><th>unattended workers</th><th></th></tr></thead>
1115
+ <tbody>${rows(Object.entries(plans).map(([n, l]) => { const fid = `plan-${esc(n)}`; return `<tr><td><form id="${fid}" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits><input type=hidden name=id value="${esc(n)}"></form><code>${esc(n)}</code></td><td class=num>${planCounts[n] ?? 0}</td><td><input form="${fid}" name=spaces aria-label="Maximum spaces" type=number min=0 value="${l.spaces}"></td><td><input form="${fid}" name=members aria-label="Maximum people per space" type=number min=1 value="${l.members}"></td><td><label><input form="${fid}" type=checkbox name=wake value=on${l.wake ? " checked" : ""}> allowed</label></td><td class=actions><button form="${fid}" class=act>save</button> ${n === "free" ? "" : form("delete_plan", n, "remove", "", "danger")}</td></tr>`; }))}
1116
+ <tr><td><form id="plan-new" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits></form><input form="plan-new" type=text name=id aria-label="New plan name" placeholder="new plan name" pattern="[a-z][a-z0-9_-]{0,23}" required style="width:150px"></td><td class="num dim">—</td><td><input form="plan-new" name=spaces aria-label="Maximum spaces" type=number min=0 value=3></td><td><input form="plan-new" name=members aria-label="Maximum people per space" type=number min=1 value=5></td><td><label><input form="plan-new" type=checkbox name=wake value=on checked> allowed</label></td><td class=actions><button form="plan-new" class=act>add plan</button></td></tr></tbody>
1048
1117
  </table></div>
1049
- <p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward "people per space" so nobody mints five at once. Personas are files on the user's machine and cannot be limited here. Every change is audited.</p>
1118
+ <p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward people per space, so nobody mints five at once. Every change is audited.</p>
1119
+ </section>
1050
1120
 
1051
- <h2>Live sessions <input type=search aria-label="Filter sessions" placeholder="Filter sessions" data-filter="t-sessions"></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-sessions>
1052
- <tr><th scope=col>session</th><th scope=col>account</th><th scope=col>device</th><th scope=col>role</th><th scope=col>space</th><th scope=col>ip</th><th scope=col>started</th><th scope=col>last seen</th><th scope=col></th></tr>
1053
- ${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td class=dim>${esc(devices.find((d) => d.id === s.device_id)?.label ?? s.device_id.slice(0, 10))}</td><td>${s.role ? `<span class=pill>${esc(s.role)}</span>` : "<span class=dim>—</span>"}</td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
1054
- ${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=9 class=dim>no open connections right now</td></tr>"}
1121
+ <section id=s-sessions>
1122
+ <div class=sec><h2>Live connections</h2><span class=count>${sessions.filter((s) => !s.revoked_at).length} open · ${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours</span><input type=search aria-label="Filter sessions" placeholder="Filter connections" data-filter="t-sessions"></div>
1123
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-sessions>
1124
+ <thead><tr><th>account</th><th>device</th><th>role</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th>session</th><th></th></tr></thead>
1125
+ <tbody>${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td class=dim>${esc(devices.find((d) => d.id === s.device_id)?.label ?? s.device_id.slice(0, 10))}</td><td>${s.role ? `<span class=pill>${esc(s.role)}</span>` : "<span class=dim>—</span>"}</td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill warn" title="no heartbeat for 15+ minutes — usually a socket that died with a restart">stale</span>' : ""}</td><td><code class=dim>${esc(s.id)}</code></td><td class=actions>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=9 class=dim>no open connections right now</td></tr>"}</tbody>
1055
1126
  </table></div>
1056
- <p class=note>One row per open connection — an agent window, a worker, or a CLI command while it runs. Rows leave when the socket closes; ${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours. "Stale" means no heartbeat for 15 minutes on a socket that has not closed, usually a machine that went to sleep.</p>
1127
+ <p class=note>One row per open connection: an agent window, a worker, or a CLI command while it runs. Rows leave when the socket closes. Stale means no heartbeat for 15 minutes on a socket that has not closed, usually a machine that went to sleep.</p>
1128
+ </section>
1057
1129
 
1058
- <h2>Blocked emails and domains</h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-blocks>
1059
- <tr><th scope=col>pattern</th><th scope=col>kind</th><th scope=col>since</th><th scope=col></th></tr>
1060
- ${rows(blocks.map((b) => `<tr><td><code>${esc(b.pattern)}</code></td><td>${esc(b.kind)}</td><td class=dim>${when(b.created_at)}</td><td>${form("unblock", b.pattern, "unblock")}</td></tr>`))}
1061
- ${blocks.length ? "" : "<tr><td colspan=4 class=dim>nothing blocked</td></tr>"}
1130
+ <section id=s-blocks>
1131
+ <div class=sec><h2>Blocked emails and domains</h2><span class=count>${blocks.length}</span></div>
1132
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-blocks>
1133
+ <thead><tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr></thead>
1134
+ <tbody>${rows(blocks.map((b) => `<tr><td><code>${esc(b.pattern)}</code></td><td>${esc(b.kind)}</td><td class=dim>${when(b.created_at)}</td><td class=actions>${form("unblock", b.pattern, "unblock")}</td></tr>`))}${blocks.length ? "" : "<tr><td colspan=4 class=dim>nothing blocked</td></tr>"}</tbody>
1062
1135
  </table></div>
1063
- <form method=POST action="/admin/action" class=blockform><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=block><input name=id aria-label="Email or domain to block" placeholder="someone@spam.example or spam.example" required><button class=act>block</button></form>
1136
+ <form method=POST action="/admin/action" class=filters style="margin-top:10px"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=block><label>email or domain<input type=text name=id aria-label="Email or domain to block" placeholder="someone@spam.example or spam.example" required style="width:320px"></label><button class=act>block</button></form>
1064
1137
  <p class=note>A blocked email or domain cannot sign up or sign in. Existing accounts are not touched; suspend those separately.</p>
1138
+ </section>
1065
1139
 
1066
- <h2>Recent verification codes <span class=dim>(support: "I never got my code")</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-verifs>
1067
- <tr><th scope=col>email</th><th scope=col>purpose</th><th scope=col>requested</th><th scope=col>expires</th><th scope=col>wrong tries</th><th scope=col>state</th></tr>
1068
- ${rows(verifs.map((v) => `<tr><td>${esc(v.email)}</td><td>${esc(v.purpose)}</td><td class=dim>${when(v.created_at)}</td><td class=dim>${when(v.expires_at)}</td><td>${v.attempts}</td><td><span class="pill ${v.expires_at < Date.now() ? "bad" : "ok"}">${v.expires_at < Date.now() ? "expired" : "pending"}</span></td></tr>`))}
1069
- ${verifs.length ? "" : "<tr><td colspan=6 class=dim>no pending codes</td></tr>"}
1140
+ <section id=s-codes>
1141
+ <div class=sec><h2>Recent verification codes</h2><span class=count>support: "I never got my code"</span></div>
1142
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-verifs>
1143
+ <thead><tr><th>email</th><th>purpose</th><th>requested</th><th>expires</th><th class=num>wrong tries</th><th>state</th></tr></thead>
1144
+ <tbody>${rows(verifs.map((v) => `<tr><td>${esc(v.email)}</td><td>${esc(v.purpose)}</td><td class=dim>${when(v.created_at)}</td><td class=dim>${when(v.expires_at)}</td><td class=num>${v.attempts}</td><td><span class="pill ${v.expires_at < Date.now() ? "bad" : "ok"}">${v.expires_at < Date.now() ? "expired" : "pending"}</span></td></tr>`))}${verifs.length ? "" : "<tr><td colspan=6 class=dim>no pending codes</td></tr>"}</tbody>
1070
1145
  </table></div>
1071
1146
  <p class=note>A row here means the relay generated a code and handed it to the email provider. No row means the request never reached the relay. Codes themselves are never stored.</p>
1147
+ </section>
1072
1148
 
1073
- <h2>Audit log <span class=dim>(latest 300)</span> <input type=search aria-label="Filter audit" placeholder="Filter audit" data-filter="t-audit"></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-audit>
1074
- <tr><th scope=col>time</th><th scope=col>event</th><th scope=col>space</th><th scope=col>target</th><th scope=col>detail</th><th scope=col>ip</th></tr>
1075
- ${rows(audit.map((e) => `<tr><td class=dim>${when(e.ts)}</td><td>${e.event.startsWith("admin_") ? `<span class=pill>${esc(e.event)}</span>` : esc(e.event)}</td><td>${esc(e.space) || ""}</td><td><code>${esc(e.target) || ""}</code></td><td class=dim>${esc(e.detail) || ""}</td><td class=dim>${esc(e.ip) || ""}</td></tr>`))}
1149
+ <section id=s-audit>
1150
+ <div class=sec><h2>Audit log</h2><span class=count>latest ${audit.length}</span><input type=search aria-label="Filter audit" placeholder="Filter audit log" data-filter="t-audit"></div>
1151
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-audit>
1152
+ <thead><tr><th>time</th><th>event</th><th>space</th><th>target</th><th>detail</th><th>ip</th></tr></thead>
1153
+ <tbody>${rows(audit.map((e) => `<tr><td class=dim>${when(e.ts)}</td><td>${e.event.startsWith("admin_") ? `<span class="pill info">${esc(e.event)}</span>` : esc(e.event)}</td><td>${esc(e.space) || ""}</td><td><code>${esc(e.target) || ""}</code></td><td class="dim wrapc">${esc(e.detail) || ""}</td><td class=dim>${esc(e.ip) || ""}</td></tr>`))}${audit.length ? "" : "<tr><td colspan=6 class=dim>nothing yet</td></tr>"}</tbody>
1076
1154
  </table></div>
1077
1155
  </section>
1078
1156
  </main>
@@ -1084,8 +1162,8 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
1084
1162
  result.setAttribute("role", "status");
1085
1163
  table.parentElement.insertAdjacentElement("afterend", result);
1086
1164
  inp.addEventListener("input", function () {
1087
- var q = inp.value.trim().toLowerCase(), rowsEl = table.querySelectorAll("tr"), shown = 0;
1088
- for (var i = 1; i < rowsEl.length; i++) {
1165
+ var q = inp.value.trim().toLowerCase(), rowsEl = table.querySelectorAll("tbody tr"), shown = 0;
1166
+ for (var i = 0; i < rowsEl.length; i++) {
1089
1167
  var match = (!q || rowsEl[i].textContent.toLowerCase().indexOf(q) > -1);
1090
1168
  var empty = rowsEl[i].querySelector("td[colspan]");
1091
1169
  rowsEl[i].hidden = !match;
@@ -1133,31 +1211,35 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
1133
1211
  return `/admin/space?${p.toString()}`;
1134
1212
  };
1135
1213
  const pill = (x) => `<span class="pill ${ACT_TONE[x]}">${x}</span>`;
1136
- const goalTitle = (id) => id ? `<a href="${link({ goal: id, before: undefined })}" title="${esc(id)}">${esc(state.goals.get(id)?.title ?? id)}</a>` : "<span class=dim>—</span>";
1137
- const metaLine = (r) => {
1138
- const bits = [`Message ID: <code>${esc(r.id)}</code>`];
1214
+ const goalTitle = (id, short = false) => id
1215
+ ? `<a href="${link({ goal: id, before: undefined })}" title="${esc(id)}">${esc(short ? clipText(state.goals.get(id)?.title ?? id, 48) : state.goals.get(id)?.title ?? id)}</a>`
1216
+ : "<span class=dim>—</span>";
1217
+ const firstLine = (s) => s.split("\n").find((l) => l.trim()) ?? "";
1218
+ const rowsHtml = feedRows.map((r) => {
1219
+ const meta = (r.meta ?? {});
1220
+ const kv = [["Message ID", `<code>${esc(r.id)}</code>`]];
1221
+ if (r.ref)
1222
+ kv.push(["Reply to", `<code>${esc(r.ref)}</code>`]);
1223
+ if (r.goal)
1224
+ kv.push(["Goal", `${goalTitle(r.goal)} <code class=dim>${esc(r.goal)}</code>`]);
1139
1225
  if (r.role)
1140
- bits.push(`Role: ${esc(r.role)}`);
1226
+ kv.push(["Session role", esc(r.role)]);
1141
1227
  const agent = sp?.members.get(r.from)?.agent;
1142
1228
  if (agent)
1143
- bits.push(`Agent: ${esc(agent)}`);
1229
+ kv.push(["Agent", esc(agent)]);
1144
1230
  if (r.summary)
1145
- bits.push(`Summary: ${esc(r.summary)}`);
1146
- const meta = (r.meta ?? {});
1147
- for (const k of ["branch", "commit", "repo_origin", "decision_domains", "defers_to", "affects"])
1231
+ kv.push(["Summary", esc(r.summary)]);
1232
+ for (const k of ["branch", "commit", "remote", "base", "repo_origin", "decision_domains", "defers_to", "affects", "authority", "unverified_domains"])
1148
1233
  if (meta[k] !== undefined)
1149
- bits.push(`${k}: ${esc(typeof meta[k] === "string" ? meta[k] : JSON.stringify(meta[k]))}`);
1150
- if (r.ref)
1151
- bits.push(`Reply to: <code>${esc(r.ref)}</code>`);
1152
- return `<details class=message-meta><summary>Message details</summary><div>${bits.join("<br>")}</div></details>`;
1153
- };
1154
- const said = (r) => r.body.length > 600
1155
- ? `<details class=message-content><summary><span class=preview>${esc(r.body.slice(0, 400))}…</span><span class=read-more>Read full message</span><span class=read-less>Show less</span></summary><div class=message-body>${esc(r.body)}</div></details>`
1156
- : `<div class=message-body>${esc(r.body) || "<span class=dim>(empty message)</span>"}</div>`;
1157
- const rowsHtml = feedRows.map((r) => `<article class=message aria-label="Message ${r.seq} from ${esc(r.from)}">
1158
- <div class=message-heading><a href="${link({ member: r.from, before: undefined })}"><strong>${esc(r.from)}</strong></a><span class=dim>to ${r.to?.length ? r.to.map((t) => esc(t)).join(", ") : "everyone"}</span>${pill(r.act)}<time datetime="${new Date(r.ts).toISOString()}" title="${when(r.ts)} UTC">${ago(r.ts)} · #${r.seq}</time></div>
1159
- ${r.goal ? `<div class=message-goal>${goalTitle(r.goal)}</div>` : ""}
1160
- ${said(r)}${metaLine(r)}</article>`).join("");
1234
+ kv.push([k.replace(/_/g, " "), esc(typeof meta[k] === "string" ? meta[k] : JSON.stringify(meta[k]))]);
1235
+ const gist = clipText(r.summary ?? firstLine(r.body), 150);
1236
+ return `<tr><td class="num dim">#${r.seq}</td><td class=dim title="${when(r.ts)} UTC">${ago(r.ts)}</td>`
1237
+ + `<td class=cell><a href="${link({ member: r.from, before: undefined })}"><code>${esc(r.from)}</code></a><span class=sub>→ ${r.to?.length ? r.to.map((t) => esc(t)).join(", ") : "everyone"}${r.role && r.role !== "interactive" ? ` · ${esc(r.role)}` : ""}</span></td>`
1238
+ + `<td>${pill(r.act)}</td><td class=dim>${r.goal ? goalTitle(r.goal, true) : "—"}</td>`
1239
+ + `<td class=wrapc>${esc(gist) || "<span class=dim>(empty message)</span>"}</td>`
1240
+ + `<td class=actions><button type=button class=more aria-expanded=false onclick="toggleRow(this)"><span class=rm>Read full message</span><span class=rl hidden>Show less</span></button></td></tr>`
1241
+ + `<tr class=detail hidden><td colspan=7><div class=body>${esc(r.body) || "<span class=dim>(empty message)</span>"}</div><div class=kv>${kv.map(([k, v]) => `<span>${k}</span><b>${v}</b>`).join("")}</div></td></tr>`;
1242
+ }).join("");
1161
1243
  const entries = allow.all();
1162
1244
  const openInvites = allow.openInvites();
1163
1245
  const csrfTok = this.adminCsrf();
@@ -1171,94 +1253,107 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
1171
1253
  const refusals = state.refusals;
1172
1254
  const refusalRows = [...refusals.recent].reverse().map((r) => `<tr><td class=dim>${when(r.ts)}</td><td><code>${esc(r.member)}</code></td><td>${esc(r.act)}</td><td><span class="pill bad">${esc(r.code)}</span></td></tr>`).join("");
1173
1255
  const byCode = Object.entries(refusals.by_code).sort((x, y) => y[1] - x[1]).map(([c, n]) => `<code>${esc(c)}</code> ${n}`).join(" · ");
1174
- const open = [...state.goals.values()].filter((g) => g.state !== "CLOSED").length;
1256
+ const goals = [...state.goals.values()];
1257
+ const open = goals.filter((g) => g.state !== "CLOSED").length;
1258
+ const lastIn = new Map();
1259
+ for (const r of records)
1260
+ if (r.goal)
1261
+ lastIn.set(r.goal, Math.max(lastIn.get(r.goal) ?? 0, r.ts));
1262
+ const owedIn = new Map();
1263
+ if (sp)
1264
+ for (const o of sp.obligations.values())
1265
+ if (o.goal)
1266
+ owedIn.set(o.goal, (owedIn.get(o.goal) ?? 0) + 1);
1267
+ const goalRows = goals.sort((x, y) => (x.state === "CLOSED" ? 1 : 0) - (y.state === "CLOSED" ? 1 : 0) || (lastIn.get(y.id) ?? 0) - (lastIn.get(x.id) ?? 0)).map((g) => `<tr><td class="cell wrapc"><a href="${link({ goal: g.id, before: undefined })}">${esc(g.title)}</a><span class=sub><code>${esc(g.id)}</code></span></td>`
1268
+ + `<td><span class="pill ${g.state === "OPEN" ? "ok" : g.state === "CLOSED" ? "dim" : "warn"}">${esc(g.state)}</span></td>`
1269
+ + `<td class=num>${g.acts}<span class=dim> / ${g.budget}</span></td><td>${g.members.map((m) => `<code>${esc(m)}</code>`).join(", ")}</td>`
1270
+ + `<td class="num${owedIn.get(g.id) ? " tone-warn" : ""}">${sp ? owedIn.get(g.id) ?? 0 : "<span class=dim>—</span>"}</td><td class=dim title="${when(lastIn.get(g.id) ?? null)}">${ago(lastIn.get(g.id) ?? null)}</td></tr>`).join("");
1175
1271
  const options = MESSAGE_ACTS.map((x) => `<option value="${x}"${q.act === x ? " selected" : ""}>${x}</option>`).join("");
1272
+ const obj24 = a.by_act_24h.OBJECT ?? 0, esc24 = countActs(a.by_act_24h, ESCALATIONS), ref24 = refusalsSince(refusals, dayAgo);
1176
1273
  const html = `<!doctype html><html lang=en><meta charset=utf-8><title>pantheon · admin · ${esc(name)}</title>
1177
1274
  <meta name=viewport content="width=device-width,initial-scale=1">
1178
1275
  ${ADMIN_CSS}
1179
- <style>
1180
- .conversation{max-width:960px;margin:0 auto}
1181
- .message{padding:20px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}
1182
- .message-heading{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px}
1183
- .message-heading time{margin-left:auto;color:var(--dim);font-size:12px}
1184
- .message-goal{margin:6px 0 12px}.message-body,.preview{white-space:pre-wrap;max-width:75ch}
1185
- .message-body{margin-top:12px}.message-content{margin-top:12px}
1186
- .message-content>summary{cursor:pointer;list-style:none}.preview{display:block;color:var(--fg)}
1187
- .read-more,.read-less{display:block;color:var(--accent);padding:8px 0}
1188
- .read-less,.message-content[open] .read-more,.message-content[open] .preview{display:none}
1189
- .message-content[open] .read-less{display:block}
1190
- .message-meta{color:var(--dim);font-size:12px;margin-top:12px}.message-meta summary{cursor:pointer;padding:6px 0}
1191
- .message-meta>div{max-width:75ch;white-space:pre-wrap}
1192
- .filters label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dim)}
1193
- .filters .presence{flex-direction:row;align-items:center}.filters input[type=checkbox]{min-height:0}
1194
- .pagination{display:flex;justify-content:space-between;gap:16px;margin:20px 0}
1195
- .empty{padding:24px 0}.empty p{color:var(--dim)}
1196
- @media(max-width:600px){.filters label{flex:1 1 140px}.filters input,.filters select{width:100%;min-width:0}.filters .presence{flex-basis:100%}.filters input[type=checkbox]{width:auto}.message-heading time{margin-left:0;width:100%}}
1197
- .pill.warn{color:var(--warn);border-color:var(--warn)}.pill.info{color:var(--accent);border-color:var(--accent)}.pill.dim{color:var(--dim)}
1198
- .filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0 10px}
1199
- .filters input,.filters select{font:13px system-ui;padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
1200
- .crumbs a{color:var(--dim);text-decoration:none}.crumbs a:hover{color:var(--accent)}
1201
- td a{text-decoration:none}td a:hover{text-decoration:underline}
1202
- </style>
1203
1276
  <header>
1204
1277
  <h1><span class=crumbs><a href="/admin">admin</a> › </span>${esc(name)} <span class="pill ${sp ? "ok" : ""}">${sp ? "live" : "sleeping"}</span></h1>
1205
- <span class=dim>${esc(allow.peekId() ?? "no id yet")} · ${records.length} acts retained${a.first ? ` since ${when(a.first)}` : ""}</span>
1206
- <span style="margin-left:auto"><a href="/admin">← dashboard</a> · <a href="/admin/logout">sign out</a></span>
1278
+ <span class=meta>${esc(allow.peekId() ?? "no id yet")} · ${records.length} acts retained${a.first ? ` since ${when(a.first)}` : ""}</span>
1279
+ <span class=links><a href="/admin">← dashboard</a><a href="/admin/logout">sign out</a></span>
1207
1280
  </header>
1208
- <main id=conversation class=conversation>
1209
- <section>
1210
- <h2>Conversation <span class=dim>(${feedRows.length} shown${filtered ? " · filtered" : ""})</span></h2>
1281
+ <nav class=jump aria-label="Sections"><a href="#s-goals">Goals</a><a href="#conversation">Messages</a><a href="#s-team">Team &amp; diagnostics</a></nav>
1282
+ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchParams.get("done"))}</div>` : ""}
1283
+ <main>
1284
+ <div class=kpis>
1285
+ <div class=kpi><div class=l>Members</div><div class=v>${entries.filter((e) => e.admitted).length}<small>admitted</small></div><div class=s>${sp ? [...sp.members.values()].filter((m) => m.sessions.size).length : 0} online now · ${openInvites.length} unused invite${openInvites.length === 1 ? "" : "s"}</div></div>
1286
+ <div class=kpi><div class=l>Goals</div><div class=v>${open}<small>/ ${goals.length} open</small></div><div class=s>${sp ? `${sp.obligations.size} obligation${sp.obligations.size === 1 ? "" : "s"} owed right now` : "asleep — reloads on the next connect"}</div></div>
1287
+ <div class=kpi><div class=l>Messages today</div><div class=v>${a.messages_24h}<small>/ ${a.messages} retained</small></div><div class=s>${a.senders_24h.length} delegate${a.senders_24h.length === 1 ? "" : "s"} active today · ${a.sessions_24h} sessions</div></div>
1288
+ <div class="kpi${obj24 + esc24 ? " warn" : ""}"><div class=l>Needs a look today</div><div class=v>${obj24 + esc24}</div><div class=s>${obj24} objection${obj24 === 1 ? "" : "s"} · ${esc24} escalation${esc24 === 1 ? "" : "s"} · ${a.by_act_24h.DELIVERED ?? 0} deliver${(a.by_act_24h.DELIVERED ?? 0) === 1 ? "y" : "ies"}</div></div>
1289
+ <div class="kpi${ref24 ? " warn" : ""}"><div class=l>Refused by the relay</div><div class=v>${ref24}<small>today / ${refusals.total} total</small></div><div class=s>${byCode || "nothing refused yet"}</div></div>
1290
+ </div>
1291
+
1292
+ <section id=s-goals>
1293
+ <div class=sec><h2>Goals</h2><span class=count>${goals.length} · open first, most recent activity first · click a goal to see only its messages</span></div>
1294
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-goals>
1295
+ <thead><tr><th>goal</th><th>state</th><th class=num>acts / budget</th><th>members</th><th class=num>owed</th><th>last activity</th></tr></thead>
1296
+ <tbody>${goalRows || "<tr><td colspan=6 class=dim>no goals yet</td></tr>"}</tbody>
1297
+ </table></div>
1298
+ </section>
1299
+
1300
+ <section id=conversation>
1301
+ <div class=sec><h2>Messages</h2><span class=count>${feedRows.length} shown${filtered ? " · filtered" : ""} · newest first</span></div>
1211
1302
  <form class=filters method=GET action="/admin/space"><input type=hidden name=name value="${esc(name)}">
1212
- <label>Message type<select name=act><option value="">All types</option>${options}</select></label>
1213
- <label>Member<input name=member placeholder="Any member" value="${esc(q.member)}" size=12></label>
1214
- <label>Goal<input name=goal placeholder="Any goal ID" value="${esc(q.goal)}" size=16></label>
1215
- <label>Search messages<input type=search name=q placeholder="Find words or phrases" value="${esc(q.q)}" size=24></label>
1216
- <label class=presence><input type=checkbox name=presence value=1${q.presence ? " checked" : ""}> Include arrivals and departures</label>
1217
- <button class=act>Apply filters</button>${filtered ? `<a href="/admin/space?name=${encodeURIComponent(name)}">Clear filters</a>` : ""}
1303
+ <label>type<select name=act><option value="">All types</option>${options}</select></label>
1304
+ <label>member<input type=text name=member placeholder="Any member" value="${esc(q.member)}" size=12></label>
1305
+ <label>goal<input type=text name=goal placeholder="Any goal ID" value="${esc(q.goal)}" size=16></label>
1306
+ <label>search<input type=search name=q placeholder="Find words or phrases" value="${esc(q.q)}" size=26></label>
1307
+ <label class=chk><input type=checkbox name=presence value=1${q.presence ? " checked" : ""}> include arrivals and departures</label>
1308
+ <button class=act>Apply filters</button>${filtered ? `<a href="/admin/space?name=${encodeURIComponent(name)}" style="padding-bottom:7px">Clear filters</a>` : ""}
1218
1309
  </form>
1219
- ${rowsHtml || `<div class=empty><h3>${filtered ? "No messages match these filters" : "No messages yet"}</h3><p>${filtered ? "Try a different search or clear the filters to see the conversation." : "Messages will appear here when this team’s agents start working together."}</p></div>`}
1220
- <nav class=pagination aria-label="Conversation pages">${q.before ? `<a href="${link({ before: undefined })}">← Latest messages</a>` : "<span></span>"}${next !== null ? `<a href="${link({ before: next })}">Older messages →</a>` : ""}</nav>
1221
- <p class=note>Retained history only: the relay keeps the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts of a space plus everything in an open goal. Messages are shown as sent. Expand Message details for IDs and delivery references.</p>
1310
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-messages>
1311
+ <thead><tr><th class=num>#</th><th>when</th><th>from → to</th><th>act</th><th>goal</th><th>what was said</th><th></th></tr></thead>
1312
+ <tbody>${rowsHtml || `<tr><td colspan=7 class=empty>${filtered ? "No messages match these filters. Try a different search or clear the filters." : "No messages yet. They appear here when this team's agents start working together."}</td></tr>`}</tbody>
1313
+ </table></div>
1314
+ <nav class=pager aria-label="Message pages">${q.before ? `<a href="${link({ before: undefined })}">← Latest messages</a>` : "<span></span>"}${next !== null ? `<a href="${link({ before: next })}">Older messages →</a>` : ""}</nav>
1315
+ <p class=note>Retained history only: the relay keeps the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts of a space plus everything in an open goal. Messages are shown as sent; nothing is edited or summarised. Read full message shows the whole text with its ids and delivery references.</p>
1222
1316
  </section>
1223
- </main>
1224
- <details class=diagnostics><summary>Team details and diagnostics · ${entries.length} members · ${open} open goals</summary>
1225
- <div class=stats>
1226
- <div class=stat><b>${a.messages_24h}<span class=dim style="font-size:14px"> / ${a.messages}</span></b><span>messages today / retained</span></div>
1227
- <div class=stat><b>${a.senders_24h.length}<span class=dim style="font-size:14px"> · ${a.sessions_24h} sessions</span></b><span>delegates active today</span></div>
1228
- <div class="stat ${a.by_act_24h.OBJECT ? "warn" : ""}"><b>${a.by_act_24h.OBJECT ?? 0}<span class=dim style="font-size:14px"> / ${a.by_act.OBJECT ?? 0}</span></b><span>objections today / retained</span></div>
1229
- <div class="stat ${countActs(a.by_act_24h, ESCALATIONS) ? "warn" : ""}"><b>${countActs(a.by_act_24h, ESCALATIONS)}<span class=dim style="font-size:14px"> / ${countActs(a.by_act, ESCALATIONS)}</span></b><span>escalations today / retained</span></div>
1230
- <div class=stat><b>${a.by_act_24h.DELIVERED ?? 0}<span class=dim style="font-size:14px"> / ${a.by_act.DELIVERED ?? 0}</span></b><span>deliveries today / retained</span></div>
1231
- <div class="stat ${refusalsSince(refusals, dayAgo) ? "warn" : ""}"><b>${refusalsSince(refusals, dayAgo)}<span class=dim style="font-size:14px"> / ${refusals.total}</span></b><span>refused today / total</span></div>
1232
- <div class=stat><b>${open}<span class=dim style="font-size:14px"> / ${state.goals.size}</span></b><span>goals open / total</span></div>
1233
- ${sp ? `<div class=stat><b>${sp.obligations.size}</b><span>obligations owed</span></div>` : ""}
1234
- </div>
1235
- <section>
1236
- <h2>Members <span class=dim>(${entries.filter((e) => e.admitted).length} admitted)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
1237
- <tr><th scope=col>member</th><th scope=col>state</th><th scope=col>may wake</th><th scope=col>online</th><th scope=col>last seen</th></tr>
1238
- ${memberRows || "<tr><td colspan=5 class=dim>nobody yet</td></tr>"}
1317
+
1318
+ <section id=s-team>
1319
+ <div class=sec><h2>Team details and diagnostics</h2><span class=count>${entries.length} member${entries.length === 1 ? "" : "s"} · ${open} open goal${open === 1 ? "" : "s"}</span></div>
1320
+ <h3 style="font-size:14px;margin:14px 0 8px">Members</h3>
1321
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
1322
+ <thead><tr><th>member</th><th>state</th><th>may wake</th><th>online</th><th>last seen</th></tr></thead>
1323
+ <tbody>${memberRows || "<tr><td colspan=5 class=dim>nobody yet</td></tr>"}</tbody>
1239
1324
  </table></div>
1240
1325
 
1241
- <h2>Open invites <span class=dim>(${openInvites.length} unused)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
1242
- <tr><th scope=col>token</th><th scope=col>minted by</th><th scope=col>expires</th><th scope=col>may wake</th><th scope=col></th></tr>
1243
- ${openInvites.map((i) => `<tr><td><code>…${esc(i.token.slice(-6))}</code></td><td><code>${esc(i.created_by)}</code></td><td class=dim>${when(i.expires_at)}</td><td>${i.may_wake ? "yes" : "no"}</td><td>${revokeForm(i.token)}</td></tr>`).join("") || "<tr><td colspan=5 class=dim>none open</td></tr>"}
1326
+ <h3 style="font-size:14px;margin:22px 0 8px">Open invites <span class=dim>(${openInvites.length} unused)</span></h3>
1327
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
1328
+ <thead><tr><th>token</th><th>minted by</th><th>expires</th><th>may wake</th><th></th></tr></thead>
1329
+ <tbody>${openInvites.map((i) => `<tr><td><code>…${esc(i.token.slice(-6))}</code></td><td><code>${esc(i.created_by)}</code></td><td class=dim>${when(i.expires_at)}</td><td>${i.may_wake ? "yes" : "no"}</td><td class=actions>${revokeForm(i.token)}</td></tr>`).join("") || "<tr><td colspan=5 class=dim>none open</td></tr>"}</tbody>
1244
1330
  </table></div>
1245
1331
  <p class=note>An unused invite counts toward the plan's people limit until it expires. Revoking makes the link inert immediately; members see theirs with <code>pantheon invite --list</code> and cancel with <code>--revoke</code>.</p>
1246
- ${repos.length ? `<h2>Repos this team works from</h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table><tr><th scope=col>origin</th><th scope=col>fingerprint</th><th scope=col>added by</th><th scope=col>when</th></tr>${repos.map((r) => `<tr><td>${esc(r.origin ?? "(no origin)")}</td><td><code>${esc(r.fingerprint)}</code></td><td><code>${esc(r.added_by)}</code></td><td class=dim>${when(r.at)}</td></tr>`).join("")}</table></div>` : ""}
1332
+ ${repos.length ? `<h3 style="font-size:14px;margin:22px 0 8px">Repos this team works from</h3><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table><thead><tr><th>origin</th><th>fingerprint</th><th>added by</th><th>when</th></tr></thead><tbody>${repos.map((r) => `<tr><td>${esc(r.origin ?? "(no origin)")}</td><td><code>${esc(r.fingerprint)}</code></td><td><code>${esc(r.added_by)}</code></td><td class=dim>${when(r.at)}</td></tr>`).join("")}</tbody></table></div>` : ""}
1247
1333
 
1248
- <h2>Acts <span class=dim>(today · retained)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-acts>
1249
- <tr><th scope=col></th>${MESSAGE_ACTS.map((x) => `<th scope=col>${x}</th>`).join("")}</tr>
1250
- <tr><td class=dim>today</td>${MESSAGE_ACTS.map((x) => `<td data-act="${x}" data-window="24h">${a.by_act_24h[x] ?? 0}</td>`).join("")}</tr>
1251
- <tr><td class=dim>retained</td>${MESSAGE_ACTS.map((x) => `<td data-act="${x}" data-window="all">${a.by_act[x] ?? 0}</td>`).join("")}</tr>
1334
+ <h3 style="font-size:14px;margin:22px 0 8px">Acts <span class=dim>(today · retained)</span></h3>
1335
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-acts>
1336
+ <thead><tr><th></th>${MESSAGE_ACTS.map((x) => `<th class=num>${x}</th>`).join("")}</tr></thead>
1337
+ <tbody><tr><td class=dim>today</td>${MESSAGE_ACTS.map((x) => `<td class=num data-act="${x}" data-window="24h">${a.by_act_24h[x] ?? 0}</td>`).join("")}</tr>
1338
+ <tr><td class=dim>retained</td>${MESSAGE_ACTS.map((x) => `<td class=num data-act="${x}" data-window="all">${a.by_act[x] ?? 0}</td>`).join("")}</tr></tbody>
1252
1339
  </table></div>
1253
1340
 
1254
- <h2>Refused by the relay <span class=dim>(latest ${refusals.recent.length} of ${refusals.total})</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
1255
- <tr><th scope=col>when</th><th scope=col>member</th><th scope=col>request</th><th scope=col>reason</th></tr>
1256
- ${refusalRows || "<tr><td colspan=4 class=dim>nothing refused</td></tr>"}
1341
+ <h3 style="font-size:14px;margin:22px 0 8px">Refused by the relay <span class=dim>(latest ${refusals.recent.length} of ${refusals.total})</span></h3>
1342
+ <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
1343
+ <thead><tr><th>when</th><th>member</th><th>request</th><th>reason</th></tr></thead>
1344
+ <tbody>${refusalRows || "<tr><td colspan=4 class=dim>nothing refused</td></tr>"}</tbody>
1257
1345
  </table></div>
1258
1346
  ${byCode ? `<p class=note>All time: ${byCode}</p>` : ""}
1259
-
1260
1347
  </section>
1261
- </details>`;
1348
+ </main>
1349
+ <script>
1350
+ function toggleRow(btn) {
1351
+ var row = btn.closest("tr"), detail = row.nextElementSibling, open = detail.hasAttribute("hidden");
1352
+ if (open) detail.removeAttribute("hidden"); else detail.setAttribute("hidden", "");
1353
+ btn.setAttribute("aria-expanded", open ? "true" : "false");
1354
+ btn.querySelector(".rm").hidden = open; btn.querySelector(".rl").hidden = !open;
1355
+ }
1356
+ </script>`;
1262
1357
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
1263
1358
  res.end(html);
1264
1359
  }
package/dist/worker.js CHANGED
@@ -690,6 +690,8 @@ export class Worker {
690
690
  this.opts.log(`! self-upgrade failed: ${e.message}`);
691
691
  }
692
692
  if (result.ok && !result.alreadyCurrent && result.to === result.from) {
693
+ delete policy.attempts.self_upgrade;
694
+ savePolicy(policy, home);
693
695
  this.opts.log(`* self-upgrade: the registry does not serve ${relayPkg} yet (installed ${result.to}); will check again`);
694
696
  return;
695
697
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
5
5
  "type": "module",
6
6
  "bin": {