seisin 0.2.0 → 0.4.0

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/src/walls.js CHANGED
@@ -36,6 +36,7 @@
36
36
  import { openSync, readSync, closeSync, statSync } from "node:fs";
37
37
  import { read } from "./log.js";
38
38
  import { explain } from "./owners.js";
39
+ import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
39
40
 
40
41
  /** A wall is something you hit more than once. Once is information; twice is a pattern. */
41
42
  export const MIN_HITS = 2;
@@ -48,8 +49,12 @@ export const MIN_HITS = 2;
48
49
  * different repositories are two walls, and merging them hides which one was
49
50
  * hit.
50
51
  */
51
- export function walls(config, role, { file, min = MIN_HITS, since = null, limit = 6 } = {}) {
52
- const denied = read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
52
+ export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false } = {}) {
53
+ // `entries`: the log already read, for a caller asking about every role at
54
+ // once (the console asks for 32 on a real policy) — one read instead of 32.
55
+ const denied = entries
56
+ ? entries.filter((e) => e.role === role && e.verdict === "denied" && (!since || e.at >= since))
57
+ : read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
53
58
 
54
59
  const byKey = new Map();
55
60
  for (const e of denied) {
@@ -69,8 +74,16 @@ export function walls(config, role, { file, min = MIN_HITS, since = null, limit
69
74
  if (verdict.allowed) continue;
70
75
  out.push({ ...w, reason: verdict.reason, owners: verdict.owners });
71
76
  }
72
- out.sort((a, b) => b.times - a.times || a.target.localeCompare(b.target));
73
- return limit > 0 ? out.slice(0, limit) : out;
77
+ // A wall the role stopped hitting — it ran STALE_RUNS times since, refused
78
+ // elsewhere but not here — is still true and no longer worth saying. Same
79
+ // rule as an old request. `fresh` drops them; otherwise they are marked.
80
+ for (const w of out) {
81
+ const n = runsAfter(denied, Date.parse(w.lastAt), RUN_GAP_MS);
82
+ if (n >= STALE_RUNS) w.stale = { runs: n };
83
+ }
84
+ const kept = fresh ? out.filter((w) => !w.stale) : out;
85
+ kept.sort((a, b) => b.times - a.times || a.target.localeCompare(b.target));
86
+ return limit > 0 ? kept.slice(0, limit) : kept;
74
87
  }
75
88
 
76
89
  /**
@@ -160,7 +173,8 @@ export function render(list) {
160
173
  ];
161
174
  for (const w of list) {
162
175
  lines.push(` ${w.times}× ${w.action} ${w.target}`);
163
- lines.push(` ${w.reason}`);
176
+ // The reason for an owned path starts with the path again; the owner is the news.
177
+ lines.push(` ${w.owners?.length ? `belongs to ${w.owners.join(", ")}` : w.reason}`);
164
178
  }
165
179
  const n = wasted(list);
166
180
  // Said to the agent, not about it. One that can see what retrying cost has a
package/ui/favicon.svg CHANGED
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#12161c"/><g clip-path="url(#p)"><clipPath id="p"><rect x="6" y="6" width="20" height="20" rx="4"/></clipPath><rect x="6" y="6" width="9" height="11" fill="#3fb950"/></g><rect x="6" y="6" width="20" height="20" rx="4" fill="none" stroke="#e6edf3" stroke-width="2.6"/><path d="M15 6v11M6 17h20" stroke="#e6edf3" stroke-width="2.6" stroke-linecap="square"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1f2328"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5H26.5V5.5Z" fill="#2da44e"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5" fill="none" stroke="#fff" stroke-width="2.8" stroke-linecap="round"/></svg>
package/ui/index.html CHANGED
@@ -4,12 +4,12 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>seisin console</title>
7
- <!-- A plot map, which is what the word means: seisin is the legal term for possession
8
- of a freehold estate in LAND. The square is the repo, the lines divide it into
9
- parcels, and one parcel is claimed. Two strokes and one fill so the divisions
10
- survive at 16px, where a favicon actually lives. Also in ui/favicon.svg for anyone
11
- who wants the file; inline here so the console needs no second route to serve it. -->
12
- <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%228%22%20fill%3D%22%2312161c%22%2F%3E%3Cg%20clip-path%3D%22url%28%23p%29%22%3E%3CclipPath%20id%3D%22p%22%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%2220%22%20height%3D%2220%22%20rx%3D%224%22%2F%3E%3C%2FclipPath%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%229%22%20height%3D%2211%22%20fill%3D%22%233fb950%22%2F%3E%3C%2Fg%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%2220%22%20height%3D%2220%22%20rx%3D%224%22%20fill%3D%22none%22%20stroke%3D%22%23e6edf3%22%20stroke-width%3D%222.6%22%2F%3E%3Cpath%20d%3D%22M15%206v11M6%2017h20%22%20stroke%3D%22%23e6edf3%22%20stroke-width%3D%222.6%22%20stroke-linecap%3D%22square%22%2F%3E%3C%2Fsvg%3E">
7
+ <!-- A boundary, which is what the word is about: seisin is the legal term for who
8
+ actually holds a piece of LAND. The S is a property line splitting one plot between
9
+ two holders, and the filled side is held. One stroke and one fill so it survives at
10
+ 16px, where a favicon actually lives. Also in ui/favicon.svg for anyone who wants
11
+ the file; inline here so the console needs no second route to serve it. -->
12
+ <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227%22%20fill%3D%22%231f2328%22%2F%3E%3Cpath%20d%3D%22M16%205.5C8%205.5%208%2016%2016%2016S24%2026.5%2016%2026.5H26.5V5.5Z%22%20fill%3D%22%232da44e%22%2F%3E%3Cpath%20d%3D%22M16%205.5C8%205.5%208%2016%2016%2016S24%2026.5%2016%2026.5%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222.8%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E">
13
13
  <meta name="description" content="Which folders each agent writes, which keys it may read, and whose a path is when the answer is no.">
14
14
  <meta name="color-scheme" content="light dark">
15
15
  <style>
@@ -60,6 +60,8 @@ body{margin:0;background:var(--bg);color:var(--fg);
60
60
  .brand{padding:15px 14px 13px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:12px}
61
61
  .brand b{font-size:15px;font-weight:600;letter-spacing:-.01em}
62
62
  .brand small{font-size:11px;color:var(--mid);display:block;margin-top:1px}
63
+ .brand .id{display:flex;align-items:center;gap:10px}
64
+ .brand .mark{width:28px;height:28px;flex:none}
63
65
  .theme{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
64
66
  .theme button{flex:1;border:0;background:none;color:var(--mid);padding:4px 0;border-radius:4px;
65
67
  cursor:pointer;font:inherit;font-size:12px}
@@ -87,6 +89,15 @@ button.btn.warn{border-color:var(--amber);color:var(--amber)}
87
89
  button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
88
90
 
89
91
  .cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
92
+ /* Time window: quick presets plus a free "from". Filters what comes from the log. */
93
+ .range{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--mid)}
94
+ .range .seg{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
95
+ .range .seg button{border:0;background:none;color:var(--mid);padding:4px 10px;border-radius:4px;
96
+ cursor:pointer;font:inherit;font-size:12px}
97
+ .range .seg button.on{background:var(--panel);color:var(--fg);box-shadow:0 1px 2px #0002}
98
+ .range input{font:inherit;font-size:12px;padding:4px 8px;border:1px solid var(--line2);
99
+ border-radius:6px;background:var(--bg);color:var(--fg)}
100
+ .range input.on{border-color:var(--fg)}
90
101
  .card{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line2);
91
102
  border-radius:8px;padding:14px 16px;min-height:96px;display:flex;flex-direction:column}
92
103
  .card.g{border-left-color:var(--green)} .card.a{border-left-color:var(--amber)} .card.b{border-left-color:var(--blue)}
@@ -145,6 +156,9 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
145
156
  justify-content:space-between;gap:12px}
146
157
  .box>header h2{font-size:13px;font-weight:600;margin:0}
147
158
  .box>header .note{font-size:11px;color:var(--dim)}
159
+ .box>header .bulk{margin-left:auto;display:flex;gap:6px;align-items:center}
160
+ .box>header .bulk[hidden]{display:none}
161
+ .box>header .bulk input{font-size:12px;padding:4px 6px;min-width:180px}
148
162
 
149
163
  .scrollx{overflow-x:auto}
150
164
  table{width:100%;border-collapse:collapse;font-size:13px;min-width:720px}
@@ -255,8 +269,11 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
255
269
 
256
270
  @media (max-width:980px){.two{grid-template-columns:1fr}}
257
271
  @media (max-width:720px){
258
- .frame{grid-template-columns:1fr}
259
- .side{border-right:0;border-bottom:1px solid var(--line)}
272
+ /* minmax(0,1fr), not 1fr: a bare 1fr never shrinks below its widest child's
273
+ min-content, and the nav's unwrapped links made the whole page 793px wide on a
274
+ 375px phone. The tabs scroll inside .list instead. */
275
+ .frame{grid-template-columns:minmax(0,1fr)}
276
+ .side{border-right:0;border-bottom:1px solid var(--line);min-width:0}
260
277
  .side .list{display:flex;overflow-x:auto;padding:6px 8px}
261
278
  .side .cap{display:none}
262
279
  .side a{border-left:0;border-bottom:2px solid transparent;white-space:nowrap}
@@ -268,6 +285,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
268
285
  /* Requests. A queue nobody opens is not human-in-the-loop, so it looks like
269
286
  something waiting rather than another table of facts. */
270
287
  .req{display:flex;gap:14px;align-items:flex-start;padding:14px 16px;border-top:1px solid var(--line)}
288
+ .req.stale .who{opacity:.6}
271
289
  .req:first-child{border-top:0}
272
290
  .req .who{flex:1;min-width:0}
273
291
  .req .who b{font-weight:600}
@@ -286,7 +304,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
286
304
 
287
305
  <nav class="side">
288
306
  <div class="brand">
289
- <div><b>seisin</b><small id="source">folders and keys, per agent</small></div>
307
+ <div class="id"><svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="var(--fg)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5H26.5V5.5Z" fill="var(--green)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5" fill="none" stroke="var(--bg)" stroke-width="2.8" stroke-linecap="round"/></svg><div><b>seisin</b><small id="source">folders and keys, per agent</small></div></div>
290
308
  <div class="theme" role="group" aria-label="Theme">
291
309
  <button type="button" id="t-light">light</button>
292
310
  <button type="button" id="t-dark">dark</button>
@@ -330,6 +348,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
330
348
  Each one is a link to the screen that explains it. A number you cannot
331
349
  act on is decoration; a number that takes you to the thing it counts
332
350
  is the top of a path. -->
351
+ <div class="range" id="range" hidden>
352
+ <span>Window</span>
353
+ <div class="seg" role="group" aria-label="Time window">
354
+ <button type="button" data-r="1h">1h</button>
355
+ <button type="button" data-r="4h">4h</button>
356
+ <button type="button" data-r="24h">24h</button>
357
+ <button type="button" data-r="7d">7d</button>
358
+ <button type="button" data-r="30d">30d</button>
359
+ <button type="button" data-r="all">All</button>
360
+ </div>
361
+ <label>from <input type="datetime-local" id="range-from" aria-label="From"></label>
362
+ </div>
363
+
333
364
  <div class="cards" id="kpis">
334
365
  <a class="card kpi" href="#requests" id="k-wait">
335
366
  <h2>waiting on you</h2><div class="n" id="c-wait">0</div><div class="s" id="c-wait-s">nothing to decide</div></a>
@@ -387,6 +418,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
387
418
  <header>
388
419
  <h2>Pending decisions</h2>
389
420
  <span class="note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
421
+ <span class="bulk" id="bulk" hidden>
422
+ <input id="why-all" placeholder="why, for all of them" aria-label="reason for declining all">
423
+ <button class="btn warn" id="decline-all" type="button">Decline all</button>
424
+ </span>
390
425
  </header>
391
426
  <div id="reqs"></div>
392
427
  </div>
@@ -456,7 +491,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
456
491
  <div class="foot">
457
492
  <b id="foot-mode">Console preview.</b> <span id="foot-text">Opened as a file, this page edits a policy in memory and shows what
458
493
  would be generated. Copy the <b>seisin.toml</b> above to keep a change.</span><br>
459
- The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.76</b>, whose schema the test suite exercises against the real binary.
494
+ The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.77</b>, whose schema the test suite exercises against the real binary.
460
495
  </div>
461
496
  </main>
462
497
  </div>
@@ -668,14 +703,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
668
703
  }
669
704
  L.push("{");
670
705
  L.push(' <span class="k">"network"</span>: {');
671
- L.push(' "allowedDomains": ["github.com"],');
706
+ var lp=(r.localPorts||[]).map(function(n){return '"localhost:'+Number(n)+'"';});
707
+ L.push(' "allowedDomains": ["github.com"'+(lp.length?', <span class="g">'+lp.join(', ')+'</span>':'')+']'+(lp.length?', <span class="c">// local_ports, through the proxy</span>':','));
672
708
  L.push(' "deniedDomains": [], "allowUnixSockets": [],');
673
- L.push(' "allowLocalBinding": false');
709
+ L.push(' "allowLocalBinding": '+(r.localBinding?'<span class="d">true</span> <span class="c">// can reach every localhost port (macOS)</span>':'false'));
674
710
  L.push(" },");
675
711
  L.push(' <span class="k">"filesystem"</span>: {');
676
712
  // KEYDIR comes from seisin.toml, which is a file the console may be
677
713
  // pointed at without having written it. Unescaped it was HTML injection
678
- // into a page that carries SEISIN_TOKEN — and that token approves grants.
714
+ // into a page that holds the run's token — and that token approves grants.
679
715
  L.push(' <span class="d">"denyRead"</span>: ["'+esc(KEYDIR)+'"],');
680
716
  if(r.keys.length){
681
717
  L.push(' <span class="g">"allowRead"</span>: [');
@@ -690,8 +726,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
690
726
  });
