<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>gage</title>
<link rel="icon" href="/favicon.svg">
<style>
:root{
--t1:11px; --t2:13px; --t3:14.5px; --t4:17px; --t5:24px;
--w1:400; --w2:550; --w3:680;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px;
--r1:8px; --r2:16px; --r3:999px;
--r:var(--r2);
--rail:320px;
--measure:62ch;
--bg:#f5f5f7; --sheet:#ffffff; --sunk:#ececf0; --line:#dedee3; --line2:#c9c9ce;
--fg:#1d1d1f; --dim:#6e6e73; --faint:#86868b;
--accent:#0066cc; --accent-ink:#ffffff; --accent-soft:#e6f0fb;
--brand:#1D6E47; --brand-ink:#f4f1e8;
--ok:#248a3d; --warn:#b25000; --bad:#d70015;
--prompt:#b25000; --memory:#d70015; --decision:#5453c9; --file:#5f6b7a;
--shadow:0 2px 6px rgba(0,0,0,.05), 0 20px 48px -20px rgba(0,0,0,.25);
--sans:-apple-system,BlinkMacSystemFont,ui-sans-serif,system-ui,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
--ease:140ms cubic-bezier(.2,.6,.3,1);
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--bg:#000000; --sheet:#1c1c1e; --sunk:#2c2c2e; --line:#333336; --line2:#48484a;
--fg:#f5f5f7; --dim:#aeaeb2; --faint:#8e8e93;
--accent:#2997ff; --accent-ink:#ffffff; --accent-soft:#10283f;
--brand:#1D6E47; --brand-ink:#f4f1e8;
--ok:#30d158; --warn:#ffb340; --bad:#ff6961;
--prompt:#ffb340; --memory:#ff6961; --decision:#a8a8e4; --file:#a1a1a6;
--shadow:0 2px 6px rgba(0,0,0,.5), 0 20px 48px -20px rgba(0,0,0,.85);
color-scheme:dark;
}}
:root[data-theme=dark]{
--bg:#000000; --sheet:#1c1c1e; --sunk:#2c2c2e; --line:#333336; --line2:#48484a;
--fg:#f5f5f7; --dim:#aeaeb2; --faint:#8e8e93;
--accent:#2997ff; --accent-ink:#ffffff; --accent-soft:#10283f;
--brand:#1D6E47; --brand-ink:#f4f1e8;
--ok:#30d158; --warn:#ffb340; --bad:#ff6961;
--prompt:#ffb340; --memory:#ff6961; --decision:#a8a8e4; --file:#a1a1a6;
--shadow:0 2px 6px rgba(0,0,0,.5), 0 20px 48px -20px rgba(0,0,0,.85);
color-scheme:dark;
}
*{box-sizing:border-box;margin:0;padding:0}
[hidden]{display:none!important}
html,body{height:100%}
body{background:var(--bg);color:var(--fg);font-family:var(--sans);
font-size:var(--t3);line-height:1.55;-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;font-variant-numeric:tabular-nums}
button,input,select,textarea{font:inherit;color:inherit}
button{background:none;border:0;cursor:pointer}
::selection{background:var(--accent-soft)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--r1)}
.state,.ty,.tag,.verdict,.bsec,.lanhead{font-size:var(--t1);letter-spacing:.07em;
text-transform:uppercase;font-weight:var(--w3)}
.app{display:grid;grid-template-columns:var(--rail) 1fr;
grid-template-rows:auto auto 1fr;height:100dvh}
.topbar{grid-column:1/-1;grid-row:1;display:flex;align-items:center;gap:var(--s3);
flex-wrap:wrap;min-width:0;
padding:var(--s3) var(--s4);border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--sheet) 80%,transparent);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px)}
.banners{grid-column:1/-1;grid-row:2;display:flex;flex-direction:column}
.left{grid-column:1;grid-row:3}
.right{grid-column:2;grid-row:3}
@media (max-width:880px){
.app{grid-template-columns:1fr}
.left,.right{grid-column:1;grid-row:3}
.right{display:none}
.app.detail .left{display:none}
.app.detail .right{display:flex}
.app.buds{grid-template-rows:auto auto minmax(0,auto) minmax(0,1fr)}
.app.buds .left{grid-row:3;max-height:48dvh;border-right:0;
border-bottom:1px solid var(--line)}
.app.buds .right{grid-row:4;display:flex}
}
.left{border-right:1px solid var(--line);background:var(--sheet);
display:flex;flex-direction:column;min-height:0;min-width:0}
.logo{width:var(--s6);height:var(--s6);border-radius:var(--r1);flex:none;
display:grid;place-items:center;background:var(--brand);color:var(--brand-ink)}
.logo svg{width:var(--s5);height:var(--s5);display:block}
.wordmark{font-weight:var(--w3);font-size:var(--t4);letter-spacing:-.01em}
.whoami{margin-left:auto;text-align:right;min-width:0;max-width:34ch;overflow:hidden}
.whoami .h,.whoami .k{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.whoami .h{font-size:var(--t2);font-weight:var(--w2);color:var(--dim)}
.whoami .k{font-family:var(--mono);font-size:var(--t1);color:var(--faint)}
.tabs{display:flex;gap:var(--s1);margin-left:var(--s4)}
.tab{font-size:var(--t2);padding:var(--s1) var(--s3);border-radius:var(--r3);cursor:pointer;
border:1px solid transparent;background:transparent;color:var(--dim);display:flex;
align-items:center;gap:var(--s2);transition:background var(--ease),color var(--ease)}
.tab:hover{background:var(--sunk);color:var(--fg)}
.tab.on{background:var(--accent);border-color:transparent;color:var(--accent-ink);
font-weight:var(--w2)}
.tab .bdg{font-size:var(--t1);min-width:var(--s4);height:var(--s4);border-radius:var(--r3);
padding:0 var(--s1);
display:grid;place-items:center;background:var(--bad);color:#fff;font-weight:var(--w3)}
.filters{display:flex;gap:var(--s1);padding:var(--s4) var(--s4) var(--s3)}
.chip{font-size:var(--t2);padding:var(--s1) var(--s3);border-radius:var(--r3);color:var(--dim);
border:1px solid transparent;transition:background var(--ease),color var(--ease)}
.chip:hover{background:var(--sunk);color:var(--fg)}
.chip.on{background:var(--accent-soft);color:var(--accent);font-weight:var(--w2)}
.chip .n{font-family:var(--mono);font-size:var(--t1);opacity:.7;margin-left:var(--s1)}
.list{overflow-y:auto;flex:1;min-height:0;padding:0 var(--s3) var(--s4);scrollbar-width:thin}
.t{width:100%;text-align:left;padding:var(--s3);border-radius:var(--r2);
display:grid;grid-template-columns:auto 1fr;gap:var(--s3);position:relative;
transition:background var(--ease)}
.t:hover{background:var(--sunk)}
.t.sel{background:var(--accent-soft)}
.t + .t{margin-top:var(--s1)}
.avatar{width:var(--s6);height:var(--s6);border-radius:var(--r1);flex:none;display:grid;
place-items:center;font-size:var(--t2);font-weight:var(--w3)}
.tbody{min-width:0}
.trow{display:flex;gap:var(--s2);align-items:baseline}
.subj{font-weight:var(--w2);font-size:var(--t3);flex:1;letter-spacing:-.005em;
display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
.when{font-family:var(--mono);font-size:var(--t1);color:var(--faint);flex:none}
.tmeta{display:flex;gap:var(--s2);align-items:center;margin-top:var(--s1);font-size:var(--t2);
color:var(--dim);min-width:0}
.tmeta .peers{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pip{width:var(--s2);height:var(--s2);border-radius:var(--r3);background:var(--accent);flex:none}
.state{padding:var(--s1) var(--s2);border-radius:var(--r1);flex:none;white-space:nowrap}
.s-you{color:var(--warn);background:color-mix(in srgb,var(--warn) 13%,transparent)}
.s-them{color:var(--dim);background:var(--sunk)}
.s-settled{color:var(--ok);background:color-mix(in srgb,var(--ok) 13%,transparent)}
.s-closed{color:var(--faint);background:var(--sunk)}
.empty{padding:var(--s7) var(--s5);color:var(--faint);text-align:center;
font-size:var(--t2);line-height:1.75}
.right{display:flex;flex-direction:column;min-height:0;min-width:0;background:var(--bg)}
.head{padding:var(--s4) var(--s5);border-bottom:1px solid var(--line);background:var(--sheet);
display:flex;gap:var(--s3);align-items:center;flex:none}
.back{display:none;font-size:var(--t4);color:var(--dim);padding:var(--s1) var(--s2)}
@media (max-width:880px){.back{display:block}}
.head h1{font-size:var(--t4);font-weight:var(--w3);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;letter-spacing:-.012em}
.head .sp{flex:1}
.headbtn{font-size:var(--t2);color:var(--dim);padding:var(--s2) var(--s3);border-radius:var(--r3);
border:1px solid var(--line2);white-space:nowrap;transition:border-color var(--ease),color var(--ease)}
.headbtn:hover{border-color:var(--accent);color:var(--accent)}
.msgs{overflow-y:auto;flex:1;min-height:0;padding:var(--s5) var(--s5) var(--s3);
display:flex;flex-direction:column;gap:var(--s5)}
.m{max-width:760px;min-width:0;width:100%;margin-inline:auto}
.m.mine{margin-left:auto}
.mhead{display:flex;gap:var(--s2);align-items:center;margin-bottom:var(--s2);
flex-wrap:wrap}
.m.mine .mhead{justify-content:flex-end}
.who{font-weight:var(--w3);font-size:var(--t2)}
.ty{padding:var(--s1) var(--s2);border-radius:var(--r1);background:var(--sunk);color:var(--dim)}
.ty.question,.ty.review-request{color:var(--warn)}
.ty.answer{color:var(--ok)}
.at{font-family:var(--mono);font-size:var(--t1);color:var(--faint)}
.dot{width:var(--s2);height:var(--s2);border-radius:var(--r3);background:var(--accent)}
.sheet{background:var(--sheet);border:1px solid var(--line);border-radius:var(--r2);
overflow:hidden;box-shadow:var(--shadow)}
.m.mine .sheet{background:var(--sunk);box-shadow:none}
.verdict{display:flex;gap:var(--s2);align-items:center;padding:var(--s3) var(--s4);
font-size:var(--t2);letter-spacing:.06em}
.verdict .vdot{width:var(--s2);height:var(--s2);border-radius:var(--r3);background:currentColor;flex:none}
.v-ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 10%,transparent)}
.v-warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 10%,transparent)}
.v-bad{color:var(--bad);background:color-mix(in srgb,var(--bad) 12%,transparent)}
.stale{margin:0 var(--s3) var(--s2);padding:var(--s2) var(--s3);border-radius:var(--r1);
font-size:var(--t2);line-height:1.5;color:var(--bad);
border:1px solid color-mix(in srgb,var(--bad) 35%,transparent);
background:color-mix(in srgb,var(--bad) 10%,transparent)}
.connbar{margin:0 var(--s3) var(--s2);padding:var(--s2) var(--s3);border-radius:var(--r1);
font-size:var(--t2);line-height:1.5;color:var(--ink);
border:1px solid var(--line);background:var(--sheet);
display:flex;flex-direction:column;gap:var(--s1)}
.connbar[hidden]{display:none}
.connrow{display:flex;align-items:center;gap:var(--s2);flex-wrap:wrap}
.connrow .sp{flex:1}
.bigfp{font-size:1.02rem;color:var(--ink);letter-spacing:.01em;padding:var(--s1) 0}
.dev.spot{border-color:var(--accent);
box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)}
.alert{padding:var(--s3) var(--s4);font-size:var(--t2);line-height:1.55;color:var(--warn);
border-top:1px solid var(--line)}
.alert.bad{color:var(--bad)}
.alert b{font-weight:var(--w3)}
.alert code{font-family:var(--mono);font-size:var(--t1)}
.files{border-top:1px solid var(--line);padding:var(--s1) 0}
.f{display:flex;gap:var(--s3);align-items:center;padding:var(--s1) var(--s4);
font-family:var(--mono);font-size:var(--t1)}
.tag{padding:var(--s1) 0;border-radius:var(--r1);width:calc(var(--s7) + var(--s4));text-align:center;flex:none;
font-family:var(--sans);background:var(--sunk);letter-spacing:.06em}
.m.mine .tag{background:var(--sheet)}
.t-file{color:var(--file)} .t-prompt{color:var(--prompt)}
.t-memory{color:var(--memory)} .t-decision{color:var(--decision)}
.fp{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fb{color:var(--faint);flex:none}
.acts{display:flex;gap:var(--s2);align-items:center;flex-wrap:wrap;
padding:var(--s3) var(--s4);border-top:1px solid var(--line)}
.btn{font-size:var(--t2);padding:var(--s2) var(--s4);border-radius:var(--r3);
border:1px solid var(--line2);white-space:nowrap;
transition:border-color var(--ease),color var(--ease),background var(--ease)}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn.go{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);
font-weight:var(--w2)}
.btn.go:hover{opacity:.88;color:var(--accent-ink)}
.btn.no{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent)}
.btn.no:hover{border-color:var(--bad);color:var(--bad)}
.cmd{font-family:var(--mono);font-size:var(--t1);color:var(--faint);margin-left:auto;
user-select:all;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:55%}
.composer{flex:none;border-top:1px solid var(--line);background:var(--sheet);
padding:var(--s4) var(--s5)}
.cwrap{max-width:720px;margin-inline:auto;display:flex;flex-direction:column;gap:var(--s2)}
.cto{font-size:var(--t2);color:var(--dim);display:flex;gap:var(--s2);align-items:center;
flex-wrap:wrap}
.cto b{font-weight:var(--w3);color:var(--fg)}
.cto select{font-size:var(--t2);padding:var(--s1) var(--s2);border-radius:var(--r1);
border:1px solid var(--line2);background:var(--bg)}
textarea{width:100%;min-height:64px;max-height:40vh;resize:vertical;
background:var(--bg);border:1px solid var(--line2);border-radius:var(--r2);
padding:var(--s3);font-size:var(--t3);line-height:1.6;
transition:border-color var(--ease)}
textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)}
.crow{display:flex;gap:var(--s3);align-items:center}
.chint{font-size:var(--t1);color:var(--faint)}
.cerr{font-size:var(--t2);color:var(--bad)}
.cblocked{font-size:var(--t2);color:var(--dim);line-height:1.55}
.lanbox{border-top:1px solid var(--line);padding:var(--s4) var(--s3) var(--s4);flex:none;
max-height:46dvh;overflow-y:auto}
.lanhead{display:flex;align-items:center;gap:var(--s2);color:var(--dim);
margin:0 0 var(--s3) var(--s2)}
.lanhead .dot{width:var(--s2);height:var(--s2);border-radius:var(--r3);background:var(--faint);flex:none}
.lanhead .dot.on{background:var(--ok);box-shadow:0 0 0 var(--s1) color-mix(in srgb,var(--ok) 20%,transparent)}
.app.solo{grid-template-columns:1fr}
.app.solo .left{display:none}
.app.solo .right{grid-column:1}
.setup{max-width:var(--measure);margin-inline:auto;width:100%}
.setup > .hint{padding-inline:0;margin-top:0}
.stept{font-size:var(--t2);color:var(--dim);margin-top:var(--s2);line-height:1.65}
.step{display:flex;gap:var(--s3);align-items:flex-start}
.stepn{font-family:var(--mono);font-size:var(--t1);color:var(--faint);
min-width:2ch;padding-top:.25em}
.stepb{flex:1;min-width:0}
.dev.done{background:var(--sheet)}
.dev{border:1px solid var(--line);border-radius:var(--r2);padding:var(--s3);
margin-bottom:var(--s2);background:var(--bg);transition:border-color var(--ease)}
.dev.sel{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}
.dev.ask{border-color:color-mix(in srgb,var(--warn) 45%,var(--line));background:var(--sunk)}
.devtop{display:flex;align-items:baseline;gap:var(--s2);flex-wrap:wrap}
.devname{font-weight:var(--w3);font-size:var(--t3);letter-spacing:-.005em}
.devtag{font-size:var(--t1);padding:var(--s1) var(--s2);border-radius:var(--r3);
border:1px solid var(--line2);color:var(--dim);white-space:nowrap}
.devtag.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}
.devtag.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}
.devfp{font-size:var(--t2);color:var(--fg);margin-top:var(--s2);line-height:1.6;
font-family:var(--mono);word-break:break-word;letter-spacing:-.01em}
.devrow{display:flex;gap:var(--s2);margin-top:var(--s3);flex-wrap:wrap}
.devbtn{font-size:var(--t2);padding:var(--s2) var(--s4);border-radius:var(--r3);cursor:pointer;
border:1px solid var(--line2);background:transparent;color:var(--fg);
transition:border-color var(--ease),color var(--ease)}
.devbtn:hover{border-color:var(--accent);color:var(--accent)}
.devbtn.go{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);
font-weight:var(--w2)}
.devbtn.go:hover{opacity:.88;color:var(--accent-ink);border-color:var(--accent)}
.devbtn.quiet{border-color:transparent;color:var(--dim)}
.devbtn.quiet:hover{border-color:transparent;color:var(--fg);background:var(--sunk)}
.devbtn.danger{border-color:var(--bad);color:var(--bad)}
.devbtn.danger:hover{background:var(--bad);color:#fff;border-color:var(--bad)}
.agkill{align-items:center}
.agkill .lansub{margin-top:0;flex:1 1 220px;min-width:0}
.lansub{font-size:var(--t2);color:var(--dim);margin-top:var(--s2);line-height:1.6}
.lanempty{font-size:var(--t2);color:var(--dim);line-height:1.6;margin-left:var(--s2)}
.lanrow{display:flex;align-items:center;gap:var(--s3);margin-left:var(--s2)}
.lanrow .lanempty{margin-left:0;flex:1;min-width:0;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap}
.netbox{max-height:none}
.lanin{font:inherit;font-size:var(--t2);padding:var(--s2);border-radius:var(--r1);
border:1px solid var(--line2);background:var(--bg);color:var(--fg);margin-top:var(--s2);
transition:border-color var(--ease)}
.lanin:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)}
.lanerr{font-size:var(--t2);color:var(--bad);margin-top:var(--s2);line-height:1.55}
.lanok{font-size:var(--t2);color:var(--ok);margin-top:var(--s2);line-height:1.55}
.drop{border:1px dashed var(--line2);border-radius:var(--r2);padding:var(--s4) var(--s3);
text-align:center;font-size:var(--t2);color:var(--dim);cursor:pointer;margin-top:var(--s2);
transition:border-color var(--ease),background var(--ease)}
.drop:hover,.drop.over{border-color:var(--accent);background:var(--accent-soft)}
.drop.has{border-style:solid;color:var(--fg)}
.side{padding:var(--s4) var(--s4) var(--s6);overflow-y:auto;flex:1;min-height:0;
min-width:0}
.side .hint{max-width:none;margin:0 0 var(--s5);padding:0;font-size:var(--t2)}
.side .hint h2{font-size:var(--t3);margin-bottom:var(--s2)}
.side .hint p{margin-bottom:var(--s3)}
.side .invite{margin-top:0}
.side .invite h2{font-size:var(--t3);margin-bottom:var(--s2)}
.side .invite p{font-size:var(--t2);margin-bottom:var(--s3);max-width:none}
.side .bsec:first-child{margin-top:0}
.side .budnote{max-width:none}
.disc{border-top:1px solid var(--line);margin-top:var(--s4);padding-top:var(--s3)}
.disc > summary{font-size:var(--t2);color:var(--dim);cursor:pointer;
font-weight:var(--w2);list-style:none}
.disc > summary::-webkit-details-marker{display:none}
.disc > summary::before{content:"\25B8\00a0"}
.disc[open] > summary::before{content:"\25BE\00a0"}
.disc > summary:hover{color:var(--fg)}
.disc .hint{margin-top:var(--s3)}
.budwrap{width:100%;min-width:0}
.budwrap .dev{min-width:0}
.budgrid{display:grid;gap:var(--s3);
grid-template-columns:repeat(auto-fill,minmax(336px,1fr));align-items:start}
.budgrid .dev{margin-bottom:0}
.aggrid{grid-template-columns:repeat(auto-fill,minmax(440px,1fr))}
.budnote{max-width:var(--measure)}
.hostpick{display:flex;gap:var(--s2);flex-wrap:wrap;margin-bottom:var(--s4)}
.copied{font-size:var(--t2);color:var(--ok)}
.copied.bad{color:var(--warn)}
.cfg{font-family:var(--mono);font-size:var(--t2);line-height:1.55;
background:var(--sunk);border:1px solid var(--line);border-radius:var(--r1);
padding:var(--s3);margin-top:var(--s2);overflow-x:auto;white-space:pre;
color:var(--fg)}
.bsec{color:var(--dim);margin:var(--s5) 0 var(--s3) var(--s2);display:flex;
align-items:baseline;gap:var(--s2)}
.bsec:first-child{margin-top:var(--s3)}
.bsect{color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit}
.blink{color:var(--accent);text-decoration:underline;padding:0}
.bsect:hover{color:var(--fg)}
.bgate{margin-top:var(--s3);padding-top:var(--s3);border-top:1px dashed var(--line2)}
.bwhy{font-size:var(--t2);color:var(--dim);line-height:1.65;margin-bottom:var(--s2)}
.bdots{display:flex;gap:var(--s3);margin-top:var(--s2);flex-wrap:wrap}
.breach{font-size:var(--t1);color:var(--faint);display:flex;align-items:center;gap:var(--s1)}
.breach.on{color:var(--ok)}
.breach i{width:var(--s1);height:var(--s1);border-radius:var(--r3);background:currentColor;
display:inline-block;opacity:.5}
.breach.on i{opacity:1}
.bnum{font-size:var(--t1);padding:var(--s1) var(--s2);border-radius:var(--r3);
background:var(--accent);color:var(--accent-ink);font-weight:var(--w3)}
.berr{font-size:var(--t2);color:var(--bad);margin-top:var(--s3);line-height:1.6}
.bok{font-size:var(--t2);color:var(--ok);margin-top:var(--s3);line-height:1.6}
.hint{max-width:var(--measure);margin:var(--s6) auto 0;padding:0 var(--s5);color:var(--dim);
font-size:var(--t3);line-height:1.7}
.hint h2{font-size:var(--t5);font-weight:var(--w3);color:var(--fg);
letter-spacing:-.022em;margin-bottom:var(--s4)}
.hint p{margin-bottom:var(--s4)}
.hint code{font-family:var(--mono);font-size:var(--t2);background:var(--sunk);
border:1px solid var(--line);border-radius:var(--r1);padding:var(--s1) var(--s2);
-webkit-box-decoration-break:clone;box-decoration-break:clone}
.invite{margin-top:var(--s5)}
.invite h2{font-size:var(--t4);font-weight:var(--w3);color:var(--fg);margin-bottom:var(--s3)}
.invite p{color:var(--dim);margin-bottom:var(--s4);max-width:var(--measure)}
.invite svg{display:block;width:min(260px,100%);height:auto;background:#fff;
border:1px solid var(--line);border-radius:var(--r1)}
.wizard{position:fixed;inset:0;z-index:20;background:var(--bg);display:grid;place-items:center;
padding:var(--s5);overflow:auto}
.wcard{width:min(680px,100%);background:var(--sheet);border:1px solid var(--line);
border-radius:var(--r2);padding:var(--s6);box-shadow:var(--shadow)}
.wcard h1{font-size:var(--t5);margin-bottom:var(--s4)}
.wcard p{color:var(--dim);margin:0 0 var(--s4);max-width:var(--measure)}
.winput{width:100%;padding:var(--s3);border:1px solid var(--line2);border-radius:var(--r1);
background:var(--bg);margin:0 0 var(--s3)}
.wwords{font-family:var(--mono);font-size:var(--t3);line-height:1.9;white-space:pre-wrap;
background:var(--sunk);border-radius:var(--r1);padding:var(--s4);margin:var(--s3) 0}
.werror{color:var(--bad);margin:var(--s3) 0}
.wcheck{display:flex;gap:var(--s2);align-items:flex-start;color:var(--dim);margin:var(--s4) 0}
.wcheck input{margin-top:var(--s1)}
.wide{width:100%}
.gap{margin-top:var(--s3)}
.note{min-height:var(--s7)}
.bwarn{color:var(--warn)}
</style>
<div class="app" id="app">
<header class="topbar">
<span class="logo"><svg viewBox="0 0 512 512" aria-hidden="true">
<g fill="none" stroke="currentColor" stroke-linecap="round">
<path d="M 340.85 374.85 A 120 120 0 1 0 171.15 374.85" stroke-width="46"/>
<path d="M 256 290 L 310.5 212.2" stroke-width="40"/>
</g><circle cx="256" cy="290" r="23" fill="currentColor"/></svg></span>
<span class="wordmark" id="wordmark">gage</span>
<nav class="tabs" id="tabs"></nav>
<span class="whoami" id="me"></span>
</header>
<div class="banners"><div class="stale" id="stale" hidden></div><div class="connbar" id="connbar" hidden></div></div>
<div class="left">
<div class="filters" id="filters"></div>
<div class="list" id="list"></div>
<div class="side" id="budside" hidden></div>
<div class="lanbox" id="lanbox"></div>
<div class="lanbox netbox" id="netbox"></div>
</div>
<div class="right">
<div class="head">
<button class="back" onclick="back()" title="Back">←</button>
<h1 id="title">Conversations</h1><span class="sp"></span>
<span id="hstate"></span>
<button class="headbtn" id="settle" hidden onclick="act('close',SEL)">Mark settled</button>
</div>
<div class="msgs" id="msgs"></div>
<div class="composer" id="composer" hidden></div>
<div class="msgs" id="budpane" hidden></div>
<div class="msgs" id="setuppane" hidden></div>
</div>
</div>
<div class="wizard" id="wizard" hidden aria-live="polite"></div>
<script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const kb=n=>n<1024?n+' B':n<1048576?(n/1024).toFixed(1)+' KB':(n/1048576).toFixed(1)+' MB';
const ago=iso=>{const d=(Date.now()-Date.parse(iso))/1000;
if(!isFinite(d))return'';
if(d<60)return'now'; if(d<3600)return Math.floor(d/60)+'m';
if(d<86400)return Math.floor(d/3600)+'h'; return Math.floor(d/86400)+'d';};
const V=[['SENDER VERIFIED','Verified','v-ok'],
['SENDER ON YOUR LIST','Unconfirmed','v-warn'],
['SENDER NOT','Not on your list','v-warn'],
['SENDER REFUSED','Refused by you','v-warn'],
['SENDER KEY RETIRED','Retired key','v-warn'],
['SENDER KEY REJECTED','Key rejected','v-bad'],
['ARRIVAL UNRECORDED','Arrival unrecorded','v-bad'],
['SENDER UNVERIFIABLE','Unverifiable','v-bad'],
['VERDICT UNAVAILABLE','Verdict unavailable','v-bad']];
const CONFE={};
const vRow=t=>V.find(r=>(t||'').startsWith(r[0]));
const vClass=t=>{const r=vRow(t);return r?r[2]:'v-bad';};
const vShort=t=>{const r=vRow(t);return r?r[1]:(t||'No verdict');};
const sClass=s=>s.includes('YOU')?'s-you':s.includes('them')?'s-them':s==='closed'?'s-closed':'s-settled';
const sShort=s=>s.includes('YOU')?'needs you':s;
function keyHue(k){let h=0;for(const c of String(k||'')) h=(h*31+c.charCodeAt(0))%360;return h;}
function avatar(m){
const label=(m.known_as||m.claimed_name||'?').trim()[0]||'?';
if(m.mine) return `<span class="avatar" style="background:var(--accent);color:var(--accent-ink)">${esc(label.toUpperCase())}</span>`;
const h=keyHue(m.key||m.known_as||m.claimed_name);
return `<span class="avatar" style="background:hsl(${h} 42% 88%);color:hsl(${h} 55% 28%)">${esc(label.toUpperCase())}</span>`;
}
const nameOf=m=>m.mine?'you':(m.known_as?m.known_as:`"${m.claimed_name}"`);
let DATA={threads:[],contacts:[]}, LERR='', SEL=null, FILTER='all', DRAFT={}, CERR='', SENDING=false;
let PASTED='', PNAME='', PKEY='', PPAIR=0;
function alerts(m){
const out=[];
if(m.tampered&&m.tampered.length) out.push(['bad',
`Payload does not match the signed manifest (${esc(m.tampered.join(', '))}). Contents withheld.`]);
if(!m.mine && m.known_as && m.known_as!==m.claimed_name) out.push(['bad',
`Name mismatch. This key is <b>${esc(m.known_as)}</b> on your list, but the bundle claims "${esc(m.claimed_name)}". The claimed name is text the sender chose.`]);
if(!m.mine && !m.known_as) out.push(['',
`This key is not on your list. It claims the name "${esc(m.claimed_name)}", which is unverified.`]);
if(m.thread_ok===false) out.push(['bad',
`This message <b>claims</b> to belong to this conversation while answering something you do not hold. The thread field is written by the sender. Treat it as a separate message, and reply to the person you meant.`]);
if(m.forwarded) out.push(['',`Forwarded, ${m.hops} hop(s). The sender is not the author, and any hop could have altered it before signing.`]);
if(!m.mine) {
if(m.freshness==='expired') out.push(['bad',
`<b>Expired</b> ${esc(m.expires||'')}. The sender declared this no longer current, inside the signed manifest. <code>gage open</code> withholds the payload. It is still here: an expiry is their statement about their own message, not a decision to refuse the arrival.`]);
else if(m.freshness==='unreadable') out.push(['bad',
`<b>Expiry unreadable</b> (${esc(m.expires||'')}) — not an RFC 3339 timestamp, so it is treated as lapsed rather than as absent and the payload is withheld.`]);
else if(m.freshness==='current'&&m.expires) out.push(['',
`Expires ${esc(m.expires)}. After that the payload is withheld.`]);
else if(m.freshness&&m.freshness!=='undeclared'&&m.freshness!=='current') out.push(['bad',
`Unrecognised freshness "${esc(m.freshness)}". This viewer does not know what it means, so treat the bundle as not current.`]);
if(m.context_match==='mismatch') out.push(['bad',
`<b>Context mismatch.</b> Built for "${esc(m.context||'')}", and this machine says it is in a different run. That is what a replayed handoff looks like — a good signature carrying work from a run that is over. Compare the two labels before acting on it.`]);
else if(m.context_match==='match') out.push(['',
`Context matches the run this machine says it is in ("${esc(m.context||'')}"). It rules out a replay from another run and authorises nothing: a label is not secret.`]);
else if(m.context_match==='no-local-context') out.push(['',
`Built for "${esc(m.context||'')}" — the sender's label for the run. Nothing here says what run this machine is in, so no comparison was made. Compare it yourself.`]);
else if(m.context_match==='undeclared') out.push(['',
`No run declared — this bundle names no run, so nothing was compared. Not a mismatch, and not a confirmation that this is the handoff you were waiting for.`]);
else if(m.context_match) out.push(['bad',
`Unrecognised context_match "${esc(m.context_match)}"${m.context?` on a bundle built for "${esc(m.context)}"`:''}. This viewer does not know what it means, so compare the run labels yourself before acting on it.`]);
}
if(m.memories) out.push(['bad',
`${m.memories} memory file(s). Memory outlives the session that accepts it, so it is never merged automatically.`]);
if(m.prompts) out.push(['',
`${m.prompts} prompt(s): text written to steer a model. Read it with <code>gage open</code>, which frames it as data.`]);
return out.map(([c,h])=>`<div class="alert ${c}">${h}</div>`).join('');
}
function msgHtml(m){
const files=(m.items||[]).map(i=>`<div class="f">
<span class="tag t-${esc(i.kind)}">${esc(i.kind)}</span>
<span class="fp">${esc(i.path)}</span><span class="fb">${kb(i.bytes)}</span></div>`).join('');
const acts = m.stage==='pending'
? `<div class="acts">
<button class="btn go" onclick="act('accept','${esc(m.id)}')">Accept</button>
<button class="btn no" onclick="act('reject','${esc(m.id)}')">Reject unread</button>
<span class="cmd">arriving is not accepting</span></div>`
: m.stage==='accepted'
? `<div class="acts">
${m.read?'':`<button class="btn" onclick="act('read','${esc(m.id)}')">Mark read</button>`}
<span class="cmd">gage open ${esc(m.path)}</span></div>`
: `<div class="acts"><span class="cmd">sent · ${esc(m.id)}</span></div>`;
return `<div class="m ${m.mine?'mine':''}">
<div class="mhead">
${m.read?'':'<span class="dot"></span>'}
<span class="who">${esc(nameOf(m))}</span>
<span class="ty ${esc(m.type)}">${esc(m.type)}</span>
<span class="at">${esc(m.created)}</span>
</div>
<div class="sheet">
${m.mine?'':`<div class="verdict ${vClass(m.trust)}"><span class="vdot"></span>${vShort(m.trust)}</div>`}
${alerts(m)}
${files?`<div class="files">${files}</div>`:''}
${acts}
</div></div>`;
}
const TYPES=['answer','share','question','review-request','ack'];
function composerHtml(t){
const r=t.reply||{};
if(!r.can){
return `<div class="cwrap"><div class="cblocked">
Cannot reply from here: ${esc(r.why||'no recipient')}.
${r.to?`<br><code>gage reply ${esc(t.id)} --text "..."</code>`:''}
</div></div>`;
}
const d=DRAFT[t.id]||{text:'',type:r.type||'answer'};
return `<div class="cwrap">
${CERR?`<div class="cerr">${esc(CERR)}</div>`:''}
<div class="cto">Reply to <b>${esc(r.to)}</b> as
<select id="ctype" onchange="draft()">${TYPES.map(x=>
`<option value="${x}"${x===d.type?' selected':''}>${x}</option>`).join('')}</select>
</div>
<textarea id="ctext" placeholder="Write a reply. It is scanned for credentials on the way out, like any file."
oninput="draft()" onkeydown="ckey(event)">${esc(d.text)}</textarea>
<div class="crow">
<span class="chint">Delivered to ${esc(r.to)}'s inbox on this machine. ${SENDING?'Sending...':'⌘/Ctrl + Enter to send.'}</span>
<button class="btn go" onclick="send()" ${SENDING?'disabled':''}>Send reply</button>
</div></div>`;
}
function render(){
const me=DATA.me;
const st=document.getElementById('stale');
if(LERR){ st.hidden=false; st.textContent=LERR; }
else st.hidden=!DATA.verdicts_stale;
if(!LERR && DATA.verdicts_stale) st.textContent=
'Verdicts withheld: your inbox could not be recorded, so a verdict here could read '+
'milder than the truth. Usually another gage process is writing \u2014 it clears on '+
'the next poll. Until then, read bundles with gage open in a terminal.';
document.getElementById('me').innerHTML = me
? `<div class="h">${esc(me.handle)}</div><div class="k">${esc((me.key||'').slice(0,10))}…</div>`
: `<div class="h">no identity</div><div class="k">run gage init</div>`;
const th=DATA.threads||[];
const needYou=th.filter(t=>t.state.includes('YOU')).length;
const unread=th.reduce((a,t)=>a+t.unread,0);
document.getElementById('filters').innerHTML=
[['all','All',th.length],['you','Needs you',needYou],['unread','Unread',unread]]
.map(([k,l,n])=>`<button class="chip ${FILTER===k?'on':''}" onclick="setF('${k}')">${l}<span class="n">${n}</span></button>`).join('');
let shown=th;
if(FILTER==='you') shown=th.filter(t=>t.state.includes('YOU'));
if(FILTER==='unread') shown=th.filter(t=>t.unread>0);
document.getElementById('list').innerHTML = shown.length ? shown.map(t=>{
const last=t.messages[t.messages.length-1]||{};
const other=[...t.messages].reverse().find(m=>!m.mine)||last;
const who=[...new Set(t.messages.map(nameOf))].join(', ');
return `<button class="t ${SEL===t.id?'sel':''}" onclick="sel('${esc(t.id)}')">
${avatar(other)}
<span class="tbody">
<span class="trow"><span class="subj">${esc(t.subject)}</span>
<span class="when">${esc(ago(last.created))}</span></span>
<span class="tmeta">
${t.unread?'<span class="pip"></span>':''}
${t.correspondents>1?'<span class="state s-you">'+t.correspondents+' senders</span>':''}
<span class="state ${sClass(t.state)}">${esc(sShort(t.state))}</span>
<span class="peers">${esc(who)}</span></span>
</span>
</button>`;}).join('')
: `<div class="empty">No bundles yet. Pair on this network, then ask your buddy to send you one.</div>`;
if(TAB!=='chats') return;
const t=th.find(x=>x.id===SEL);
document.getElementById('title').textContent = t ? t.subject : 'Conversations';
document.getElementById('hstate').innerHTML = t
? `<span class="state ${sClass(t.state)}">${esc(sShort(t.state))}</span>` : '';
document.getElementById('settle').hidden = !t || t.state==='closed';
const comp=document.getElementById('composer');
comp.hidden = !t;
document.getElementById('msgs').innerHTML = t
? t.messages.map(msgHtml).join('')
: `<div class="hint">
<h2>Pick a conversation</h2>
<p>Threads group by the message that started them. What is read, and what
counts as settled, is yours: a sender cannot mark their own message
read in your inbox.</p>
<p><code>gage send --to codex --type question --decision q.md</code><br>
<code>gage reply <id> --text "..."</code></p>
<p>Replies written here are delivered to a local route. Files you drop
on a machine in <b>On this network</b> go straight to it, over the
wifi, and land unread in its inbox. Sending through a <i>relay</i> is
still <code>gage push</code> in a terminal, where you see the list
first.</p>
</div>`;
if(t){
const prev=document.activeElement&&document.activeElement.id;
const caret=prev==='ctext'?document.activeElement.selectionStart:null;
comp.innerHTML=composerHtml(t);
if(prev==='ctext'){
const el=document.getElementById('ctext');
if(el){el.focus();if(caret!=null)el.setSelectionRange(caret,caret);}
}
}
}
function setF(f){FILTER=f;render();}
function sel(id){SEL=id;CERR='';document.getElementById('app').classList.add('detail');render();
const m=document.getElementById('msgs');if(m)m.scrollTop=m.scrollHeight;}
function back(){document.getElementById('app').classList.remove('detail');SEL=null;render();}
function draft(){
const te=document.getElementById('ctext'), ty=document.getElementById('ctype');
if(SEL) DRAFT[SEL]={text:te?te.value:'',type:ty?ty.value:'answer'};
}
function ckey(e){ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();send();} }
const post=(path,body)=>fetch(path,{method:'POST',headers:
Object.assign({'X-Gage':'1'},body?{'Content-Type':'application/json'}:{}),
body:body?JSON.stringify(body):undefined});
async function act(what,id){
if(!id)return;
const r=await post(`/api/${what}/${id}`);
if(!r.ok){const j=await r.json().catch(()=>({}));CERR=j.error||'that did not work';}
await load();
}
async function send(){
draft();
const d=DRAFT[SEL]; if(!d||!d.text.trim()||SENDING) return;
SENDING=true;CERR='';render();
try{
const r=await post('/api/reply',{thread:SEL,text:d.text,type:d.type});
const j=await r.json().catch(()=>({}));
if(r.ok){ DRAFT[SEL]={text:'',type:d.type}; }
else { CERR=j.error||'the reply was not sent'; }
}catch(e){ CERR=String(e); }
SENDING=false;
await load();
}
async function load(){
try{
const r=await fetch('/api/threads');
if(!r.ok)throw new Error('HTTP '+r.status);
DATA=await r.json();
LERR='';
}catch(e){
LERR='Could not load your inbox from this server: '+(e&&e.message?e.message:e)
+'. The server is at '+location.origin+'; gage ui logs to the terminal it runs in.';
render();
return;
}
if(SEL&&!DATA.threads.some(t=>t.id===SEL))SEL=null;
render();
}
let LAN={lan_running:false,devices:[]}, LSEL=null, FILES=[], LERR2='', LOK='', LBUSY=false;
let RELAY=null, RADDR='', RBUSY=false, RERR='', ROK='', RSHOW=false;
let LPAIR=null, LNAME='';
const PAIRHINT='Check all four pairs against what the other machine shows. They come '
+'from its key, so nobody picks their own. If one word differs, it is not that key.';
function netRender(){
const box=document.getElementById('netbox');
if(!box)return;
const on=!!RELAY;
let h=`<div class="lanhead"><span class="dot${on?' on':''}"></span>Over the internet</div>`;
if(on){
h+=`<div class="lanrow"><span class="lanempty">${esc(RELAY)}</span>
<button class="devbtn" onclick="relayOff()" ${RBUSY?'disabled':''}>
${RBUSY?'Working...':'Turn off'}</button></div>`;
}else if(!RSHOW){
h+=`<div class="lanrow"><span class="lanempty">Off</span>
<button class="devbtn" onclick="RSHOW=true;netRender()">Turn on</button></div>`;
}else{
h+=`<div class="lansub">The address of the relay to use. It has to be an
<b>https</b> address: this crosses networks nobody here controls, and
without that there is nothing to say the relay is the relay.</div>
<input class="lanin wide" aria-label="Relay address"
value="${esc(RADDR)}" oninput="RADDR=this.value"
onkeydown="if(event.key==='Enter')relayOn()">
<div class="devrow">
<button class="devbtn" onclick="relayOn()" ${RBUSY?'disabled':''}>
${RBUSY?'Registering...':'Register'}</button>
<button class="devbtn" onclick="RSHOW=false;RERR='';netRender()">Cancel</button>
</div>`;
}
if(RERR)h+=`<div class="lanerr">${esc(RERR)}</div>`;
if(ROK)h+=`<div class="lanok">${esc(ROK)}</div>`;
box.innerHTML=h;
}
async function relayPost(body){
RBUSY=true;RERR='';ROK='';netRender();
try{
const r=await fetch('/api/relay',{method:'POST',
headers:{'X-Gage':'1','Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
ROK=(j.lines||[]).join(' ');
RSHOW=false;
}catch(e){ RERR=(e&&e.message)?e.message:String(e); }
RBUSY=false; await lanLoad();
}
function relayOn(){ return relayPost({addr:RADDR}); }
function relayOff(){ return relayPost({off:true}); }
function lanRender(){
const box=document.getElementById('lanbox');
if(!box)return;
const on=LAN.lan_running;
let h=`<div class="lanhead"><span class="dot${on?' on':''}"></span>On this network</div>`;
if(!LAN.devices.length){
h+=`<div class="lanempty">${on
? 'Nobody else here yet. Run <b>gage lan</b> on the other machine too.'
: 'Off. Run <b>gage lan</b> in a terminal.'}</div>`;
box.innerHTML=h; return;
}
for(const d of LAN.devices){
const sel=LSEL===d.label;
h+=`<div class="dev${sel?' sel':''}">
<div class="devtop">
<span class="devname">${esc(d.paired_as||d.label)}</span>
${d.paired_as
? `<span class="devtag ok">paired</span>`
: `<span class="devtag">not paired</span>`}
</div>
<div class="devfp">${esc(d.fingerprint)}</div>
<div class="devrow">
<button class="devbtn" onclick="lanPick('${esc(d.label)}')">${sel?'Chosen':'Send to this'}</button>
${d.paired_as?'':`<button class="devbtn" onclick="lanPair('${esc(d.label)}')">${
LPAIR===d.label?'Cancel':'Pair'}</button>`}
</div>
${LPAIR===d.label?`
<div class="lansub">${esc(PAIRHINT)}</div>
<input class="lanin" placeholder="Call it what on your list?" value="${esc(LNAME)}"
oninput="LNAME=this.value">
<div class="devrow">
<button class="devbtn go" onclick="lanPairGo('${esc(d.label)}')" ${LBUSY?'disabled':''}>
${LBUSY?'Pairing...':'Those four pairs match'}</button>
</div>`:''}
</div>`;
}
const names=FILES.map(f=>f.name).join(', ');
h+=`<div class="drop${FILES.length?' has':''}" id="drop"
onclick="document.getElementById('fpick').click()">
${FILES.length?esc(names)+` · ${kb(FILES.reduce((a,f)=>a+f.size,0))}`
:'Drop files here, or click to choose'}
</div>
<input type="file" id="fpick" multiple hidden onchange="lanFiles(this.files)">`;
if(LSEL){
h+=`<input class="lanin" id="lsubj" placeholder="Subject" value="${esc(LSUBJ)}"
oninput="LSUBJ=this.value">
<textarea class="lanin note" id="lnote"
placeholder="Write a note (optional)" oninput="LNOTE=this.value">${esc(LNOTE)}</textarea>`;
if(FILES.length||LNOTE.trim()){
h+=`<div class="devrow">
<button class="devbtn" onclick="lanSend()" ${LBUSY?'disabled':''}>
${LBUSY?'Sending...':'Send to '+esc((LAN.devices.find(x=>x.label===LSEL)||{}).paired_as||LSEL)}</button>
<button class="devbtn" onclick="FILES=[];LNOTE='';lanRender()">Clear</button>
</div>`;
} else {
h+=`<div class="lansub">Drop a file or write a note.</div>`;
}
} else if(FILES.length||LNOTE.trim()){
h+=`<div class="lansub">Choose a machine above.</div>`;
}
if(LERR2)h+=`<div class="lanerr">${esc(LERR2)}</div>`;
if(LOK)h+=`<div class="lanok">${esc(LOK)}</div>`;
box.innerHTML=h;
const dz=document.getElementById('drop');
if(dz){
dz.ondragover=e=>{e.preventDefault();dz.classList.add('over')};
dz.ondragleave=()=>dz.classList.remove('over');
dz.ondrop=e=>{e.preventDefault();dz.classList.remove('over');lanFiles(e.dataTransfer.files)};
}
}
let LSUBJ='', LNOTE='';
function lanPick(l){ LSEL=(LSEL===l?null:l); LERR2='';LOK=''; lanRender(); }
async function lanFiles(fl){
LERR2='';LOK='';
const out=[];
let total=0;
for(const f of fl){
total+=f.size;
if(total*1.37>11*1024*1024){
LERR2='That is too much to send through the page ('+kb(total)
+'). Use gage send --dir in the terminal for anything large.';
lanRender(); return;
}
out.push({name:f.name,size:f.size,data:await b64(f)});
}
FILES=out; lanRender();
}
const b64=f=>new Promise((res,rej)=>{
const r=new FileReader();
r.onerror=()=>rej(new Error('could not read '+f.name));
r.onload=()=>res(String(r.result).split(',',2)[1]||'');
r.readAsDataURL(f);
});
async function lanSend(){
if(!LSEL||(!FILES.length&&!LNOTE.trim())||LBUSY)return;
LBUSY=true;LERR2='';LOK='';lanRender();
try{
const r=await post('/api/send',{to:LSEL,subject:LSUBJ,type:'share',text:LNOTE,
files:FILES.map(f=>({name:f.name,data:f.data}))});
const j=await r.json().catch(()=>({}));
if(r.ok){ LOK=(j.lines||[]).join('\n'); FILES=[]; LSUBJ=''; LNOTE=''; }
else { LERR2=j.error||'that did not send'; }
}catch(e){ LERR2=String(e); }
LBUSY=false; lanRender();
}
function lanPair(label){ LPAIR=(LPAIR===label?null:label); LNAME=''; LERR2='';LOK=''; lanRender(); }
async function lanPairGo(label){
const d=LAN.devices.find(x=>x.label===label);
if(!d||LBUSY)return;
if(!LNAME.trim()){ LERR2='Give it a name to file it under.'; lanRender(); return; }
LBUSY=true;LERR2='';LOK='';lanRender();
try{
const r=await post('/api/pair',{label:label,name:LNAME.trim(),fingerprint:d.fingerprint});
const j=await r.json().catch(()=>({}));
if(r.ok){ LOK=(j.lines||[]).join('\n'); LPAIR=null; LNAME=''; }
else { LERR2=j.error||'that did not pair'; }
}catch(e){ LERR2=String(e); }
LBUSY=false;
await lanLoad();
}
async function lanLoad(){
try{
const r=await fetch('/api/nearby');
if(!r.ok)throw new Error('HTTP '+r.status);
LAN=await r.json();
if(LSEL&&!LAN.devices.some(d=>d.label===LSEL))LSEL=null;
const sr=await fetch('/api/state');
if(sr.ok){const st=await sr.json();ST=st;RELAY=st.relay||null;
if(!RADDR)RADDR=st.relay_default||'';}
}catch(e){
LERR2='Could not read the network list: '+(e&&e.message?e.message:e);
}
lanRender();
netRender();
if(TAB==='setup')setupRender();
}
lanLoad();setInterval(lanLoad,2000);
let BUD={buddies:[],requests:[],refused:[]}, TAB='chats';
let ST=null;
let AGENTS=null, AGNAME='', AGBUSY=false, AGERR='', AGHOST='claude_code', AGNEW=null;
let AGKILL='';
let ASK=null, ANAME='', ACONF='', AERR='', AOK='', ABUSY=false, SHOWREF=false;
let BWHY=false;
let REVEAL=null;
const ASKWHY='The word-pairs come from the key itself, so nobody picks their own. '
+'This screen does not show them yet, on purpose: read the pair below off the '
+'OTHER machine, where they must run gage fingerprint --signing-only. Get it '
+'right and the rest are shown, so you can check all four before adding.';
const ASKHALF='These words cover the SIGNING key only. A message does not carry its '
+'sender\u2019s encryption key, so adding them here lets you verify what they '
+'sign and not send them sealed mail. For both, get their `gage add` line and '
+'compare the normal four pairs.';
const ASKTRAP='If this pair came from the message itself, or from anything this '
+'key sent you, it proves nothing -- a key knows its own word-pairs and can '
+'quote them. It only means something if it came from the other machine.';
function tabsRender(){
const el=document.getElementById('tabs');
if(!el)return;
const n=BUD.requests.length;
const open=setupOpenCount();
el.innerHTML=
`<button class="tab${TAB==='chats'?' on':''}" onclick="setTab('chats')">Chats</button>`
+`<button class="tab${TAB==='buddies'?' on':''}" onclick="setTab('buddies')">Buddies`
+(n?`<span class="bdg">${n}</span>`:'')+`</button>`
+`<button class="tab${TAB==='agents'?' on':''}" onclick="setTab('agents')">Agents</button>`
+`<button class="tab${TAB==='setup'?' on':''}" onclick="setTab('setup')">Setup`
+(open?`<span class="bdg">${open}</span>`:'')+`</button>`;
}
let INVITE=null, inviteSeq=0;
function inviteOk(t){
if(typeof t!=='string'||t.length<20||t.length>200000)return false;
if(!t.startsWith('<svg ')||!t.endsWith('</svg>'))return false;
if((t.match(/<svg/g)||[]).length!==1)return false;
if(/<\s*(script|img|object|embed|iframe|link|foreignObject)\b|<!DOCTYPE|<\?xml/i.test(t))return false;
return true;
}
async function inviteLoad(force){
if(!force&&INVITE!==null)return;
const seq=++inviteSeq;
let next='';
try{
const r=await fetch('/api/invite');
if(r.ok){const t=await r.text();if(inviteOk(t))next=t;}
}catch(e){}
if(seq===inviteSeq)INVITE=next;
}
function inviteHtml(){
if(!INVITE)return '';
return `<div class="invite"><h2>The line the other person runs</h2>
<p>This is the public <code>gage add</code> line <code>gage connect</code> prints.
It names this machine and its public key, and nothing else.</p>
${INVITE}</div>`;
}
function budpaneHtml(){
return inviteHtml()+pasteHtml()+`<details class="disc"${BWHY?' open':''}
ontoggle="BWHY=this.open">
<summary>Why this matters</summary>
<div class="hint">
<h2>Who can reach you</h2>
<p>Your list is the trust boundary. A key on it is one you confirmed out of
band; a key not on it is a decision you have not taken yet. Nothing here is
automatic, and nothing a sender writes puts it on the list.</p>
<p>Approving one asks for a word-pair you have to read off the other machine.
That is not a formality: the pairs come from the key, so a key can quote its
own in a message to you, and only the other machine's screen is evidence.</p>
<p>Refusing records that you said no. It is not a block — nothing in gage
can stop a bundle being delivered, and mail from a refused key still arrives
and still waits for you to accept it.</p>
<p><code>gage decline <key></code> and <code>gage undecline <key></code>
do the same from a terminal.</p></div></details>`;
}
function pasteHtml(){
let h=`<div class="hint"><h2>Paste an invite</h2>
<p>Paste the other person's complete <code>gage add</code> line. The clipboard is read only when you press the button.</p>
<button class="devbtn" onclick="pasteClipboard()">Read clipboard</button>
<textarea class="lanin wide gap" aria-label="Invite text"
placeholder="Paste or type the complete gage add line" oninput="PASTED=this.value">${esc(PASTED)}</textarea>
<div class="devrow"><button class="devbtn go" onclick="pasteInvite()">Use this invite</button></div>
${AERR?`<div class="berr">${esc(AERR)}</div>`:''}${AOK?`<div class="bok">${esc(AOK)}</div>`:''}</div>`;
if(PKEY)h+=`<div class="bwhy">Invite parsed. The existing request card now has the suggested name and pairing check.</div>`;
return h;
}
async function pasteClipboard(){
try{if(navigator.clipboard&&navigator.clipboard.readText){const t=await navigator.clipboard.readText();if(t){PASTED=t;budRender(true);}}}catch(e){}
}
async function pasteInvite(){
AERR='';AOK='';PKEY='';
try{
const r=await fetch('/api/parse-invite',{method:'POST',headers:{'X-Gage':'1','Content-Type':'application/json'},body:JSON.stringify({text:PASTED})});
const j=await r.json().catch(()=>({}));if(!r.ok)throw Error(j.error||('HTTP '+r.status));
PKEY=j.key;PNAME=j.suggested_name||'';PPAIR=j.ask_pair;ASK=PKEY;ANAME=PNAME;ACONF='';REVEAL=null;
}catch(e){AERR=e&&e.message?e.message:String(e)}
if(TAB==='buddies')budRender(true);
}
function setTab(t){
TAB=t;
hashSync();
const wide = (t==='buddies'||t==='agents');
const solo = (t==='setup');
for(const [id,show] of [['filters',t==='chats'],['list',t==='chats'],
['lanbox',t==='buddies'],['netbox',t==='buddies'],['budside',wide],
['msgs',t==='chats'],['budpane',wide],['setuppane',solo]]){
const el=document.getElementById(id); if(el)el.hidden=!show;
}
const appEl=document.getElementById('app');
if(appEl)appEl.classList.toggle('buds',wide);
if(appEl)appEl.classList.toggle('solo',solo);
if(appEl)appEl.classList.toggle('detail', t==='chats' && !!SEL);
if(t==='agents'){
document.getElementById('title').textContent='Agents';
const c=document.getElementById('composer'); if(c)c.hidden=true;
const se=document.getElementById('settle'); if(se)se.hidden=true;
document.getElementById('hstate').textContent='';
agentRender();
if(AGENTS===null)agentLoad();
}else if(t==='buddies'){
document.getElementById('title').textContent='Buddies';
const c=document.getElementById('composer'); if(c)c.hidden=true;
const se=document.getElementById('settle'); if(se)se.hidden=true;
document.getElementById('hstate').textContent='';
budRender(true);
if(INVITE===null)inviteLoad(false).then(()=>{if(TAB==='buddies'){
budRender(true);
}});
}else if(t==='setup'){
document.getElementById('title').textContent='Setup';
const c=document.getElementById('composer'); if(c)c.hidden=true;
const se=document.getElementById('settle'); if(se)se.hidden=true;
document.getElementById('hstate').textContent='';
if(AGENTS===null)agentLoad().then(()=>{if(TAB==='setup'){setupRender();tabsRender()}});
setupRender();
}else{
document.getElementById('title').textContent='Conversations';
render();
}
tabsRender(); budRender(true);
}
function hashSync(){
let h='';
if(TAB==='buddies')h = ASK ? '#buddies='+encodeURIComponent(ASK) : '#buddies';
if(TAB==='agents')h = '#agents';
if(TAB==='setup')h = '#setup';
try{ history.replaceState(null,'',h||location.pathname); }catch(e){}
}
function hashRead(){
const h=decodeURIComponent(location.hash||'');
if(h==='#agents'){ TAB='agents'; return; }
if(h==='#setup'){ TAB='setup'; return; }
if(!h.startsWith('#buddies'))return;
TAB='buddies';
const i=h.indexOf('=');
if(i>0)ASK=h.slice(i+1);
}
function askOpen(k){
ASK=(ASK===k?null:k); ANAME='';ACONF='';AERR='';AOK='';
if(!ASK||(REVEAL&&REVEAL.key!==ASK))REVEAL=null;
hashSync(); budRender(true);
}
async function budReveal(k){
ABUSY=true;AERR='';AOK='';budRender(true);
try{
const r=await fetch('/api/connect',{method:'POST',
headers:{'X-Gage':'1','Content-Type':'application/json'},
body:JSON.stringify({key:k,name:'',confirm:ACONF})});
const j=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
REVEAL={key:k,fp:(j.lines||[])[1]||'',pair:ACONF};
ACONF='';
}catch(e){ AERR=(e&&e.message)?e.message:String(e); }
ABUSY=false; budRender(true);
}
function reachRow(r){
const bits=[
['routed under this name', r.routed_by_name,
'A local route is filed under this name, which is the name sending resolves too. '
+'It is your label, not their identity.'],
['on this network now', r.lan_now,
'This key is announcing itself on this network, which it chose to do by running '
+'gage lan. Nothing here polls anybody.'],
['mail can be left', r.can_leave_mail,
'You are joined to a relay and hold an encryption key for them, so mail CAN be '
+'left. This is about your setup, not about them -- whether they ever collect it '
+'is not something this machine can know.'],
];
return `<div class="bdots">`+bits.map(([t,on,title])=>
`<span class="breach${on?' on':''}" title="${esc(title)}"><i></i>${t}</span>`).join('')+`</div>`;
}
const AGHOSTS=[['claude_code','Claude Code'],['claude_desktop','Claude Desktop'],
['codex','Codex'],['generic','Other host']];
function agentRender(){
const box=document.getElementById('budpane');
const side=document.getElementById('budside');
if(!box||!side||TAB!=='agents')return;
const a=document.activeElement;
if(a&&(box.contains(a)||side.contains(a))
&&(a.tagName==='INPUT'||a.tagName==='TEXTAREA')&&!AGNEW)return;
side.innerHTML=`<div class="bsec">Connect an agent</div>
<div class="lansub budnote">Each agent gets its own identity, so a verdict
can say <i>which</i> agent sent something and a grant can name one of
them. They are added to your list here without a word-pair compare:
this page generated the key a moment ago, so there is no other party
it could belong to. A key from anywhere else still goes through the
compare on the Buddies tab.</div>
<div class="devrow gap">
<input class="lanin wide" placeholder="Name, e.g. codex"
value="${esc(AGNAME)}" oninput="AGNAME=this.value"
onkeydown="if(event.key==='Enter')agentCreate()">
<button class="devbtn go" onclick="agentCreate()" ${AGBUSY?'disabled':''}>
${AGBUSY?'Creating...':'Create agent'}</button>
</div>
<div class="bsec">Host to configure</div>
<div class="hostpick">`+AGHOSTS.map(([k,label])=>
`<button class="chip${AGHOST===k?' on':''}" onclick="AGHOST='${k}';agentRender()">${label}</button>`
).join('')+`</div>`;
let h=`<div class="budwrap">`;
if(AGERR)h+=`<div class="lanerr">${esc(AGERR)}</div>`;
const list=AGENTS||[];
h+=`<div class="bsec">Agents on this machine<span class="bnum">${list.length}</span></div>`;
if(!list.length){
h+=`<div class="lansub budnote">None yet.</div>`;
}
h+=`<div class="budgrid aggrid">`;
for(const g of list){
const cfg=(g.configs&&g.configs[AGHOST])||'';
h+=`<div class="dev">
<div class="devtop"><span class="devname">${esc(g.name)}</span>
<span class="devtag">${esc(g.handle)}</span></div>
<div class="devfp">${esc(g.fingerprint)}</div>
<div class="lansub">Identity in <code>${esc(g.home)}</code></div>
<div class="lansub gap">Point the host at it:</div>
<pre class="cfg" id="cfg-${esc(g.name)}">${esc(cfg)}</pre>
<div class="devrow">
<button class="devbtn" onclick="copyCfg('${esc(g.name)}')">Copy config</button>
<span class="copied" id="copy-cfg-${esc(g.name)}"></span>
</div>
<div class="lansub gap">To let its messages be accepted
without you, put this line in your <code>authority</code> file yourself.
Nothing in gage writes that file: a path that grants authority by being
asked is one an agent can be talked into asking for.</div>
<pre class="cfg" id="grant-${esc(g.name)}">${esc(g.grant)}</pre>
<div class="devrow">
<button class="devbtn" onclick="copyGrant('${esc(g.name)}')">Copy line</button>
<span class="copied" id="copy-grant-${esc(g.name)}"></span>
</div>
<div class="devrow agkill">${ AGKILL===g.name
? `<button class="devbtn danger" onclick="agentRemove('${esc(g.name)}')"
${AGBUSY?'disabled':''}>${AGBUSY?'Removing...':'Yes, remove '+esc(g.name)}</button>
<button class="devbtn" onclick="AGKILL='';agentRender()">Cancel</button>
<span class="lansub">Deletes its identity key. That cannot be undone.
Anything it already sent stays readable and keeps its verdict.</span>`
: `<button class="devbtn" onclick="AGKILL='${esc(g.name)}';agentRender()">Remove</button>`
}</div>
</div>`;
}
h+=`</div></div>`;
box.innerHTML=h;
}
function copyCfg(name){
const g=(AGENTS||[]).find(x=>x.name===name); if(!g)return;
doCopy((g.configs&&g.configs[AGHOST])||'', 'copy-cfg-'+name, 'cfg-'+name);
}
function copyGrant(name){
const g=(AGENTS||[]).find(x=>x.name===name); if(!g)return;
doCopy(g.grant||'', 'copy-grant-'+name, 'grant-'+name);
}
async function doCopy(text, tagId, blockId){
const tag=document.getElementById(tagId);
const say=(m,bad)=>{ if(!tag)return; tag.textContent=m;
tag.className='copied'+(bad?' bad':''); };
if(!text){ say('nothing to copy', true); return; }
try{
if(!navigator.clipboard||!navigator.clipboard.writeText)throw new Error('no clipboard');
await navigator.clipboard.writeText(text);
say('Copied');
setTimeout(()=>{ if(tag&&tag.textContent==='Copied')tag.textContent=''; },2500);
}catch(e){
const el=document.getElementById(blockId);
try{
const r=document.createRange(); r.selectNodeContents(el);
const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
say(navigator.platform.indexOf('Mac')>=0 ? 'Selected -- press Cmd-C'
: 'Selected -- press Ctrl-C', true);
}catch(e2){ say('Could not copy; select the text above', true); }
}
}
async function agentLoad(){
try{
const r=await fetch('/api/agents');
if(!r.ok)throw new Error('HTTP '+r.status);
AGENTS=(await r.json()).agents||[];
}catch(e){ AGERR='Could not read the agent list: '+(e&&e.message?e.message:e); }
agentRender();
}
async function agentRemove(name){
AGBUSY=true;AGERR='';agentRender();
try{
const r=await fetch('/api/agent/remove',{method:'POST',
headers:{'Content-Type':'application/json','X-Gage':'1'},
body:JSON.stringify({name:name})});
const j=await r.json();
if(!r.ok||j.error)AGERR=j.error||('Could not remove '+name+'.');
else AGERR='Removed '+name+'. Its key is retired, not forgotten, so what it '
+'already sent keeps its verdict. If an MCP host still points at it, '
+'remove it there too.';
}catch(e){ AGERR='Could not remove '+name+': '+(e&&e.message?e.message:e); }
AGBUSY=false; AGKILL=''; await agentLoad();
}
async function agentCreate(){
AGBUSY=true;AGERR='';AGNEW=null;agentRender();
try{
const r=await fetch('/api/agent',{method:'POST',
headers:{'X-Gage':'1','Content-Type':'application/json'},
body:JSON.stringify({name:AGNAME.trim()})});
const j=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
AGNAME='';AGNEW=j.name;
}catch(e){ AGERR=(e&&e.message)?e.message:String(e); }
AGBUSY=false; await agentLoad(); AGNEW=null;
}
function budRender(force){
const box=document.getElementById('budpane');
const side=document.getElementById('budside');
if(!box||!side||TAB!=='buddies')return;
let h='';
const requests=(BUD.requests||[]).map(q=>q.key===PKEY?{...q,label:PNAME||q.label,
ask_pair:PPAIR,pasted:true}:q);
if(PKEY&&!requests.some(q=>q.key===PKEY))requests.unshift({key:PKEY,label:PNAME||'Pasted invite',
bundles:0,verdict:'SENDER NOT ON YOUR LIST',first_observed:null,ask_pair:PPAIR,pasted:true});
if(requests.length){
h+=`<div class="bsec">Not on your list<span class="bnum">${requests.length}</span></div>`
+`<div class="lansub budnote gap">${requests.some(q=>q.pasted)?'Pasted invites and keys that have sent you something are not on your list. ':''}A message is not a request for anything — deciding is yours, and doing nothing is a decision the list will keep offering.</div>`
+`<div class="budgrid">`;
for(const q of requests){
const open=ASK===q.key;
const shown=(REVEAL&&REVEAL.key===q.key)?REVEAL.fp:null;
h+=`<div class="dev ask">
<div class="devtop"><span class="devname">${esc(q.label)}</span>
<span class="devtag">${q.bundles} message${q.bundles===1?'':'s'}</span>
</div>
<div class="bwhy gap">${esc(vShort(q.verdict))}
${q.first_observed?'· first seen here '+esc(ago(q.first_observed)):''}
· pair 1 of its fingerprint is <b>${esc(q.label)}</b></div>
${shown?`<div class="devfp">${esc(shown)}</div>`:''}
<div class="devrow">
<button class="devbtn${open?'':' go'}" onclick="askOpen('${esc(q.key)}')">${open?'Cancel':'Add to my list'}</button>
<button class="devbtn quiet" onclick="budDecline('${esc(q.key)}',false)" ${ABUSY?'disabled':''}
title="Records that you said no. It does not block anything: mail from this key still arrives and still waits for you to accept it.">Refuse</button>
</div>
<div class="bwhy">Refusing records the decision so you are not asked again. It does
not stop anything arriving.</div>`;
if(open&&!shown){
h+=`<div class="bgate">
<div class="bwhy">${esc(ASKWHY)}</div>
<div class="bwhy">${esc(ASKHALF)}</div>
<div class="bwhy bwarn">${esc(ASKTRAP)}</div>
${q.pasted?`<div class="bwhy">Suggested name (edit as needed)</div>
<input class="lanin wide" placeholder="Name for this contact" value="${esc(ANAME)}"
oninput="ANAME=this.value">`:''}
<div class="bwhy">Type pair <b>${q.ask_pair}</b> of four, as the other machine shows it.</div>
<input class="lanin wide" placeholder="pair ${q.ask_pair}, e.g. two words" value="${esc(ACONF)}"
oninput="ACONF=this.value" onkeydown="if(event.key==='Enter')budReveal('${esc(q.key)}')">
<div class="devrow">
<button class="devbtn go" onclick="budReveal('${esc(q.key)}')" ${ABUSY?'disabled':''}>
${ABUSY?'Checking...':'Check this pair'}</button>
</div></div>`;
}else if(open&&shown){
h+=`<div class="bgate">
<div class="bwhy">Pair ${q.ask_pair} matched. Now check <b>all four</b> above against
the other machine before you add anything — one matching pair is 16 bits, and
a key can be ground out to produce a chosen one.</div>
<input class="lanin wide" placeholder="Call them what on your list?" value="${esc(ANAME)}"
oninput="ANAME=this.value">
<div class="devrow">
<button class="devbtn go" onclick="budConnect('${esc(q.key)}')" ${ABUSY?'disabled':''}>
${ABUSY?'Adding...':'All four match -- add them'}</button>
</div></div>`;
}
h+=`</div>`;
}
h+=`</div>`;
}
h+=`<div class="bsec">Your list<span class="bnum">${BUD.buddies.length}</span></div>`;
if(!BUD.buddies.length){
h+=`<div class="lansub budnote">Nobody yet. Pair with a buddy on this network to add their key, then confirm its word-pairs with them.</div>`;
}
const ags=BUD.agents_on_list||[];
if(ags.length){
const un=ags.reduce((t,a)=>t+a.unread,0);
h+=`<div class="lansub budnote">${ags.length===1?'One agent on this machine is':
`${ags.length} agents on this machine are`} also on your list
(${ags.map(a=>esc(a.name)).join(', ')}) — their keys came out of a keygen this
machine ran, so there is nobody to compare pairs with. They are on
<button class="blink" onclick="setTab('agents')">the Agents tab</button>.${
un?` <b>${un} unread</b> from them, in Chats.`:''}</div>`;
}
h+=`<div class="budgrid">`;
for(const b of BUD.buddies){
h+=`<div class="dev">
<div class="devtop"><span class="devname">${esc(b.name)}</span>
${b.unread?`<span class="bnum">${b.unread} unread</span>`:''}
${b.confirmed?'<span class="devtag ok" title="Somebody compared this key\'s four word-pairs against the other machine, over a channel neither machine controls.">confirmed</span>'
:`<span class="devtag warn" title="On your list, but nothing here records anybody comparing its word-pairs.">${b.stale?'confirmation stale':'unconfirmed'}</span>`}
${b.sealable?'<span class="devtag ok">sealable</span>'
:'<span class="devtag" title="A bundle does not carry its sender\'s encryption key, so a contact added from a message is verify-only.">verify only</span>'}</div>
${b.confirmed
? `<div class="devfp">${esc(b.fingerprint)}</div>`
: `<div class="bwhy">${b.stale
? 'What was confirmed for this contact no longer covers the key on file — an encryption key was added after the comparison, and the words that were checked did not cover it. Confirm it again.'
: 'Nobody has compared this key\'s word-pairs. It is on your list, so its mail arrives and is attributable — but nothing here records that the key belongs to the person whose name is beside it.'}
Their mail reads <b>SENDER ON YOUR LIST, UNCONFIRMED</b> until somebody does.</div>
<div class="bwhy">This key's device label is <b>${esc(b.label)}</b> — derived from the
signing key alone, which is all a manifest carries, so it is <i>not</i> the first of the
four pairs when there is an encryption key as well. It tells entries apart and nothing more.
Compare the four pairs below with the other person — over a call, in
person, any channel the machines do not control. Their machine shows
the same four for this key: their own list, or
<code>gage fingerprint${b.sealable?'':' --signing-only'}</code>.
If all four match, the key is theirs.</div>
<div class="devfp bigfp">${esc(b.fingerprint)}</div>
<div class="devrow">
<button class="devbtn go" onclick="budConfirm('${esc(b.name)}','${esc(b.fingerprint)}')" ${ABUSY?'disabled':''}>
${ABUSY?'Recording...':'The words match'}</button>
<button class="devbtn" onclick="budMismatch('${esc(b.name)}')" ${ABUSY?'disabled':''}>They don't match</button>
</div>
${CONFE[b.name]?`<div class="lanerr">${esc(CONFE[b.name])}</div>`:''}`}
${reachRow(b.reach)}
</div>`;
}
h+=`</div>`;
if(BUD.refused.length){
h+=`<div class="bsec"><button class="bsect" onclick="SHOWREF=!SHOWREF;budRender(true)"
>Refused (${BUD.refused.length}) ${SHOWREF?'▾':'▸'}</button></div>`;
if(SHOWREF){
h+=`<div class="lansub gap">Refusing records a decision. It does
not block anything — nothing in gage can stop a bundle being delivered, and
mail from these keys still arrives and still waits for you to accept it. What it
buys is that you are not asked about these again. Their word-pairs are not shown
here, so that un-refusing one puts you back in front of the same check.</div>
<div class="budgrid">`;
for(const r of BUD.refused){
h+=`<div class="dev">
<div class="devtop"><span class="devname">${esc(r.label)}</span>
<span class="devtag">refused ${esc(r.at)}</span>
${r.holding?`<span class="devtag">${r.holding} here now</span>`:''}</div>
<div class="devrow">
<button class="devbtn" onclick="budDecline('${esc(r.key)}',true)" ${ABUSY?'disabled':''}>
Un-refuse</button>
</div></div>`;
}
h+=`</div>`;
}
}
const active = document.activeElement;
if (!force && active && (box.contains(active) || side.contains(active))
&& (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
box.innerHTML=`<div class="budwrap">${h}</div>`;
const top=side.scrollTop;
side.innerHTML=budpaneHtml();
side.scrollTop=top;
}
async function budPost(path,body){
ABUSY=true;AERR='';AOK='';budRender(true);
try{
const r=await fetch(path,{method:'POST',headers:{'X-Gage':'1','Content-Type':'application/json'},
body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
AOK=(j.lines||[]).filter(x=>x&&x.trim()).join(' ');
ASK=null;ANAME='';ACONF='';REVEAL=null;
if(path==='/api/connect'){PKEY='';PNAME='';PPAIR=0;PASTED='';}
}catch(e){ AERR=(e&&e.message)?e.message:String(e); }
ABUSY=false; await budLoad(); load();
}
function budConnect(k){
const p = (REVEAL && REVEAL.key===k) ? REVEAL.pair : ACONF;
budPost('/api/connect',{key:k,name:ANAME,confirm:p});
}
function budDecline(k,undo){ budPost('/api/decline',{key:k,undo:!!undo}); }
async function budConfirm(name,fp){
CONFE[name]='';ABUSY=true;budRender(true);
try{
const r=await fetch('/api/confirm',{method:'POST',
headers:{'X-Gage':'1','Content-Type':'application/json'},
body:JSON.stringify({name:name,fingerprint:fp})});
const j=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
}catch(e){ CONFE[name]=(e&&e.message)?e.message:String(e); }
ABUSY=false; await budLoad(); load();
}
function budMismatch(name){
CONFE[name]='Then this key is not theirs — or one of you is looking at the wrong entry. Do not confirm it. If you do not know whose key this is, refuse it instead.';
budRender(true);
}
function setupKnown(){ return ST!==null && AGENTS!==null; }
function setupSteps(){
const agentNames=(AGENTS||[]).map(a=>a.name);
const people=((ST&&ST.contacts)||[]).filter(c=>!agentNames.includes(c.name));
const routed=people.filter(c=>c.routed);
const nearby=((LAN&&LAN.devices)||[]).length;
const accepted=((ST&&ST.accepted)||[]).length;
const agents=(AGENTS||[]);
const names=a=>a.map(x=>x.name).join(', ');
return [
{t:'This machine has an identity',
done:!!(ST&&ST.has_identity&&ST.me),
fact:(ST&&ST.me)?ST.me.handle+' · '+ST.me.key.slice(0,12)+'…':'',
mono:true,
act:(ST&&ST.has_identity)
?'A key file is here but will not load, so this is not a first run '
+'and no guide will open over it. Nothing else can be done until it '
+'reads again -- check its permissions before anything writes.'
:'Choose a handle and create a key. The guide that opens on a first '
+'run does exactly this, and nothing else here can be done until it has.',
go:null,goL:''},
{t:'An agent identity exists here',
done:agents.length>0,
fact:agents.length
?names(agents)+' — whether your assistant has loaded the config '
+'is not something this page can see'
:'',
act:'Create one on the Agents tab. It writes the config your assistant '
+'reads, with this machine’s real paths already in it; pasting '
+'that config into the assistant is the half that happens outside gage.',
go:'agents',goL:'Open Agents'},
{t:'Somebody else’s key is on your list',
done:people.length>0,
fact:people.length?(people.length+' '+(people.length===1?'person':'people')+': '+names(people)):'',
act:'Hand them your invite from the Buddies tab, or paste theirs, and '
+'compare all four word-pairs aloud before you add them.',
go:'buddies',goL:'Open Buddies'},
{t:'You have a way to reach them',
done:routed.length>0
||(people.length>0&&(!!(ST&&ST.relay)||nearby>0||accepted>0||!!(ST&&ST.ever_sent))),
fact:routed.length?(routed.length+' with a saved address: '+names(routed))
:(people.length>0&&ST&&ST.relay)?'a relay is set, and somebody is on your list'
:(people.length>0&&nearby)?(nearby+' device'+(nearby===1?'':'s')+' on this network')
:(people.length>0&&(accepted>0||(ST&&ST.ever_sent)))
?'something has already made the trip, which is better evidence than an address'
:'',
act:'Three ways, and none is required to receive: hand a sealed file over '
+'(gage seal, then gage unseal and gage open at the other end), run '
+'gage lan on both machines on the same network, or set a relay in the '
+'rail on Conversations.',
go:'chats',goL:'Open Conversations'},
{t:'You have exchanged something real',
done:!!(ST&&ST.ever_sent)||accepted>0,
fact:(ST&&ST.ever_sent)
?(accepted?'you have sent, and accepted something':'you have sent')
:(accepted?'you have accepted something':''),
act:'Send one small thing to somebody on your list. Publishing an anchor '
+'does not count: that is not handing work to a person.',
go:'chats',goL:'Open Conversations'},
];
}
function setupOpenCount(){ return setupKnown()?setupSteps().filter(s=>!s.done).length:0; }
let SUWHY=false;
function setupRender(){
const el=document.getElementById('setuppane'); if(!el)return;
if(!setupKnown()){
el.innerHTML='<div class="setup"><div class="hint"><h2>Reading this machine</h2>'
+'<p>The steps below are derived from this machine’s own state, so '
+'there is nothing to say until it has been read.</p></div></div>';
return;
}
const steps=setupSteps(), open=steps.filter(s=>!s.done).length;
let h='<div class="setup">';
h+='<div class="hint"><h2>'
+(open?esc(open+' of '+steps.length+' left'):'Nothing left to set up')
+'</h2><p>'
+(open
?'Each step reads this machine’s own state, so it answers for this '
+'machine and not for the project in general.'
:'This machine has an identity, an agent identity, somebody else’s '
+'key on its list, a way to reach them, and a send or an acceptance '
+'behind it.')
+'</p></div>';
steps.forEach((st,i)=>{
h+='<div class="dev'+(st.done?' done':'')+'"><div class="step">'
+'<span class="stepn">'+(i+1)+'</span><div class="stepb">'
+'<div class="devtop"><span class="devname">'+esc(st.t)+'</span>'
+'<span class="devtag">'+(st.done?'done':'not yet')+'</span></div>';
if(st.done&&st.fact)
h+='<div class="'+(st.mono?'devfp':'stept')+'">'+esc(st.fact)+'</div>';
if(!st.done){
h+='<div class="stept">'+esc(st.act)+'</div>';
if(st.go)h+='<div class="devrow"><button class="devbtn" onclick="setTab(\''
+st.go+'\')">'+esc(st.goL)+'</button></div>';
}
h+='</div></div></div>';
});
h+='<details class="disc"'+(SUWHY?' open':'')+' ontoggle="SUWHY=this.open">'
+'<summary>Where the longer instructions are</summary>'
+'<div class="hint"><p>This page deliberately contains no link: a viewer '
+'that can point you somewhere else is a browser, and a test asserts there '
+'is no address anywhere in it. For the long form, run <code>gage help</code> '
+'in a terminal, or read the project’s own first-tester packet, which '
+'walks two people through this same list in about twenty minutes.</p></div>'
+'</details>';
h+='</div>';
el.innerHTML=h;
}
const CONNDISMISS=new Set();
let CONNMAP={};
function connItems(){
const out=[];
for(const q of BUD.requests||[]){
out.push({id:'req:'+q.key,
text:`${q.label} sent you ${q.bundles===1?'a bundle':q.bundles+' bundles'} and is not on your list.`,
btn:'Review',act:()=>{ setTab('buddies'); }});
}
for(const b of BUD.buddies||[]){
if(b.confirmed)continue;
out.push({id:'unconf:'+b.key,
text:`${b.name} is on your list but not confirmed — comparing the words finishes the connection.`,
btn:'Compare the words',act:()=>{ setTab('buddies');
setTimeout(()=>{ const cards=document.querySelectorAll('#budpane .dev');
for(const c of cards){ const n=c.querySelector('.devname');
if(n&&n.textContent===b.name){ c.scrollIntoView({block:'center'});
c.classList.add('spot');
setTimeout(()=>c.classList.remove('spot'),1600); break; } } },60); }});
}
for(const d of LAN.devices||[]){
if(d.paired_as)continue;
out.push({id:'lan:'+d.label,
text:`${d.label} is on this network and not paired.`,
btn:'Pair',act:()=>{ setTab('buddies'); lanPair(d.label); }});
}
return out.filter(i=>!CONNDISMISS.has(i.id));
}
function connRender(){
const bar=document.getElementById('connbar'); if(!bar)return;
const items=connItems(); CONNMAP={};
if(!items.length){ bar.hidden=true; bar.innerHTML=''; return; }
const shown=items.slice(0,3);
let h='';
shown.forEach((it,ix)=>{ CONNMAP[ix]=it;
h+=`<div class="connrow"><span>${esc(it.text)}</span><span class="sp"></span>`
+`<button class="devbtn" onclick="connAct(${ix})">${esc(it.btn)}</button>`
+`<button class="devbtn" onclick="connDismiss(${ix})" title="Not now">✕</button></div>`; });
if(items.length>shown.length)
h+=`<div class="connrow"><span>And ${items.length-shown.length} more waiting in Buddies.</span></div>`;
bar.innerHTML=h; bar.hidden=false;
}
function connAct(ix){ const it=CONNMAP[ix]; if(it)it.act(); }
function connDismiss(ix){ const it=CONNMAP[ix]; if(it){ CONNDISMISS.add(it.id); connRender(); } }
async function budLoad(){
try{
const r=await fetch('/api/buddies');
if(!r.ok)throw new Error('HTTP '+r.status);
BUD=await r.json();
if(ASK&&!BUD.requests.some(q=>q.key===ASK))ASK=null;
}catch(e){
AERR='Could not read your list: '+(e&&e.message?e.message:e);
}
tabsRender(); budRender(); connRender();
if(TAB==='setup'){ await agentLoad(); setupRender(); tabsRender(); }
}
budLoad();setInterval(budLoad,2500);
hashRead();
setTab(TAB);
let W={stage:'welcome',handle:'',words:[],warning:'',devices:[],chosen:'',name:'',compared:false,
subject:'Hello from gage',message:'I have set up gage. Reply when you are ready.',error:'',busy:false};
function wRender(){
const el=document.getElementById('wizard'); if(!el)return;
const field=(id,val,placeholder)=>`<input class="winput" id="${id}" value="${esc(val)}" placeholder="${placeholder}" oninput="W.${id==='whandle'?'handle':id==='wname'?'name':id==='wsubject'?'subject':'message'}=this.value">`;
let body='';
if(W.stage==='welcome')body=`<h1>Welcome to gage</h1><p>Gage lets you exchange signed bundles with people you know. First choose a name for this machine, then compare word-pairs with a buddy before you send them a first bundle.</p><button class="btn go" onclick="W.stage='handle';wRender()">Get started</button>`;
if(W.stage==='handle')body=`<h1>Choose a handle</h1><p>This name helps you and your buddies recognize this machine.</p>${field('whandle',W.handle,'Your handle')}<button class="btn go" onclick="wCreate()" ${W.busy?'disabled':''}>${W.busy?'Creating…':'Create identity'}</button>`;
if(W.stage==='words')body=`<h1>Your word-pairs</h1><p>Keep these pairs available while you compare them with your buddy. They identify this machine.</p><div class="wwords">${esc(W.words.join('\n'))}</div><p>${esc(W.warning)}</p><p>For a backup, run <code>gage export-identity</code> in a terminal and keep the exported file safe.</p>`+inviteHtml()+`<button class="btn go" onclick="wNearby()">Continue to pairing</button>`;
if(W.stage==='pair'){
const d=W.devices.find(x=>x.label===W.chosen);
body=`<h1>Pair with a buddy</h1><p>Both machines must be running <code>gage lan</code> on the same network. Select your buddy, read all four pairs aloud, and compare them with the other screen.</p>`+
(W.devices.length?W.devices.map(x=>`<label class="wcheck"><input type="radio" name="wdevice" ${W.chosen===x.label?'checked':''} onchange="W.chosen='${esc(x.label)}';W.compared=false;wRender()"><span>${esc(x.paired_as||x.label)}</span></label>${W.chosen===x.label?`<div class="wwords">${esc(x.fingerprint)}</div>`:''}`).join(''):'<p>No nearby machines yet. Start <code>gage lan</code> on both machines, then refresh this step.</p>')+
`<button class="btn" onclick="wNearby()">Refresh nearby machines</button>`+
(d?`<label class="wcheck"><input type="checkbox" ${W.compared?'checked':''} onchange="W.compared=this.checked;wRender()"><span>We compared these aloud and all four pairs match.</span></label>${field('wname',W.name,'Name for this buddy')}<button class="btn go" onclick="wPair()" ${!W.compared||W.busy?'disabled':''}>${W.busy?'Pairing…':'Pair buddy'}</button>`:'');
}
if(W.stage==='send')body=`<h1>Send your first bundle</h1><p>Write a short message for ${esc(W.name)}. Gage will send it through the existing bundle flow.</p>${field('wsubject',W.subject,'Subject')}<textarea class="winput" id="wmessage" oninput="W.message=this.value">${esc(W.message)}</textarea><button class="btn go" onclick="wSend()" ${W.busy?'disabled':''}>${W.busy?'Sending…':'Send first bundle'}</button>`;
if(W.stage==='done')body=`<h1>You’re set up</h1><p>Your first bundle was sent. You can continue in the gage console.</p><button class="btn go" onclick="document.getElementById('wizard').hidden=true">Open console</button>`;
el.innerHTML=`<section class="wcard">${body}${W.error?`<div class="werror">${esc(W.error)}</div>`:''}</section>`;
}
async function wCreate(){
W.busy=true;W.error='';wRender();
try{const r=await post('/api/init',{handle:W.handle});const j=await r.json().catch(()=>({}));if(!r.ok)throw Error(j.error||`HTTP ${r.status}`);
W.handle=j.handle;W.words=Array.isArray(j.words)?j.words:(typeof j.words==='string'?j.words.split('\n'):[]);W.warning=j.backup_warning||'';W.stage='words';
await inviteLoad(true);
}catch(e){W.error=e.message||String(e)} W.busy=false;wRender();
}
async function wNearby(){
W.error='';try{const r=await fetch('/api/nearby');if(!r.ok)throw Error(`HTTP ${r.status}`);const j=await r.json();W.devices=j.devices||[];W.chosen='';W.compared=false;W.stage='pair';}
catch(e){W.error=e.message||String(e)}wRender();
}
async function wPair(){
const d=W.devices.find(x=>x.label===W.chosen);if(!d||!W.compared)return;
W.busy=true;W.error='';wRender();
try{const r=await post('/api/pair',{label:d.label,name:W.name.trim(),fingerprint:d.fingerprint});const j=await r.json().catch(()=>({}));if(!r.ok)throw Error(j.error||`HTTP ${r.status}`);W.stage='send';}
catch(e){W.error=e.message||String(e)}W.busy=false;wRender();
}
function wB64(s){const bytes=new TextEncoder().encode(s);let bin='';for(const b of bytes)bin+=String.fromCharCode(b);return btoa(bin)}
async function wSend(){
if(!W.message.trim())return;W.busy=true;W.error='';wRender();
try{const r=await post('/api/send',{to:W.name.trim(),subject:W.subject.trim(),type:'share',files:[{name:'hello.txt',data:wB64(W.message)}]});const j=await r.json().catch(()=>({}));if(!r.ok)throw Error(j.error||`HTTP ${r.status}`);W.stage='done';}
catch(e){W.error=e.message||String(e)}W.busy=false;wRender();
}
async function wStart(){try{const r=await fetch('/api/state');if(!r.ok)throw Error(`HTTP ${r.status}`);const s=await r.json();if(s.has_identity===false){document.getElementById('wizard').hidden=false;wRender()}}
catch(e){}}
wStart();
load();setInterval(load,2500);
</script>