691
727
  else L.push(' <span class="c">// no territory</span>');
692
728
  L.push(" ],");
693
- L.push(' "denyWrite": []');
694
- L.push(" }");
729
+ if((r.neverWrites||[]).length){
730
+ L.push(' <span class="d">"denyWrite"</span>: [ <span class="c">// never_writes</span>');
731
+ r.neverWrites.forEach(function(p,i){ L.push(' "'+esc(p.replace(/\/\*\*$/,""))+'"'+(i<r.neverWrites.length-1?",":"")); });
732
+ L.push(" ]");
733
+ } else L.push(' "denyWrite": []');
734
+ L.push(" }"+(r.trustd?",":""));
735
+ if(r.trustd) L.push(' <span class="d">"enableWeakerNetworkIsolation"</span>: true <span class="c">// trustd = true</span>');
695
736
  L.push("}");
696
737
  document.getElementById("cfg").innerHTML=L.join("\n");
697
738
  }
@@ -851,7 +892,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
851
892
  "somebody claims them.";
852
893
  document.getElementById("ca-lede").innerHTML = lede;
853
894
  var win = document.getElementById("dn-window");
854
- if (win) win.textContent = f.total.toLocaleString() + " denials \u00b7 " +
895
+ if (win) win.textContent = rangeLabel() + " \u00b7 " + f.total.toLocaleString() + " denials \u00b7 " +
855
896
  (f.distinct || f.causes.length).toLocaleString() + " distinct paths";
856
897
 
857
898
  var max = top.times || 1;
@@ -978,7 +1019,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
978
1019
  set("k-open", off ? "alarm" : "calm", off,
979
1020
  off ? (off === 1 ? "role nothing is enforcing" : "roles nothing is enforcing") : "every role confined");
980
1021
  set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
981
- f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied");
1022
+ (f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied") +
1023
+ (RANGE === "all" ? "" : " \u00b7 " + rangeLabel()));
982
1024
  // The shape, not the size: one name at 74% is a tooling problem, the same
983
1025
  // volume spread thin is a territory question, so the caption carries the name.
984
1026
  set("k-conc", fam && fam.share >= 0.4 ? "live" : "calm",
@@ -1014,11 +1056,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1014
1056
  host.innerHTML = REQS.map(function(r, i){
1015
1057
  var owners = (r.owners && r.owners.length) ? " — belongs to <b>" + esc(r.owners.join(", ")) + "</b>" : "";
1016
1058
  var times = r.times > 1 ? ' · <span class="asked">asked ' + r.times + "×</span>" : "";
1017
- return '<div class="req">' +
1059
+ // Marked, not moved: Approve and Decline post this row's number.
1060
+ var stale = r.stale ? ' · not asked again in ' + Number(r.stale.runs) + ' runs since — likely no longer needed' : "";
1061
+ return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
1018
1062
  '<div class="who">' +
1019
1063
  "<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) +
1020
1064
  ' <span class="path">' + esc(r.grant) + "</span>" + owners + "</div>" +
1021
- '<div class="sub">first asked over ' + esc(r.target) + times + "</div>" +
1065
+ '<div class="sub">first asked over ' + esc(r.target) + times + stale + "</div>" +
1022
1066
  "</div>" +
1023
1067
  '<div class="act">' +
1024
1068
  '<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
@@ -1031,6 +1075,48 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1031
1075
  host.querySelectorAll("button[data-n]").forEach(function(b){
1032
1076
  b.addEventListener("click", function(){ act(b); });
1033
1077
  });
1078
+ drawBulk();
1079
+ }
1080
+
1081
+ /**
1082
+ * Decline every request on screen — two clicks, no dialog.
1083
+ *
1084
+ * There is no "approve all" on purpose: a grant is read one at a time. The
1085
+ * keys sent are the ones drawn now, so a request that arrives in between is
1086
+ * not refused unseen.
1087
+ */
1088
+ function drawBulk(){
1089
+ var bulk = document.getElementById("bulk");
1090
+ var btn = document.getElementById("decline-all");
1091
+ bulk.hidden = REQS.length < 2;
1092
+ btn.disabled = false;
1093
+ btn.dataset.armed = "";
1094
+ btn.textContent = "Decline all (" + REQS.length + ")";
1095
+ btn.onclick = async function(){
1096
+ if (!btn.dataset.armed) {
1097
+ btn.dataset.armed = "1";
1098
+ btn.textContent = "Click again to decline " + REQS.length;
1099
+ setTimeout(function(){ if (btn.dataset.armed) { btn.dataset.armed = ""; btn.textContent = "Decline all (" + REQS.length + ")"; } }, 4000);
1100
+ return;
1101
+ }
1102
+ btn.disabled = true;
1103
+ try {
1104
+ var r = await fetch("/api/decline-all", {
1105
+ method: "POST",
1106
+ headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
1107
+ body: JSON.stringify({ keys: REQS.map(function(q){ return q.key; }),
1108
+ reason: (document.getElementById("why-all") || {}).value || "declined in bulk from the console" })
1109
+ });
1110
+ var out = await r.json();
1111
+ if (!r.ok) { var err = new Error(out.error || "refused"); err.status = r.status; throw err; }
1112
+ await reload();
1113
+ } catch (e) {
1114
+ btn.disabled = false;
1115
+ btn.textContent = "Decline all (" + REQS.length + ")";
1116
+ var note = document.querySelector("#requests .note");
1117
+ if (note) { note.textContent = whyFailed(e, e.status); note.style.color = "var(--amber)"; }
1118
+ }
1119
+ };
1034
1120
  }
1035
1121
 
1036
1122
  /**
@@ -1047,18 +1133,18 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1047
1133
  try {
1048
1134
  var r = await fetch("/api/decide", {
1049
1135
  method: "POST",
1050
- headers: { "content-type": "application/json", "x-seisin-token": window.SEISIN_TOKEN || "" },
1136
+ headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
1051
1137
  body: JSON.stringify({ number: n, decision: btn.dataset.d, reason: why })
1052
1138
  });
1053
1139
  var out = await r.json();
1054
- if (!r.ok) throw new Error(out.error || "denied");
1140
+ if (!r.ok) { var err = new Error(out.error || "denied"); err.status = r.status; throw err; }
1055
1141
  // Re-read rather than patch: the grant rewrote seisin.toml, so the
1056
1142
  // territory on screen is stale too, not just the queue.
1057
1143
  await reload();
1058
1144
  } catch (e) {
1059
1145
  row.querySelectorAll("button,input").forEach(function(el){ el.disabled = false; });
1060
1146
  var s = row.querySelector(".sub");
1061
- s.innerHTML = '<span style="color:var(--amber)">' + esc(e.message) + "</span>";
1147
+ s.innerHTML = '<span style="color:var(--amber)">' + esc(whyFailed(e, e.status)) + "</span>";
1062
1148
  }
1063
1149
  }
1064
1150
 
@@ -1085,16 +1171,112 @@ function draw(){
1085
1171
  * you can look at before installing anything. What it must never do is show
1086
1172
  * example data while looking live, so the label says which one you are in.
1087
1173
  */
1174
+ /**
1175
+ * This run's token, from the link `seisin ui` opened.
1176
+ *
1177
+ * It arrives in the fragment (#t=…), which the browser never sends to any
1178
+ * server, and it is moved into this tab's storage and wiped from the address
1179
+ * bar and the history entry at once. The server no longer puts it in the
1180
+ * page: anything that could GET "/" used to walk away with it.
1181
+ */
1182
+ var TOKEN = (function(){
1183
+ var m = /(?:^#|&)t=([a-f0-9]+)/.exec(location.hash || "");
1184
+ try {
1185
+ if (m) { sessionStorage.setItem("seisin-token", m[1]); }
1186
+ var t = m ? m[1] : (sessionStorage.getItem("seisin-token") || "");
1187
+ if (m) history.replaceState(null, "", location.pathname + location.search);
1188
+ return t;
1189
+ } catch (_) { return m ? m[1] : ""; }
1190
+ })();
1191
+
1192
+ /**
1193
+ * What to tell a person when a request to this server fails.
1194
+ *
1195
+ * "Failed to fetch" was all the browser said when the console had been
1196
+ * restarted under an open tab — the server was gone, or back with a new
1197
+ * token that this tab does not hold. Both have the same fix, and it is not
1198
+ * obvious from either message.
1199
+ */
1200
+ function whyFailed(e, status){
1201
+ if (status === 403)
1202
+ return "this tab's link is stale — the console restarted. Open the new link `seisin ui` printed.";
1203
+ if (e && /network|fetch/i.test(String(e.message || e)))
1204
+ return "the console did not answer. If `seisin ui` restarted, open the new link it printed.";
1205
+ return (e && e.message) || "failed";
1206
+ }
1207
+
1208
+ /* ── time window ──────────────────────────────────────────────────────── */
1209
+ // "1h" … "30d", "all", or "from:<ISO>". Remembered per browser, as a convenience.
1210
+ var RANGE = "all";
1211
+ try { RANGE = localStorage.getItem("seisin.range") || "all"; } catch (_) {}
1212
+ var SPANS = { "1h": 36e5, "4h": 4 * 36e5, "24h": 24 * 36e5, "7d": 7 * 864e5, "30d": 30 * 864e5 };
1213
+
1214
+ // Worked out on every request, so "last 4h" keeps moving while the page is open.
1215
+ function sinceIso(){
1216
+ if (SPANS[RANGE]) return new Date(Date.now() - SPANS[RANGE]).toISOString();
1217
+ if (RANGE.indexOf("from:") === 0) return RANGE.slice(5);
1218
+ return null;
1219
+ }
1220
+ function stateUrl(){
1221
+ var s = sinceIso();
1222
+ return "/api/state" + (s ? "?since=" + encodeURIComponent(s) : "");
1223
+ }
1224
+ function rangeLabel(){
1225
+ if (SPANS[RANGE]) return "last " + RANGE;
1226
+ if (RANGE.indexOf("from:") === 0) return "since " + new Date(RANGE.slice(5)).toLocaleString();
1227
+ return "all time";
1228
+ }
1229
+ function drawRange(){
1230
+ document.querySelectorAll("#range .seg button").forEach(function(b){
1231
+ b.classList.toggle("on", b.dataset.r === RANGE);
1232
+ });
1233
+ var from = document.getElementById("range-from");
1234
+ from.classList.toggle("on", RANGE.indexOf("from:") === 0);
1235
+ if (RANGE.indexOf("from:") !== 0) from.value = "";
1236
+ }
1237
+ function setRange(r){
1238
+ RANGE = r;
1239
+ try { localStorage.setItem("seisin.range", r); } catch (_) {}
1240
+ drawRange();
1241
+ reload().catch(function(){ /* server went away: keep what is on screen */ });
1242
+ }
1243
+ document.querySelectorAll("#range .seg button").forEach(function(b){
1244
+ b.addEventListener("click", function(){ setRange(b.dataset.r); });
1245
+ });
1246
+ document.getElementById("range-from").addEventListener("change", function(e){
1247
+ var t = Date.parse(e.target.value); // datetime-local is local time, which Date.parse reads as such
1248
+ setRange(isNaN(t) ? "all" : "from:" + new Date(t).toISOString());
1249
+ });
1250
+
1088
1251
  async function boot(){
1089
- let s = null;
1252
+ let s = null, stale = false;
1090
1253
  try {
1091
- const r = await fetch("/api/state", { cache: "no-store" });
1254
+ const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
1092
1255
  if (r.ok) s = await r.json();
1256
+ // A live server that refuses this tab's token: `seisin ui` was restarted
1257
+ // and minted a new one. Falling back to the example here showed a policy
1258
+ // that is not the reader's, one F5 after a restart, with nothing saying why.
1259
+ stale = r.status === 403;
1093
1260
  } catch (_) { /* opened as a file: stay in demo */ }
1094
1261
 
1095
1262
  const label = document.getElementById("source");
1263
+ if (stale) {
1264
+ label.textContent = "link from an earlier run";
1265
+ label.style.color = "var(--amber)";
1266
+ document.querySelector("main").innerHTML =
1267
+ '<div class="box" style="padding:22px 24px;max-width:640px">' +
1268
+ '<h2 style="margin:0 0 8px;font-size:17px">This tab has the link from an earlier run</h2>' +
1269
+ '<p style="margin:0 0 8px">`seisin ui` was restarted, and every run gets a new token. ' +
1270
+ 'Open the new link it printed in the terminal (it starts with ' +
1271
+ '<code>http://127.0.0.1:' + location.port + '/#t=</code>).</p>' +
1272
+ '<p style="margin:0;color:var(--mid)">Nothing here is shown until then, so this page never ' +
1273
+ 'passes the example policy off as yours.</p></div>';
1274
+ return;
1275
+ }
1096
1276
  if (!s || s.error) {
1097
- label.textContent = "example policy — not your repo";
1277
+ label.textContent = location.protocol.startsWith("http") && !TOKEN
1278
+ ? "no token — open the link `seisin ui` printed · showing an example"
1279
+ : "example policy — not your repo";
1098
1280
  label.style.color = "var(--amber)";
1099
1281
  draw();
1100
1282
  return;
@@ -1107,7 +1289,13 @@ function draw(){
1107
1289
  writes: r.writes.slice(),
1108
1290
  keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
1109
1291
  sandbox: true,
1110
- blocks: r.blocks
1292
+ blocks: r.blocks,
1293
+ // The three role keys that change the profile, drawn from what the
1294
+ // server says rather than typed into this page (review of 2026-09-22, #5).
1295
+ neverWrites: r.neverWrites || [],
1296
+ localBinding: !!r.localBinding,
1297
+ localPorts: (r.localPorts || []).slice(),
1298
+ trustd: !!r.trustd
1111
1299
  }));
1112
1300
  LOG = fromServer(s.log);
1113
1301
  REQS = s.requests || [];
@@ -1131,6 +1319,8 @@ function draw(){
1131
1319
  // everything" against a real repo is not a preview. `dataset.live` is what `show()`
1132
1320
  // reads, so the router never brings it back when you return to Roles.
1133
1321
  document.getElementById("kill").dataset.live = "1";
1322
+ document.getElementById("range").hidden = false;
1323
+ drawRange();
1134
1324
  document.getElementById("kill").hidden = true;
1135
1325
  document.getElementById("toml-note").textContent = "the file on disk, as it stands";
1136
1326
  document.getElementById("foot-mode").textContent = "Live.";
@@ -1227,7 +1417,7 @@ function route(){ return (location.hash || "#requests").slice(1); }
1227
1417
 
1228
1418
  async function refreshLog(){
1229
1419
  try {
1230
- const r = await fetch("/api/state", { cache: "no-store" });
1420
+ const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
1231
1421
  if (!r.ok) return;
1232
1422
  const s = await r.json();
1233
1423
  if (s.error) return;
@@ -1250,7 +1440,7 @@ function route(){ return (location.hash || "#requests").slice(1); }
1250
1440
 
1251
1441
  /** Everything again, after a decision rewrote the policy on disk. */
1252
1442
  async function reload(){
1253
- const r = await fetch("/api/state", { cache: "no-store" });
1443
+ const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
1254
1444
  const s = await r.json();
1255
1445
  if (s.error) return;
1256
1446
  KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
@@ -1262,8 +1452,11 @@ function route(){ return (location.hash || "#requests").slice(1); }
1262
1452
  }));
1263
1453
  LOG = fromServer(s.log);
1264
1454
  REQS = s.requests || [];
1455
+ CAUSES = s.causes || null;
1456
+ WALLS = s.walls || {};
1265
1457
  ONDISK = s.toml || null;
1266
1458
  draw();
1459
+ drawKpis();
1267
1460
  }
1268
1461
 
1269
1462
  boot();