seisin 0.4.2 → 0.5.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.
Files changed (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
package/ui/index.html CHANGED
@@ -10,7 +10,7 @@
10
10
  16px, where a favicon actually lives. Also in ui/favicon.svg for anyone who wants
11
11
  the file; inline here so the console needs no second route to serve it. -->
12
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
- <meta name="description" content="Which folders each agent writes, which keys it may read, and whose a path is when the answer is no.">
13
+ <meta name="description" content="Which territory each role 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>
16
16
  /* Nothing moves on its own: fixed widths on the columns that change,
@@ -19,8 +19,11 @@
19
19
  :root{
20
20
  --bg:#fff; --panel:#fff; --side:#f6f8fa;
21
21
  --line:#d1d9e0; --line2:#d8dee4;
22
- --fg:#1f2328; --mid:#59636e; --dim:#818b98;
23
- --green:#1a7f37; --amber:#9a6700; --blue:#0969da;
22
+ /* --dim carries informative text at 10–12px, so it is held to AA (4.5:1) on every surface it
23
+ sits on — white, the sidebar and a hovered row. It was #818b98: 3.45:1 on white. */
24
+ --fg:#1f2328; --mid:#59636e; --dim:#656d76;
25
+ --green:#1a7f37; --amber:#8a5a00; --blue:#0969da; --on-blue:#fff;
26
+ --amber-bg:#fff8c5;
24
27
  --hover:#eaeef2; --active:#ddf4ff;
25
28
  /* Declared, and it was not: `.req .path` asked for `var(--mono)` against a variable that
26
29
  never existed, so the one place showing a requested path fell back to the body sans —
@@ -34,16 +37,18 @@
34
37
  @media (prefers-color-scheme:dark){ :root:not([data-theme="light"]){
35
38
  --bg:#0e1116; --panel:#161b22; --side:#10151c;
36
39
  --line:#262d38; --line2:#30363d;
37
- --fg:#e6edf3; --mid:#8b949e; --dim:#6e7681;
38
- --green:#3fb950; --amber:#d29922; --blue:#58a6ff;
40
+ --fg:#e6edf3; --mid:#9ea7b3; --dim:#848d97;
41
+ --green:#3fb950; --amber:#d29922; --blue:#58a6ff; --on-blue:#0e1116;
42
+ --amber-bg:#2b2111;
39
43
  --hover:#1c2430; --active:#1f2d3d;
40
44
  color-scheme:dark;
41
45
  }}
42
46
  :root[data-theme="dark"]{
43
47
  --bg:#0e1116; --panel:#161b22; --side:#10151c;
44
48
  --line:#262d38; --line2:#30363d;
45
- --fg:#e6edf3; --mid:#8b949e; --dim:#6e7681;
46
- --green:#3fb950; --amber:#d29922; --blue:#58a6ff;
49
+ --fg:#e6edf3; --mid:#9ea7b3; --dim:#848d97;
50
+ --green:#3fb950; --amber:#d29922; --blue:#58a6ff; --on-blue:#0e1116;
51
+ --amber-bg:#2b2111;
47
52
  --hover:#1c2430; --active:#1f2d3d;
48
53
  color-scheme:dark;
49
54
  }
@@ -86,7 +91,14 @@ button.btn{font:inherit;font-size:12px;font-weight:600;cursor:pointer;padding:6p
86
91
  border:1px solid var(--line2);background:var(--panel);color:var(--fg)}
87
92
  button.btn:hover{background:var(--hover)}
88
93
  button.btn.warn{border-color:var(--amber);color:var(--amber)}
89
- button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
94
+ /* The consequential action is the filled one. Decline was amber and Grant plain, so the eye
95
+ went to the safe button and read the one that edits the policy as secondary. */
96
+ button.btn.primary{background:var(--blue);border-color:var(--blue);color:var(--on-blue)}
97
+ button.btn.primary:hover{filter:brightness(1.08);background:var(--blue)}
98
+ button.btn.armed{background:var(--amber-bg);border-color:var(--amber);color:var(--fg)}
99
+ button.btn:disabled{opacity:.5;cursor:not-allowed}
100
+ button:focus-visible,a:focus-visible,select:focus-visible,input:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
101
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
90
102
 
91
103
  .cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
92
104
  /* Time window: quick presets plus a free "from". Filters what comes from the log. */
@@ -158,7 +170,8 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
158
170
  .box>header .note{font-size:11px;color:var(--dim)}
159
171
  .box>header .bulk{margin-left:auto;display:flex;gap:6px;align-items:center}
160
172
  .box>header .bulk[hidden]{display:none}
161
- .box>header .bulk input{font-size:12px;padding:4px 6px;min-width:180px}
173
+ .box>header .bulk input{font:inherit;font-size:12px;padding:5px 8px;min-width:230px;border:1px solid var(--line2);
174
+ border-radius:6px;background:var(--bg);color:var(--fg)}
162
175
 
163
176
  .scrollx{overflow-x:auto}
164
177
  table{width:100%;border-collapse:collapse;font-size:13px;min-width:720px}
@@ -204,7 +217,7 @@ pre .c{color:var(--dim)} pre .k{color:var(--blue)} pre .d{color:var(--amber)} pr
204
217
  .ev{display:flex;align-items:baseline;gap:8px;padding:9px 14px;border-bottom:1px solid var(--line)}
205
218
  .ev:last-child{border-bottom:0}
206
219
  .ev b{font-weight:600;font-size:12px;flex:0 0 auto;width:52px}
207
- .ev.deny b{color:var(--amber)} .ev.allow b{color:var(--green)} .ev.admin b{color:var(--blue)}
220
+ .ev.deny b{color:var(--amber)} .ev.allow b{color:var(--green)} .ev.admin b{color:var(--blue)} .ev.observed b{color:var(--dim)}
208
221
  .ev .body{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px}
209
222
  .ev .what{font:11px ui-monospace,SFMono-Regular,monospace;color:var(--dim);
210
223
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -297,6 +310,135 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
297
310
  .req input:focus{outline:2px solid var(--blue);outline-offset:1px}
298
311
  .req .empty{color:var(--dim);padding:18px 16px}
299
312
  .asked{font-variant-numeric:tabular-nums}
313
+
314
+ /* Control files. The one place on Roles that writes — so it never writes on the click
315
+ that changes it: the toggle asks the server what the profile would become, the
316
+ answer is shown, and only the second, separate button saves. */
317
+ .ctl{display:flex;flex-direction:column}
318
+ .ctl-row{display:flex;gap:14px;align-items:center;justify-content:space-between;padding:12px 16px;
319
+ border-top:1px solid var(--line)}
320
+ .ctl-row:first-child{border-top:0}
321
+ .ctl-row.glob{background:var(--side)}
322
+ .ctl-row .lbl{min-width:0}
323
+ .ctl-row .lbl b{font-weight:600;font-size:13px}
324
+ .ctl-row .lbl code{font-family:var(--mono);font-size:11.5px;color:var(--mid)}
325
+ .ctl-row .lbl p{margin:2px 0 0;font-size:12px;color:var(--mid);max-width:70ch}
326
+ button.sw[data-state="shut"]{color:var(--green)} button.sw[data-state="shut"] i{background:var(--green)}
327
+ button.sw[data-state="open"]{color:var(--amber);border-color:var(--amber)} button.sw[data-state="open"] i{background:var(--amber)}
328
+ button.sw:disabled{opacity:.45;cursor:not-allowed}
329
+ button.sw:disabled:hover{background:var(--bg)}
330
+ .preview{border-top:1px solid var(--line);padding:14px 16px;background:var(--side);display:flex;flex-direction:column;gap:10px}
331
+ .preview[hidden]{display:none}
332
+ .preview h3{margin:0;font-size:13px;font-weight:600}
333
+ .preview p{margin:0;font-size:12px;color:var(--mid)}
334
+ .preview p b{color:var(--fg)}
335
+ .preview code{font-family:var(--mono);font-size:11.5px;color:var(--fg)}
336
+ .diff{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
337
+ .diff li{display:grid;grid-template-columns:14px 1fr;gap:6px;font-family:var(--mono);font-size:11.5px}
338
+ .diff li span{overflow-wrap:anywhere;color:var(--fg)}
339
+ .diff li em{display:block;font-family:ui-sans-serif,system-ui,sans-serif;font-style:normal;color:var(--dim);font-size:11px}
340
+ .diff li.open b{color:var(--amber)} .diff li.shut b{color:var(--green)}
341
+ .preview .act{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
342
+ .preview input{font:inherit;font-size:12.5px;padding:6px 9px;border:1px solid var(--line);border-radius:6px;
343
+ background:var(--bg);color:var(--fg);width:min(40vw,260px)}
344
+ .preview .err{color:var(--amber)}
345
+
346
+ /* ── added with the review of 2026-09-30 ────────────────────────────────── */
347
+
348
+ /* One sentence on top that says which page this is. The example and a real repo look the
349
+ same otherwise, and an offline tab looks exactly like a live one. */
350
+ .banner{border:1px solid var(--line);border-left:3px solid var(--blue);border-radius:8px;
351
+ padding:10px 14px;font-size:13px;line-height:1.55;background:var(--panel)}
352
+ .banner[hidden]{display:none}
353
+ section[data-view]{display:flex;flex-direction:column;gap:14px}
354
+ section[data-view][hidden]{display:none}
355
+ .places li em{font-family:ui-sans-serif,system-ui,sans-serif}
356
+ .banner.off{border-left-color:var(--amber);background:var(--amber-bg)}
357
+ .banner code,.notice code,.req code{font-family:var(--mono);font-size:12px}
358
+ .banner b{font-weight:600}
359
+
360
+ /* What a decision did, left on screen until the next one — not a toast that is gone before
361
+ it is read. */
362
+ .notice{display:flex;gap:10px;align-items:flex-start;justify-content:space-between;
363
+ padding:10px 16px;border-bottom:1px solid var(--line);background:var(--side);font-size:12.5px}
364
+ .notice[hidden]{display:none}
365
+ .notice .txt{min-width:0;overflow-wrap:anywhere}
366
+ .notice .ln{display:block;margin-top:4px;font-family:var(--mono);font-size:11.5px;color:var(--mid)}
367
+ .notice a{color:var(--blue)}
368
+ .notice button{flex:none;border:0;background:none;color:var(--mid);cursor:pointer;font:inherit;font-size:12px;text-decoration:underline}
369
+
370
+ /* A request: who, what was asked, what granting covers — in that order. */
371
+ .req .scope{font-size:12.5px;margin-top:4px;color:var(--fg)}
372
+ .req .scope.wide>b{color:var(--amber)}
373
+ .req .scope .own{display:block;color:var(--mid);font-size:12px;margin-top:2px}
374
+ .req .err{font-size:12px;color:var(--amber);margin-top:4px}
375
+ .req .err[hidden]{display:none}
376
+ .req input[aria-invalid="true"]{border-color:var(--amber)}
377
+
378
+ /* Denied: tabs, and the parts that are not paths. */
379
+ .tabs{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
380
+ .tabs a{font-size:12px;padding:3px 10px;border-radius:4px;color:var(--mid);text-decoration:none}
381
+ .tabs a[aria-selected="true"]{background:var(--panel);color:var(--fg);box-shadow:0 1px 2px #0002}
382
+ .showall{margin-top:12px;font:inherit;font-size:12px;border:0;background:none;color:var(--blue);cursor:pointer;padding:0;text-decoration:underline}
383
+ .other{margin-top:20px;border-top:1px solid var(--line);padding-top:14px}
384
+ .other h3{font-size:12px;font-weight:600;margin:0 0 2px}
385
+ .other p{font-size:12px;color:var(--mid);margin:0 0 8px}
386
+
387
+ /* Roles, read-only when live. */
388
+ .ro{font-size:11px;color:var(--mid)}
389
+ td.role button{font:inherit;font-weight:500;color:inherit;background:none;border:0;padding:0;cursor:pointer;
390
+ display:inline-flex;align-items:center;gap:8px;text-align:left}
391
+ td.role button i{font-style:normal;font-size:11px;color:var(--dim)}
392
+ tr.g td.role i{color:var(--green)} tr.a td.role i{color:var(--amber)}
393
+ .state{font-size:12px;color:var(--green);display:inline-flex;align-items:center;gap:6px}
394
+ .state i{width:7px;height:7px;border-radius:50%;background:var(--green)}
395
+
396
+ /* The control-files preview: grouped, and the exact line. */
397
+ .diff li.shut b,.diff li.shut .cnt{color:var(--amber)}
398
+ .diff li.open b,.diff li.open .cnt{color:var(--blue)}
399
+ .diff details{margin-top:4px}
400
+ .diff summary{cursor:pointer;color:var(--mid);font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px}
401
+ .diff details ul{margin:4px 0 0;padding-left:14px;color:var(--mid)}
402
+ .tomlline{margin:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--bg);
403
+ font:11.5px/1.6 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;min-height:0}
404
+ .tomlline .d{color:var(--amber)} .tomlline .g{color:var(--green)}
405
+ pre mark{background:var(--amber-bg);color:var(--fg);outline:1px solid var(--amber);border-radius:2px}
406
+
407
+ /* KPIs: full cards on the landing view, a strip everywhere else. */
408
+ #kpis.compact{display:flex;flex-wrap:wrap;gap:8px}
409
+ #kpis.compact .card{min-height:0;padding:6px 12px;flex-direction:row;align-items:baseline;gap:8px;flex:1 1 150px}
410
+ #kpis.compact .card .n{font-size:16px;margin-top:0;order:-1;min-width:0}
411
+ #kpis.compact .card .s{display:none}
412
+ #kpis.compact .card h2{font-size:10px}
413
+ .range .custom[hidden]{display:none}
414
+ .range[hidden]{display:none}
415
+ .range label{display:inline-flex;align-items:center;gap:6px}
416
+
417
+ @media (max-width:720px){
418
+ /* A request stacks: the sentence gets the width, the actions go under it at full width. */
419
+ .req{flex-direction:column;align-items:stretch}
420
+ .req .act{justify-content:stretch}
421
+ .req input{width:100%;flex:1 1 100%}
422
+ .req .act .btn{flex:1 1 0}
423
+ .box>header{flex-wrap:wrap}
424
+ .box>header .note{flex-basis:100%}
425
+ .box>header .bulk{margin-left:0;flex-basis:100%;flex-wrap:wrap}
426
+ .box>header .bulk input{flex:1 1 100%;min-width:0}
427
+ .box>header .bulk .btn{flex:1 1 100%}
428
+ /* The roles table becomes one card per role: a column name beside each value. */
429
+ table.roles{min-width:0}
430
+ table.roles thead{display:none}
431
+ table.roles tr{display:block;border-bottom:1px solid var(--line);padding:6px 0}
432
+ table.roles tbody tr:last-child{border-bottom:0}
433
+ table.roles td{display:grid;grid-template-columns:76px minmax(0,1fr);gap:8px;border:0;padding:4px 14px;width:auto;text-align:left}
434
+ table.roles td::before{content:attr(data-label);font-size:10px;font-weight:600;letter-spacing:.06em;
435
+ text-transform:uppercase;color:var(--dim);padding-top:3px}
436
+ #kpis.compact{flex-wrap:nowrap;overflow-x:auto}
437
+ #kpis.compact .card{flex:0 0 auto}
438
+ .ctl-row{flex-direction:column;align-items:flex-start}
439
+ .preview input{width:100%}
440
+ .places li{flex-direction:column;gap:2px}
441
+ }
300
442
  </style>
301
443
  </head>
302
444
  <body>
@@ -304,7 +446,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
304
446
 
305
447
  <nav class="side">
306
448
  <div class="brand">
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>
449
+ <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" role="status">folders and keys, per agent</small></div></div>
308
450
  <div class="theme" role="group" aria-label="Theme">
309
451
  <button type="button" id="t-light">light</button>
310
452
  <button type="button" id="t-dark">dark</button>
@@ -313,13 +455,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
313
455
  </div>
314
456
  <div class="list">
315
457
  <div class="cap">Policy</div>
316
- <a class="on" href="#requests" id="nav-req" aria-current="page"><i>⚖</i>Waiting on you<span class="pin" id="req-pin"></span></a>
317
- <a href="#denied"><i>⊘</i>Denied<span class="pin" id="dn-pin"></span></a>
318
- <a href="#walls"><i>▦</i>Walls<span class="pin" id="wl-pin"></span></a>
319
- <a href="#policy"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
320
- <a href="#activity"><i>◷</i>Activity</a>
321
- <a href="#configfile"><i>⚙</i>Config file</a>
458
+ <a class="on" href="#requests" id="nav-req" aria-current="page" title="Requests: access an agent asked for and cannot have"><i>⚖</i>Waiting on you<span class="pin" id="req-pin"></span></a>
459
+ <a href="#denied" title="Every denial in the window: what an agent tried and the OS denied"><i>⊘</i>Denied<span class="pin" id="dn-pin"></span></a>
460
+ <a href="#walls" title="A wall: something a role was denied twice or more and would still be denied today"><i>▦</i>Walls<span class="pin" id="wl-pin"></span></a>
461
+ <a href="#policy" title="Each role's territory (the folders it may write) and the keys it may read"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
462
+ <a href="#activity" title="The log: every entry, newest first"><i>◷</i>Activity</a>
463
+ <a href="#configfile" title="seisin.toml, the policy file"><i>⚙</i>Config file</a>
322
464
  <div class="cap">Docs</div>
465
+ <a href="https://github.com/carlostapiacl/seisin#readme" target="_blank" rel="noopener"><i>↗</i>seisin README</a>
466
+ <a href="https://github.com/carlostapiacl/seisin/blob/main/docs/glossary.md" target="_blank" rel="noopener"><i>↗</i>Glossary</a>
323
467
  <a href="https://github.com/anthropics/sandbox-runtime" target="_blank" rel="noopener"><i>↗</i>sandbox-runtime</a>
324
468
  </div>
325
469
  </nav>
@@ -329,48 +473,55 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
329
473
  24 roles, and "Activity" meant "keep going". Each of these answers a different
330
474
  question and none of them is a footnote to Roles, so each gets the screen. -->
331
475
  <main>
476
+ <!-- Which page this is. Opened as a file it is an example with nothing behind it; a live tab
477
+ whose server went away looked exactly like one that was still answering. -->
478
+ <div class="banner" id="demo-banner" hidden>
479
+ <b>Preview with example data — nothing here is saved.</b>
480
+ seisin gives each AI coding agent its own folders and keys, has the OS enforce them, and
481
+ when it says no, tells you whose file it was. To see your own repository, run
482
+ <code>seisin ui</code> in it.
483
+ </div>
484
+ <div class="banner off" id="off-banner" role="alert" hidden></div>
485
+
332
486
  <div class="head">
333
487
  <div>
334
488
  <h1 id="v-title">Roles</h1>
335
- <p id="v-sub">Which folders each agent writes, which keys it may read, and whether it runs sandboxed.</p>
489
+ <p id="v-sub">Which territory each role writes, and which keys it may read.</p>
336
490
  </div>
337
- <button class="btn warn" id="kill" type="button">Unsandbox everything</button>
491
+ <button class="btn warn" id="kill" type="button">Run every role unconfined</button>
338
492
  </div>
339
493
 
340
- <!-- OUTSIDE the views, so the same five numbers are on every screen.
341
- They used to live inside Roles, which meant the only live one —
342
- denials — was invisible from the three other pages, and four of the
343
- five were constants: 31 roles, 31 sandboxed, 0 unsandboxed, 31 with no
344
- keys. A number that reads the same every day is not an indicator, it
345
- is a caption, and putting it beside a live one teaches people to stop
346
- reading the row.
347
-
348
- Each one is a link to the screen that explains it. A number you cannot
349
- act on is decoration; a number that takes you to the thing it counts
350
- is the top of a path. -->
494
+ <!-- The window filters what is read from the log. Not the request queue: that is what is
495
+ waiting now, whatever the window. -->
351
496
  <div class="range" id="range" hidden>
352
- <span>Window</span>
353
- <div class="seg" role="group" aria-label="Time window">
497
+ <span id="range-lbl">Window</span>
498
+ <div class="seg" role="group" aria-labelledby="range-lbl">
354
499
  <button type="button" data-r="1h">1h</button>
355
500
  <button type="button" data-r="4h">4h</button>
356
501
  <button type="button" data-r="24h">24h</button>
357
502
  <button type="button" data-r="7d">7d</button>
358
503
  <button type="button" data-r="30d">30d</button>
359
- <button type="button" data-r="all">All</button>
504
+ <button type="button" data-r="all" class="on">All</button>
505
+ <button type="button" data-r="custom" id="range-custom">From a date…</button>
360
506
  </div>
361
- <label>from <input type="datetime-local" id="range-from" aria-label="From"></label>
507
+ <label class="custom" id="range-from-box" hidden>since <input type="datetime-local" id="range-from"></label>
362
508
  </div>
363
509
 
510
+ <!-- Each number is a link to the screen that explains it. Full cards on the landing view,
511
+ a strip on the others: the same five cards on every screen pushed every view's own
512
+ content below the fold, on a phone by a whole screen. -->
364
513
  <div class="cards" id="kpis">
365
514
  <a class="card kpi" href="#requests" id="k-wait">
366
515
  <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>
367
- <a class="card kpi" href="#policy" id="k-open">
368
- <h2>unsandboxed</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">none are open</div></a>
516
+ <!-- Example only: seisin.toml cannot express an unconfined role, so live this card would
517
+ always say 0 and is removed with the what-if button. -->
518
+ <a class="card kpi" href="#policy" id="k-open" title="Roles run without a sandbox in this example: nothing enforces their territory">
519
+ <h2>unconfined</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">every role confined</div></a>
369
520
  <a class="card kpi" href="#denied" id="k-ref">
370
521
  <h2>denied</h2><div class="n" id="c-blocks">0</div><div class="s" id="c-blocks-s">in this window</div></a>
371
- <a class="card kpi" href="#causes" id="k-conc">
372
- <h2>one cause</h2><div class="n" id="c-conc">—</div><div class="s" id="c-conc-s">of all denials</div></a>
373
- <a class="card kpi" href="#walls" id="k-retry">
522
+ <a class="card kpi" href="#causes" id="k-conc" title="How much of the window's denials share one file name">
523
+ <h2>top cause</h2><div class="n" id="c-conc">—</div><div class="s" id="c-conc-s">of all denials</div></a>
524
+ <a class="card kpi" href="#walls" id="k-retry" title="Calls made after the first denial of the same thing">
374
525
  <h2>spent retrying</h2><div class="n" id="c-retry">0</div><div class="s" id="c-retry-s">calls on known walls</div></a>
375
526
  </div>
376
527
 
@@ -379,20 +530,21 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
379
530
  <div class="box">
380
531
  <header id="policy">
381
532
  <h2>Policy</h2>
533
+ <span class="note ro" id="roles-note"></span>
382
534
  <!-- The filter takes a path as readily as a role name, because "whose is this path"
383
535
  is the question the CLI answers with `seisin whose` and the console could not
384
536
  ask at all. A map you cannot query is a poster. -->
385
537
  <div class="filter">
386
538
  <label class="note" for="f">filter</label>
387
539
  <input id="f" type="search" spellcheck="false" autocomplete="off"
388
- placeholder="a role, or a path like src/api" aria-label="Filter roles by name or path">
540
+ placeholder="a role, or a path like src/api">
389
541
  <span class="hits" id="f-hits" aria-live="polite"></span>
390
542
  </div>
391
543
  </header>
392
544
  <div class="scrollx">
393
- <table>
545
+ <table class="roles">
394
546
  <thead><tr>
395
- <th>role</th><th class="terr">writes</th><th>keys</th><th class="st">sandbox</th><th class="num">blocks</th>
547
+ <th>role</th><th class="terr" title="Territory: the paths a role may write">writes (territory)</th><th title="Credentials the role may read">keys</th><th class="st" title="Whether the OS enforces this role's territory">confined</th><th class="num">denials</th>
396
548
  </tr></thead>
397
549
  <tbody id="rows"></tbody>
398
550
  </table>
@@ -400,70 +552,75 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
400
552
  </div>
401
553
  </div>
402
554
 
555
+ <!-- Policy-wide first, then per role. The one global switch used to sit inside the
556
+ selected role's box, so turning it on read as a change to that role alone. -->
557
+ <div class="box">
558
+ <header><h2>Policy-wide</h2><span class="note">applies to every role</span></header>
559
+ <div class="ctl" id="pw"></div>
560
+ <div class="preview" id="pw-preview" hidden aria-live="polite"></div>
561
+ <div class="notice" id="pw-done" role="status" hidden></div>
562
+ </div>
563
+
564
+ <div class="box">
565
+ <header><h2 id="ctl-title">Control files</h2><span class="note" id="ctl-note" title="Control files: files an editor or the next agent session acts on — editor settings and instruction files">files an editor or the next agent session acts on — protected unless the policy hands them over</span></header>
566
+ <div class="ctl" id="ctl"></div>
567
+ <div class="preview" id="ctl-preview" hidden aria-live="polite"></div>
568
+ <div class="notice" id="ctl-done" role="status" hidden></div>
569
+ </div>
570
+
403
571
  <!-- The generated settings stay with Roles: they are not a fifth subject, they are what
404
- the row you clicked resolves to. Splitting them off would leave a view whose whole
405
- content depends on a selection made somewhere you can no longer see. -->
572
+ the row you clicked resolves to. -->
406
573
  <div class="box">
407
- <header><h2 id="cfg-title">.seisin/….json</h2><span class="note">what seisin hands the runtime for the selected role</span></header>
574
+ <header><h2 id="cfg-title">.seisin/….json</h2><span class="note" id="cfg-note">what seisin hands the runtime for the selected role</span></header>
408
575
  <pre id="cfg"></pre>
409
576
  </div>
410
577
  </section>
411
578
 
412
- <!-- One view per KPI. A number you can click is the top of a path; a number
413
- you cannot is decoration, and the five up there all pointed at two
414
- pages. Each of these is the detail of exactly one of them. -->
415
-
416
579
  <section data-view="requests" hidden>
417
580
  <div class="box" id="requests">
418
581
  <header>
419
582
  <h2>Pending decisions</h2>
420
- <span class="note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
583
+ <span class="note" id="req-note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
421
584
  <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>
585
+ <input id="why-all" placeholder="reason, for all of them (required)" aria-label="Reason for declining all">
586
+ <button class="btn" id="decline-all" type="button" data-write>Decline all</button>
424
587
  </span>
425
588
  </header>
589
+ <div class="notice" id="decided" role="status" hidden></div>
426
590
  <div id="reqs"></div>
427
591
  </div>
428
592
  </section>
429
593
 
430
- <!-- KPI: denied. Grouped by WHAT was denied, never by who asked — per role
431
- it is the same list a hundred times over. -->
594
+ <!-- Denied, by path or by name: one view with two cuts. "Causes" was a view the nav never
595
+ listed, reachable only from a KPI. #causes still lands on the name cut. -->
432
596
  <section data-view="denied" hidden>
433
597
  <div class="box">
434
598
  <header>
435
- <h2>Denied, by path</h2>
599
+ <h2>Denied</h2>
600
+ <nav class="tabs" role="tablist" aria-label="Group denials">
601
+ <a href="#denied" role="tab" id="tab-path">by path</a>
602
+ <a href="#causes" role="tab" id="tab-name">by name</a>
603
+ </nav>
436
604
  <span class="note" id="dn-window"></span>
437
605
  </header>
438
- <div class="pad">
606
+ <div class="pad" id="dn-path" role="tabpanel" aria-labelledby="tab-path">
439
607
  <p class="lede" id="ca-lede"></p>
440
608
  <div class="bars" id="ca-bars"></div>
609
+ <div id="ca-more"></div>
610
+ <div id="ca-other"></div>
441
611
  </div>
442
- </div>
443
- </section>
444
-
445
- <!-- KPI: one cause. The same denials one level coarser — by the name at the
446
- end of the path. This is the cut that says whether a day was a tooling
447
- problem or a territory one, and by path alone it reads backwards. -->
448
- <section data-view="causes" hidden>
449
- <div class="box">
450
- <header>
451
- <h2>Denied, by name</h2>
452
- <span class="note">the same denials, grouped one level coarser</span>
453
- </header>
454
- <div class="pad">
612
+ <div class="pad" id="dn-name" role="tabpanel" aria-labelledby="tab-name" hidden>
455
613
  <p class="lede" id="fa-lede"></p>
456
614
  <div id="fa-list"></div>
457
615
  </div>
458
616
  </div>
459
617
  </section>
460
618
 
461
- <!-- KPI: spent retrying. -->
462
619
  <section data-view="walls" hidden>
463
620
  <div class="box">
464
621
  <header>
465
622
  <h2>Walls</h2>
466
- <span class="note">denied twice or more, and the policy still denies it</span>
623
+ <span class="note">denied twice or more, and the policy still denies it · a retry is every call after the first denial</span>
467
624
  </header>
468
625
  <div class="pad">
469
626
  <p class="lede" id="wl-lede"></p>
@@ -474,24 +631,24 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
474
631
 
475
632
  <section data-view="activity" hidden>
476
633
  <div class="box">
477
- <header><h2>Activity</h2><span class="note">every decision, with its owner</span></header>
634
+ <header><h2>Activity</h2><span class="note">newest first · each denial with whose it was</span></header>
478
635
  <div id="log"></div>
479
636
  </div>
480
637
  </section>
481
638
 
482
639
  <section data-view="config" hidden>
483
640
  <div class="box">
484
- <header id="configfile"><h2>seisin.toml</h2><span class="note" id="toml-note">copy this back into your repo</span></header>
641
+ <header id="configfile"><h2>seisin.toml</h2><span class="note" id="toml-note">generated from the example policy in this page — there is no file behind it</span></header>
485
642
  <pre id="toml"></pre>
486
643
  </div>
487
644
  </section>
488
645
 
489
646
  <!-- The footer stays outside the views: it says what mode the page is in and what the
490
- JSON is measured against, and that is true on all four. -->
647
+ JSON is measured against, and that is true on all of them. -->
491
648
  <div class="foot">
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
493
- would be generated. Copy the <b>seisin.toml</b> above to keep a change.</span><br>
494
- The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.77</b>, whose schema the test suite exercises against the real binary.
649
+ <b id="foot-mode">Preview.</b> <span id="foot-text">Opened as a file, this page edits an example policy in memory and
650
+ shows what would be generated. Nothing is saved; run <b>seisin ui</b> in a repository to see yours.</span><br>
651
+ The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.78</b>, whose schema the test suite exercises against the real binary.
495
652
  </div>
496
653
  </main>
497
654
  </div>
@@ -500,50 +657,217 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
500
657
  (function(){
501
658
  "use strict";
502
659
 
503
- let KEYS = ["netlify-token","database-url","sentry-dsn","registry-token","hetzner-ro"];
504
-
505
- let ROLES = [
506
- {name:"frontend", writes:["src/web/**","public/**"], keys:["netlify-token"], sandbox:true, blocks:3},
507
- {name:"backend", writes:["src/api/**","migrations/**"], keys:["database-url","sentry-dsn"], sandbox:true, blocks:2},
508
- {name:"docs", writes:["docs/**","README.md"], keys:[], sandbox:true, blocks:0},
509
- {name:"release", writes:[".github/**"], keys:["registry-token"], sandbox:true, blocks:1},
510
- {name:"scratch", writes:["tmp/**"], keys:[], sandbox:false, blocks:0}
511
- ];
660
+ /* ── the example ──────────────────────────────────────────────────────────
661
+ * Opened as a file there is no server, so the page shows an example. It used
662
+ * to be three unrelated lists — a policy, five log lines, and nothing else —
663
+ * so Activity showed denials while the KPIs said none and Waiting on you was
664
+ * empty. Now the example is a log and a queue, and every screen is computed
665
+ * from them by the same functions that draw the live state (demoState below).
666
+ * All of it is illustrative: the names are made up. */
667
+ var DEMO = {
668
+ keys: ["netlify-token", "database-url", "sentry-dsn", "registry-token", "cloud-token"],
669
+ roles: [
670
+ {name:"frontend", writes:["src/web/**","public/**"], keys:["netlify-token"], controlFiles:["ide"]},
671
+ {name:"backend", writes:["src/api/**","migrations/**"], keys:["database-url","sentry-dsn"]},
672
+ {name:"docs", writes:["docs/**","README.md"], keys:[]},
673
+ {name:"release", writes:[".github/**"], keys:["registry-token","cloud-token"]}
674
+ ],
675
+ // [minutes ago, role, action, target, verdict, kind, source]
676
+ log: [
677
+ [240,"backend","write","migrations/014_stock.sql","allowed"],
678
+ [200,"frontend","write","src/web/App.tsx","allowed"],
679
+ [190,"backend","write",".git/index.lock","denied"],
680
+ [188,"backend","write",".git/index.lock","denied"],
681
+ [150,"frontend","write","src/api/orders.ts","denied"],
682
+ [149,"frontend","write","src/api/orders.ts","denied"],
683
+ [140,"backend","connect","api.stripe.com:443","denied","network","kernel"],
684
+ [121,"backend","write",".env.local","denied"],
685
+ [120,"backend","write",".env.local","denied"],
686
+ [118,"backend","write",".env.local","denied"],
687
+ [95,"frontend","write","dist/index.html","denied"],
688
+ [94,"frontend","write","dist/index.html","denied"],
689
+ [80,"docs","write","CHANGELOG.md","denied"],
690
+ [70,"docs","use","mcp__github__create_issue","denied","tool"],
691
+ [61,"release","read",".secrets/database-url","denied","key"],
692
+ [60,"backend","write",".git/index.lock","denied"],
693
+ [45,"frontend","write","src/api/orders.ts","denied"],
694
+ [44,"frontend","write","src/web/Cart.tsx","allowed"],
695
+ [30,"docs","write","src/api/orders.ts","denied"],
696
+ [21,"frontend","write","scripts/deploy.sh","denied","file","kernel"],
697
+ [12,"release","write","docs/release-notes.md","denied"],
698
+ [9,"frontend","write","src/api/orders.ts","denied"],
699
+ [4,"frontend","write","src/web/Header.tsx","allowed"]
700
+ ],
701
+ requests: [
702
+ {role:"frontend", action:"write", target:"src/api/orders.ts", grant:"src/api/**", times:4, first:150},
703
+ {role:"docs", action:"write", target:"CHANGELOG.md", grant:"CHANGELOG.md", times:1, first:80},
704
+ {role:"release", action:"write", target:"docs/release-notes.md", grant:"docs/**", times:1, first:12}
705
+ ]
706
+ };
512
707
 
513
- let KEYDIR = ".secrets";
708
+ var KEYS = DEMO.keys.slice();
709
+ var ROLES = [];
710
+ var KEYDIR = ".secrets";
711
+ // `[protect]` and the hash of the policy text the console last read, which a
712
+ // control-files save sends back so a file that moved in between is refused.
713
+ var PROTECT = { instructions: false };
714
+ var BASE = null;
715
+ var ROOT = "";
514
716
  var sel = "frontend";
515
-
516
- let LOG = [
517
- {t:"21:04:12", kind:"deny", role:"frontend", act:"read", target:".secrets/database-url.txt", owners:["backend"]},
518
- {t:"21:02:58", kind:"deny", role:"docs", act:"write", target:"src/api/orders.ts", owners:["backend"]},
519
- {t:"20:58:31", kind:"allow", role:"backend", act:"write", target:"migrations/014_stock.sql", owners:[]},
520
- {t:"20:51:07", kind:"deny", role:"frontend", act:"write", target:"scripts/deploy.sh", owners:[], source:"kernel"},
521
- {t:"20:47:44", kind:"admin", role:"you", act:"unsandboxed", target:"scratch", owners:[]}
522
- ];
717
+ var LOG = [];
523
718
 
524
719
  function esc(s){return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
525
720
  // For attribute values: the text an agent chose (a request's id is its path)
526
721
  // must not be able to close the quote it sits in.
527
722
  function escA(s){return esc(s).replace(/"/g,"&quot;").replace(/'/g,"&#39;");}
528
723
  function pad(n){return String(n).padStart(2,"0");}
724
+ function plural(n, one, many){ return n.toLocaleString() + " " + (n === 1 ? one : (many || one + "s")); }
725
+
726
+ /** "3 min ago · 14:02", with the exact instant in the tooltip. */
727
+ function when(iso){
728
+ var t = Date.parse(iso);
729
+ if (isNaN(t)) return { text: "", title: "" };
730
+ var d = new Date(t), s = Math.max(0, (Date.now() - t) / 1000);
731
+ var ago = s < 45 ? "just now" : s < 3600 ? Math.round(s / 60) + " min ago" :
732
+ s < 86400 ? Math.round(s / 3600) + " h ago" : Math.round(s / 86400) + " d ago";
733
+ var clock = pad(d.getHours()) + ":" + pad(d.getMinutes());
734
+ if (s >= 86400) clock = d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + clock;
735
+ return { text: ago + " · " + clock, title: d.toISOString() };
736
+ }
529
737
 
530
738
  function theme(v){
531
739
  var r=document.documentElement;
532
740
  if(v==="auto") r.removeAttribute("data-theme"); else r.setAttribute("data-theme",v);
533
741
  [["light","light"],["dark","dark"],["auto","auto"]].forEach(function(p){
534
- document.getElementById("t-"+p[0]).classList.toggle("on", v===p[1]);
742
+ var b=document.getElementById("t-"+p[0]);
743
+ b.classList.toggle("on", v===p[1]);
744
+ b.setAttribute("aria-pressed", v===p[1] ? "true" : "false");
535
745
  });
536
746
  }
537
747
  document.getElementById("t-light").onclick=function(){theme("light");};
538
748
  document.getElementById("t-dark").onclick=function(){theme("dark");};
539
749
  document.getElementById("t-auto").onclick=function(){theme("auto");};
540
750
 
541
- function note(kind,act,target,owners){
542
- var d=new Date();
543
- LOG.unshift({t:pad(d.getHours())+":"+pad(d.getMinutes())+":"+pad(d.getSeconds()),
544
- kind:kind, role:"you", act:act, target:target, owners:owners||[]});
545
- LOG=LOG.slice(0,6);
751
+ /** A change made in the example, written into the example's log. */
752
+ function note(act, target){
753
+ DEMO.log.push([new Date(), "you", act, target, "admin"]);
754
+ demoRefresh();
755
+ }
756
+
757
+ /* ── the example, computed the way the server computes the live state ── */
758
+
759
+ /** What kind of thing an unowned path is — kinds.js's own sentences, word for word. */
760
+ var KIND_HINTS = {
761
+ credential: "credential-shaped: never grant a write to it; if a role needs to read it, declare it as a key",
762
+ git: "git's own bookkeeping, not territory: grant the repository to the role that runs git there, never the file",
763
+ temporary: "temporary files a tool or a test makes while it runs: point it at $TMPDIR (already writable through [runtime] writes), or fix it; do not grant it in the repo",
764
+ build: "build output or installed dependencies: grant the folder to the role that builds it, or build outside the repo",
765
+ territory: "an ownable path no role claims: a real decision — give it an owner, or tell the agent not to write there"
766
+ };
767
+ function demoKind(p){
768
+ var name = p.split("/").pop();
769
+ if (p.indexOf(KEYDIR + "/") === 0 || /^\.env(\..+)?$/.test(name)) return "credential";
770
+ if (/(^|\/)\.git(\/|$)/.test(p)) return "git";
771
+ if (/(^|\/)(tmp|\.cache)\//.test(p) || /\.(tmp|log|lock)$/.test(name)) return "temporary";
772
+ if (/(^|\/)(dist|build|node_modules)\//.test(p)) return "build";
773
+ return "territory";
774
+ }
775
+ function covers(glob, p){
776
+ if (/\/\*\*$/.test(glob)) { var d = glob.slice(0, -3); return p === d || p.indexOf(d + "/") === 0; }
777
+ return glob === p;
546
778
  }
779
+ function ownersIn(roles, p){ return roles.filter(function(r){ return r.writes.some(function(g){ return covers(g, p); }); }).map(function(r){ return r.name; }); }
780
+
781
+ function demoState(){
782
+ var now = Date.now();
783
+ var roles = ROLES.length ? ROLES : DEMO.roles.map(function(r){ return Object.assign({ sandbox: true, controlFiles: [] }, r, { writes: r.writes.slice(), keys: r.keys.slice() }); });
784
+ var entries = DEMO.log.map(function(x){
785
+ var about = x[5] || "file";
786
+ return { at: new Date(x[0] instanceof Date ? x[0] : now - x[0] * 6e4).toISOString(), role: x[1], action: x[2], target: x[3],
787
+ verdict: x[4], kind: about, source: x[6],
788
+ owners: about === "file" ? ownersIn(roles, x[3]) : [] };
789
+ });
790
+ var denied = entries.filter(function(e){ return e.verdict === "denied"; });
791
+ var still = function(role, e){
792
+ var r = roles.filter(function(x){ return x.name === role; })[0];
793
+ if (!r) return false;
794
+ if (e.kind === "key") return r.keys.indexOf(e.target.split("/").pop()) === -1;
795
+ if (e.kind !== "file") return true;
796
+ return !r.writes.some(function(g){ return covers(g, e.target); });
797
+ };
798
+ var by = {}, fams = {}, about = { file:{paths:0,denials:0}, key:{paths:0,denials:0}, network:{paths:0,denials:0}, tool:{paths:0,denials:0} };
799
+ denied.forEach(function(e){
800
+ var k = e.action + "\u0000" + e.target;
801
+ var g = by[k] || (by[k] = { about: e.kind, action: e.action, target: e.target, times: 0, roles: [], owners: e.owners });
802
+ g.times++; if (g.roles.indexOf(e.role) === -1) g.roles.push(e.role);
803
+ });
804
+ var groups = Object.keys(by).map(function(k){ return by[k]; });
805
+ var total = denied.length, unowned = 0, kinds = {};
806
+ Object.keys(KIND_HINTS).forEach(function(k){ kinds[k] = { paths: 0, denials: 0 }; });
807
+ groups.forEach(function(g){
808
+ about[g.about].paths++; about[g.about].denials += g.times;
809
+ g.share = g.times / total;
810
+ g.stillRefused = g.roles.filter(function(r){ return still(r, g); }).length;
811
+ if (g.about !== "file") { g.standing = "outside"; return; }
812
+ if (g.owners.length) { g.standing = "owned"; return; }
813
+ g.standing = "unowned"; unowned++;
814
+ g.kind = demoKind(g.target); g.hint = KIND_HINTS[g.kind];
815
+ kinds[g.kind].paths++; kinds[g.kind].denials += g.times;
816
+ });
817
+ groups.forEach(function(g){
818
+ var name = g.target.split("/").pop();
819
+ var f = fams[name] || (fams[name] = { name: name, times: 0, paths: 0, where: [], roles: [] });
820
+ f.times += g.times; f.paths++; f.where.push({ target: g.target, times: g.times });
821
+ g.roles.forEach(function(r){ if (f.roles.indexOf(r) === -1) f.roles.push(r); });
822
+ });
823
+ var families = Object.keys(fams).map(function(k){ var f = fams[k]; f.share = f.times / total; return f; })
824
+ .sort(function(a, b){ return b.times - a.times; });
825
+ groups.sort(function(a, b){ return b.times - a.times; });
826
+
827
+ var walls = {};
828
+ roles.forEach(function(r){
829
+ var mine = {};
830
+ denied.forEach(function(e){
831
+ if (e.role !== r.name) return;
832
+ var k = e.action + "\u0000" + e.target;
833
+ (mine[k] = mine[k] || { e: e, times: 0 }).times++;
834
+ });
835
+ var list = Object.keys(mine).map(function(k){ return mine[k]; })
836
+ .filter(function(w){ return w.times >= 2 && still(r.name, w.e); })
837
+ .map(function(w){
838
+ var x = { action: w.e.action, target: w.e.target, times: w.times, retried: w.times - 1,
839
+ owners: w.e.owners, about: w.e.kind, reason: "" };
840
+ if (x.about === "file" && !x.owners.length) { x.standing = "unowned"; x.kind = demoKind(x.target); x.hint = KIND_HINTS[x.kind]; }
841
+ return x;
842
+ }).sort(function(a, b){ return b.times - a.times; });
843
+ if (list.length) walls[r.name] = list;
844
+ });
845
+
846
+ return {
847
+ live: false,
848
+ allKeys: DEMO.keys,
849
+ protect: PROTECT,
850
+ roles: roles.map(function(r){
851
+ return { name: r.name, writes: r.writes.slice(), keys: r.keys.slice(), sandbox: r.sandbox !== false,
852
+ controlFiles: (r.controlFiles || []).slice(), neverWrites: [],
853
+ blocks: denied.filter(function(e){ return e.role === r.name; }).length };
854
+ }),
855
+ requests: DEMO.requests.map(function(q){
856
+ return Object.assign({ key: q.role + ":" + q.action + ":" + q.target, owners: ownersIn(roles, q.target),
857
+ last: new Date(now - q.first * 6e4).toISOString() }, q);
858
+ }),
859
+ causes: { total: total, distinct: groups.length, unowned: unowned, about: about,
860
+ standing: { unowned: { paths: unowned, kinds: kinds } },
861
+ families: families.slice(0, 8), causes: groups },
862
+ walls: walls,
863
+ log: entries.slice().reverse()
864
+ };
865
+ }
866
+
867
+ /** The example's state again, after a change made in it. */
868
+ function demoRefresh(){ if (!LIVE) applyState(demoState()); }
869
+
870
+ /* ── roles ────────────────────────────────────────────────────────────── */
547
871
 
548
872
  /** Above this many paths the territory cell folds into folder counts. Four is what fits on
549
873
  * one line at the narrowest column this table is allowed to reach. */
@@ -553,9 +877,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
553
877
  var OPEN={};
554
878
  var FILTER="";
555
879
 
556
- /** Paths grouped by the folder that holds them, biggest group first.
557
- * The group is the parent directory, with `/**` and a trailing filename removed, because
558
- * that is the unit a reader thinks in: "what does this role write under src/". */
880
+ /** Paths grouped by the folder that holds them, biggest group first. */
559
881
  function byFolder(paths){
560
882
  var m={};
561
883
  paths.forEach(function(p){
@@ -566,9 +888,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
566
888
  .sort(function(a,b){return b.n-a.n||a.dir.localeCompare(b.dir);});
567
889
  }
568
890
 
569
- /** Does this role match what was typed? Name OR any path it writes.
570
- * Matching on paths is the point: typing a path answers "whose is this", which until now
571
- * you could only ask the CLI. */
891
+ /** Does this role match what was typed? Name OR any path it writes. */
572
892
  function hits(r,q){
573
893
  if(!q) return true;
574
894
  q=q.toLowerCase();
@@ -576,6 +896,16 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
576
896
  return r.writes.some(function(p){return p.toLowerCase().indexOf(q)!==-1;});
577
897
  }
578
898
 
899
+ /**
900
+ * The roles table.
901
+ *
902
+ * Live, it is read-only. It used to offer × on every path, "+ folder" and key
903
+ * toggles against a real repo, and every one of those edits was thrown away on
904
+ * the next poll without a word — a page that looks like it saved and did not.
905
+ * Live, the only writes are a decision on a request and a control-files save;
906
+ * territories and keys are changed in seisin.toml. In the example the table
907
+ * stays editable, under a banner that says nothing is saved.
908
+ */
579
909
  function drawRows(){
580
910
  var tb=document.getElementById("rows"); tb.textContent="";
581
911
  var shown=ROLES.filter(function(r){return hits(r,FILTER);});
@@ -585,32 +915,34 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
585
915
  if(vacio) vacio.hidden=!(FILTER&&!shown.length);
586
916
  if(vacio&&FILTER&&!shown.length)
587
917
  vacio.innerHTML="No role writes anything matching <b>"+esc(FILTER)+"</b>. "+
588
- "If an agent needs it, that is a request nobody can approve yet — "+
918
+ "If an agent needs it, that is a request nobody can grant yet — "+
589
919
  "no role owns the path.";
920
+ document.getElementById("roles-note").textContent = LIVE
921
+ ? "read-only here — change territories and keys in seisin.toml"
922
+ : "example — edits stay in this page";
590
923
 
591
924
  shown.forEach(function(r){
592
925
  var tr=document.createElement("tr");
593
926
  tr.className=(r.sandbox?"g":"a")+(r.name===sel?" on":"");
594
927
 
595
- var c1=document.createElement("td");
596
- c1.className="role"; c1.innerHTML="<i>●</i>"+esc(r.name); c1.tabIndex=0;
597
- function pick(){sel=r.name;drawRows();drawCfg();}
598
- c1.onclick=pick;
599
- c1.onkeydown=function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();pick();}};
600
- tr.appendChild(c1);
928
+ var c1=document.createElement("td"); c1.className="role"; c1.setAttribute("data-label","role");
929
+ var rb=document.createElement("button"); rb.type="button";
930
+ rb.innerHTML="<i aria-hidden=\"true\">●</i>"+esc(r.name);
931
+ rb.setAttribute("aria-pressed", r.name===sel ? "true" : "false");
932
+ rb.setAttribute("aria-label", r.name+" — show its control files and sandbox settings");
933
+ rb.onclick=function(){sel=r.name;drawRows();drawCfg();drawControls();};
934
+ c1.appendChild(rb); tr.appendChild(c1);
601
935
 
602
- var c2=document.createElement("td"); c2.className="terr";
936
+ var c2=document.createElement("td"); c2.className="terr"; c2.setAttribute("data-label","writes");
603
937
  var t2=document.createElement("div"); t2.className="tags";
604
938
  function tagFor(p,i){
605
939
  var s=document.createElement("span"); s.className="tag w"; s.textContent=p;
940
+ if (LIVE) return s;
606
941
  var x=document.createElement("button"); x.type="button"; x.className="x"; x.textContent="×";
607
942
  x.setAttribute("aria-label","Remove "+p+" from "+r.name);
608
- x.onclick=function(){r.writes.splice(i,1);note("admin","revoked folder",r.name+" ✕ "+p);draw();};
943
+ x.onclick=function(){r.writes.splice(i,1);note("removed folder",r.name+" ✕ "+p);};
609
944
  s.appendChild(x); return s;
610
945
  }
611
- // Below the threshold there is nothing to summarise and a fold would only hide what
612
- // already fits. Above it, the folder counts ARE the reading: "this role writes 43
613
- // files under src/" is the fact, and the forty-three names are the footnote.
614
946
  if(r.writes.length<=FOLD_AT||OPEN[r.name]){
615
947
  r.writes.forEach(function(p,i){ t2.appendChild(tagFor(p,i)); });
616
948
  if(r.writes.length>FOLD_AT){
@@ -620,12 +952,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
620
952
  }
621
953
  }else{
622
954
  byFolder(r.writes).forEach(function(f){
623
- var s=document.createElement("span"); s.className="tag fold"; s.tabIndex=0;
955
+ var s=document.createElement("button"); s.type="button"; s.className="tag fold";
624
956
  s.innerHTML=esc(f.dir)+" <b>"+f.n+"</b>";
625
957
  s.title=f.paths.slice(0,12).join("\n")+(f.n>12?"\n… and "+(f.n-12)+" more":"");
626
- function open(){OPEN[r.name]=true;drawRows();}
627
- s.onclick=open;
628
- s.onkeydown=function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();open();}};
958
+ s.onclick=function(){OPEN[r.name]=true;drawRows();};
629
959
  t2.appendChild(s);
630
960
  });
631
961
  var more=document.createElement("button"); more.type="button"; more.className="more";
@@ -633,43 +963,53 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
633
963
  more.onclick=function(){OPEN[r.name]=true;drawRows();};
634
964
  t2.appendChild(more);
635
965
  }
636
- var add=document.createElement("button"); add.type="button"; add.className="add"; add.textContent="+ folder";
637
- add.onclick=function(){
638
- var v=prompt("Path from the repo root, with /** for the subtree:","src/");
639
- if(v&&v.trim()){r.writes.push(v.trim());note("admin","granted folder",r.name+" + "+v.trim());draw();}
640
- };
641
- t2.appendChild(add); c2.appendChild(t2); tr.appendChild(c2);
642
-
643
- var c3=document.createElement("td");
966
+ if (!r.writes.length) { var none=document.createElement("span"); none.className="ro"; none.textContent="no territory"; t2.appendChild(none); }
967
+ if (!LIVE) {
968
+ var add=document.createElement("button"); add.type="button"; add.className="add"; add.textContent="+ folder";
969
+ add.onclick=function(){
970
+ var v=prompt("Path from the repo root, with /** for the subtree:","src/");
971
+ if(v&&v.trim()){r.writes.push(v.trim());note("added folder",r.name+" + "+v.trim());}
972
+ };
973
+ t2.appendChild(add);
974
+ }
975
+ c2.appendChild(t2); tr.appendChild(c2);
976
+
977
+ var c3=document.createElement("td"); c3.setAttribute("data-label","keys");
644
978
  var t3=document.createElement("div"); t3.className="tags";
645
- KEYS.forEach(function(k){
979
+ if (LIVE) {
980
+ r.keys.forEach(function(k){ var s=document.createElement("span"); s.className="tag"; s.textContent=k; t3.appendChild(s); });
981
+ if (!r.keys.length) { var nk=document.createElement("span"); nk.className="ro"; nk.textContent="none"; t3.appendChild(nk); }
982
+ } else KEYS.forEach(function(k){
646
983
  var on=r.keys.indexOf(k)!==-1;
647
984
  var b=document.createElement("button"); b.type="button"; b.className="tag"; b.textContent=k;
648
985
  b.setAttribute("aria-pressed",on?"true":"false");
649
- b.setAttribute("aria-label",(on?"Revoke":"Grant")+" key "+k+" for "+r.name);
986
+ b.setAttribute("aria-label",k+" — "+(on?"held by ":"not held by ")+r.name);
650
987
  b.onclick=function(){
651
988
  if(on) r.keys.splice(r.keys.indexOf(k),1); else r.keys.push(k);
652
- note("admin",(on?"revoked key":"granted key"),r.name+(on?" ✕ ":" + ")+k);
653
- draw();
989
+ note(on?"removed key":"added key",r.name+(on?" ✕ ":" + ")+k);
654
990
  };
655
991
  t3.appendChild(b);
656
992
  });
657
993
  c3.appendChild(t3); tr.appendChild(c3);
658
994
 
659
- var c4=document.createElement("td"); c4.className="st";
660
- var sw=document.createElement("button"); sw.type="button"; sw.className="sw";
661
- sw.setAttribute("data-on",r.sandbox?"true":"false");
662
- sw.setAttribute("aria-pressed",r.sandbox?"true":"false");
663
- sw.setAttribute("aria-label","Sandbox for "+r.name);
664
- sw.innerHTML="<i></i>"+(r.sandbox?"sandboxed":"open");
665
- sw.onclick=function(){
666
- r.sandbox=!r.sandbox;
667
- note("admin",r.sandbox?"sandboxed":"unsandboxed",r.name);
668
- draw();
669
- };
670
- c4.appendChild(sw); tr.appendChild(c4);
671
-
672
- var c5=document.createElement("td"); c5.className="num";
995
+ var c4=document.createElement("td"); c4.className="st"; c4.setAttribute("data-label","confined");
996
+ if (LIVE) {
997
+ // `seisin run` always sandboxes; there is nothing to switch here.
998
+ c4.innerHTML='<span class="state"><i></i>confined</span>';
999
+ } else {
1000
+ var sw=document.createElement("button"); sw.type="button"; sw.className="sw";
1001
+ var txt=r.sandbox?"confined":"unconfined";
1002
+ sw.setAttribute("data-on",r.sandbox?"true":"false");
1003
+ sw.setAttribute("aria-pressed",r.sandbox?"true":"false");
1004
+ sw.setAttribute("aria-label",txt+" — "+r.name);
1005
+ sw.title = r.sandbox ? "The OS enforces this role's territory" : "Unconfined: nothing stops a write outside its territory";
1006
+ sw.innerHTML="<i></i>"+txt;
1007
+ sw.onclick=function(){ r.sandbox=!r.sandbox; note(r.sandbox?"confined":"unconfined",r.name); };
1008
+ c4.appendChild(sw);
1009
+ }
1010
+ tr.appendChild(c4);
1011
+
1012
+ var c5=document.createElement("td"); c5.className="num"; c5.setAttribute("data-label","denials");
673
1013
  c5.innerHTML=r.blocks?'<span style="color:var(--amber);font-weight:600">'+r.blocks+"</span>"
674
1014
  :'<span style="color:var(--dim)">—</span>';
675
1015
  tr.appendChild(c5);
@@ -677,48 +1017,52 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
677
1017
  tb.appendChild(tr);
678
1018
  });
679
1019
 
680
- var on=0,off=0,nok=0,bl=0;
681
- ROLES.forEach(function(r){r.sandbox?on++:off++; if(!r.keys.length)nok++; bl+=r.blocks;});
1020
+ var off=ROLES.filter(function(r){return !r.sandbox;}).length;
682
1021
  drawKpis();
683
- var pin=document.getElementById("pin");
684
- if(off){pin.textContent=off;pin.hidden=false;}else{pin.hidden=true;}
1022
+ pill("pin", off, off === 1 ? "role unconfined" : "roles unconfined");
685
1023
  var k=document.getElementById("kill");
686
- k.textContent=off?"Sandbox everything":"Unsandbox everything";
1024
+ k.textContent=off?"Confine every role":"Run every role unconfined";
687
1025
  k.classList.toggle("warn",off===0);
688
1026
  }
689
1027
 
690
1028
  function drawCfg(){
691
1029
  var r=ROLES.filter(function(x){return x.name===sel;})[0];
692
- // Belt and braces. Whatever leaves `sel` pointing at a role that is not there, one panel
693
- // going blank must not blank the ones drawn after it: the throw here was swallowing two.
694
1030
  if(!r){
695
- document.getElementById("cfg-title").textContent=".seisin/\u2026.json";
1031
+ document.getElementById("cfg-title").textContent=".seisin/….json";
696
1032
  document.getElementById("cfg").textContent =
697
1033
  ROLES.length ? "pick a role above" : "no roles in this policy";
698
1034
  return;
699
1035
  }
700
1036
  document.getElementById("cfg-title").textContent=".seisin/"+r.name+".json";
1037
+ document.getElementById("cfg-note").textContent = LIVE
1038
+ ? "what seisin hands the runtime for the selected role"
1039
+ : "illustrative — the shape of what seisin hands the runtime, from the example";
1040
+ if(LIVE){
1041
+ var got=SETTINGS[r.name];
1042
+ if(!got){ document.getElementById("cfg").textContent="reading "+r.name+"’s settings…"; loadSettings(r.name); return; }
1043
+ if(got.error){ document.getElementById("cfg").textContent=got.error; return; }
1044
+ document.getElementById("cfg").innerHTML=
1045
+ '<span class="c">// seisin run '+esc(r.name)+' -- claude -p "…"</span>\n'+
1046
+ esc(JSON.stringify(got.settings,null,2)).replace(/^(\s*)("[^"\n]*")(:)/gm,'$1<span class="k">$2</span>$3');
1047
+ return;
1048
+ }
701
1049
  var L=[];
702
- L.push('<span class="c">// seisin run '+esc(r.name)+' -- claude -p "…"</span>');
1050
+ L.push('<span class="c">// illustrative: seisin run '+esc(r.name)+' -- claude -p "…"</span>');
703
1051
  if(!r.sandbox){
704
- L.push('<span class="d">// UNSANDBOXED. seisin still names the owner, but</span>');
1052
+ L.push('<span class="d">// UNCONFINED. seisin still names the owner, but</span>');
705
1053
  L.push('<span class="d">// nothing stops the write. Only the log will know.</span>');
706
1054
  }
707
1055
  L.push("{");
708
1056
  L.push(' <span class="k">"network"</span>: {');
709
- var lp=(r.localPorts||[]).map(function(n){return '"localhost:'+Number(n)+'"';});
710
- L.push(' "allowedDomains": ["github.com"'+(lp.length?', <span class="g">'+lp.join(', ')+'</span>':'')+']'+(lp.length?', <span class="c">// local_ports, through the proxy</span>':','));
1057
+ L.push(' "allowedDomains": ["github.com"],');
711
1058
  L.push(' "deniedDomains": [], "allowUnixSockets": [],');
712
- L.push(' "allowLocalBinding": '+(r.localBinding?'<span class="d">true</span> <span class="c">// can reach every localhost port (macOS)</span>':'false'));
1059
+ L.push(' "allowLocalBinding": false');
713
1060
  L.push(" },");
714
1061
  L.push(' <span class="k">"filesystem"</span>: {');
715
- // KEYDIR comes from seisin.toml, which is a file the console may be
716
- // pointed at without having written it. Unescaped it was HTML injection
717
- // into a page that holds the run's token — and that token approves grants.
718
1062
  L.push(' <span class="d">"denyRead"</span>: ["'+esc(KEYDIR)+'"],');
719
1063
  if(r.keys.length){
720
1064
  L.push(' <span class="g">"allowRead"</span>: [');
721
- r.keys.forEach(function(k,i){L.push(' "'+esc(KEYDIR)+"/"+esc(k)+'.txt"'+(i<r.keys.length-1?",":""));});
1065
+ r.keys.forEach(function(k,i){L.push(' "'+esc(KEYDIR)+"/"+esc(k)+'"'+(i<r.keys.length-1?",":""));});
722
1066
  L.push(" ],");
723
1067
  }else{
724
1068
  L.push(' <span class="g">"allowRead"</span>: [], <span class="c">// declares none</span>');
@@ -729,13 +1073,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
729
1073
  });
730
1074
  else L.push(' <span class="c">// no territory</span>');
731
1075
  L.push(" ],");
732
- if((r.neverWrites||[]).length){
733
- L.push(' <span class="d">"denyWrite"</span>: [ <span class="c">// never_writes</span>');
734
- r.neverWrites.forEach(function(p,i){ L.push(' "'+esc(p.replace(/\/\*\*$/,""))+'"'+(i<r.neverWrites.length-1?",":"")); });
735
- L.push(" ]");
736
- } else L.push(' "denyWrite": []');
737
- L.push(" }"+(r.trustd?",":""));
738
- if(r.trustd) L.push(' <span class="d">"enableWeakerNetworkIsolation"</span>: true <span class="c">// trustd = true</span>');
1076
+ L.push(' "denyWrite": []');
1077
+ L.push(" }");
739
1078
  L.push("}");
740
1079
  document.getElementById("cfg").innerHTML=L.join("\n");
741
1080
  }
@@ -743,217 +1082,498 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
743
1082
  /**
744
1083
  * The policy, as TOML.
745
1084
  *
746
- * Two different things depending on where the page is running, and the
747
- * difference matters. Served live, it prints the file on disk verbatim —
748
- * comments, order and the provenance a grant wrote. Opened as a file, there
749
- * is no disk to read, so it renders the in-memory model, which by definition
750
- * has no comments. Rendering the model in live mode and labelling it
751
- * "seisin.toml · copy this back into your repo" was an instruction to paste
752
- * away the one record of why a permission exists.
1085
+ * Served live, it prints the file on disk verbatim — comments, order and the
1086
+ * provenance a grant wrote — with the line the last decision wrote marked.
1087
+ * Opened as a file, there is no disk to read, so it renders the example's
1088
+ * model, and says so.
753
1089
  */
1090
+ var MARK = null; // the line number the last save wrote, marked until the next one
754
1091
  function drawToml(){
1092
+ var host = document.getElementById("toml");
755
1093
  if (ONDISK !== null) {
756
- var html = esc(ONDISK)
757
- .replace(/^(\[[^\]]+\])$/gm, '<span class="k">$1</span>')
758
- .replace(/(#[^\n]*)$/gm, '<span class="c">$1</span>');
759
- document.getElementById("toml").innerHTML = html;
1094
+ host.innerHTML = ONDISK.split("\n").map(function(l, i){
1095
+ var h = esc(l).replace(/^(\[[^\]]+\])$/, '<span class="k">$1</span>').replace(/(#[^\n]*)$/, '<span class="c">$1</span>');
1096
+ return MARK === i + 1 ? '<mark id="toml-mark">' + h + "</mark>" : h;
1097
+ }).join("\n");
760
1098
  return;
761
1099
  }
762
- var L=['<span class="c"># Anything not listed is denied.</span>',"",
1100
+ var L=['<span class="c"># Example policy, generated in this page. Anything not listed is denied.</span>',"",
763
1101
  "[keys]",'dir = "'+esc(KEYDIR)+'"',"","[network]",'allow = ["github.com", "*.github.com"]',""];
1102
+ if(PROTECT.instructions) L.push('<span class="k">[protect]</span>',"instructions = true","");
764
1103
  ROLES.forEach(function(r){
765
1104
  L.push('<span class="k">[roles.'+esc(r.name)+"]</span>");
766
1105
  L.push("writes = ["+r.writes.map(function(w){return '"'+esc(w)+'"';}).join(", ")+"]");
767
- L.push("keys = ["+r.keys.map(function(k){return '"'+esc(k)+'.txt"';}).join(", ")+"]");
1106
+ L.push("keys = ["+r.keys.map(function(k){return '"'+esc(k)+'"';}).join(", ")+"]");
1107
+ if((r.controlFiles||[]).length)
1108
+ L.push("control_files = ["+r.controlFiles.map(function(f){return '"'+esc(f)+'"';}).join(", ")+"]");
768
1109
  L.push("");
769
1110
  });
770
- document.getElementById("toml").innerHTML=L.join("\n");
1111
+ host.innerHTML=L.join("\n");
771
1112
  }
772
1113
 
773
- var VERB={"granted folder":"granted folder","revoked folder":"revoked folder",
774
- "granted key":"granted key","revoked key":"revoked key",
775
- "sandboxed":"sandboxed","unsandboxed":"unsandboxed"};
1114
+ /** What a denial with no owner was about, said of the thing it was — not "no role owns this
1115
+ * path" of a port or a tool, which is not a path and was never anybody's. */
1116
+ function unownedSays(about){
1117
+ return about === "network" ? "not in the network allow list"
1118
+ : about === "tool" ? "MCP server not allowed for this role"
1119
+ : about === "key" ? "a key this role does not declare"
1120
+ : "no role owns this path";
1121
+ }
776
1122
 
777
1123
  function drawLog(){
778
1124
  var c=document.getElementById("log"); c.textContent="";
779
- // An empty log is not a broken page, and now that Activity is a view of its own it has
780
- // a whole screen to say nothing on. It means one of two things and they are worth
781
- // telling apart: nothing has run under seisin yet, or everything that ran was allowed.
782
1125
  if(!LOG.length){
783
1126
  var z=document.createElement("p"); z.className="blank";
784
1127
  z.innerHTML="No decisions recorded yet.<br><span>The log fills when an agent actually "+
785
- "runs inside the box \u2014 <code>seisin run &lt;role&gt; -- \u2026</code>. "+
1128
+ "runs inside the sandbox — <code>seisin run &lt;role&gt; -- …</code>. "+
786
1129
  "Asking <code>seisin explain</code> or reading this console does not write to it.</span>";
787
1130
  c.appendChild(z); return;
788
1131
  }
789
1132
  LOG.forEach(function(e){
790
1133
  var d=document.createElement("div");
791
1134
  d.className="ev "+e.kind;
792
- var label=e.kind==="admin"?"you":(e.kind==="deny"?"denied":"allowed");
1135
+ var label=e.kind==="admin"?"you":e.kind==="deny"?"denied":e.kind==="observed"?"observed":"allowed";
793
1136
  var line,who="";
794
1137
  if(e.kind==="admin"){
795
- line=esc(VERB[e.act]||e.act)+" <em>"+esc(e.target)+"</em>";
1138
+ line=esc(e.act)+" <em>"+esc(e.target)+"</em>";
796
1139
  }else{
797
1140
  line="<em>"+esc(e.role)+"</em> "+esc(e.act)+" "+esc(e.target);
798
1141
  if(e.kind==="deny"){
799
1142
  who=e.owners.length
800
1143
  ? '<div class="who">belongs to <em>'+e.owners.map(esc).join("</em>, <em>")+"</em></div>"
801
- : '<div class="who">no role owns this path</div>';
1144
+ : '<div class="who">'+esc(unownedSays(e.about))+"</div>";
802
1145
  }
803
1146
  }
804
1147
  // Only the kernel's own lines get the mark. A hook line and an older line look the
805
1148
  // same as they always did — the absence is the default, not a third state.
806
1149
  var src=e.source==="kernel"
807
- ? '<span class="src" title="Recorded from the kernel: what the OS actually denied, '+
808
- 'whether or not the hook saw the attempt. This line cannot be talked around.">kernel</span>'
1150
+ ? '<span class="src" title="The OS denied it: recorded from the kernel, whether or not the hook saw the attempt. '+
1151
+ 'This line cannot be talked around.">OS denied</span>'
809
1152
  : "";
1153
+ var w=when(e.at);
810
1154
  d.innerHTML="<b>"+label+'</b><span class="body"><span class="what">'+line+"</span>"+who+
811
- "</span>"+src+'<span class="when">'+esc(e.t)+"</span>";
1155
+ "</span>"+src+'<span class="when" title="'+escA(w.title)+'">'+esc(w.text)+"</span>";
812
1156
  c.appendChild(d);
813
1157
  });
814
1158
  }
815
1159
 
816
1160
  /**
817
- * A what-if, and only where a what-if is honest.
818
- *
819
- * seisin.toml cannot express an unsandboxed role — this shows what the map
820
- * would be worth without the boundary, which is a useful thing to see once
821
- * and a lie to leave on screen next to real decisions read from disk. It
822
- * writes an entry into the Activity panel, and in live mode that panel is
823
- * the log. Hidden when live for that reason; boot() does the hiding.
1161
+ * A what-if, and only where a what-if is honest: the example. seisin.toml cannot
1162
+ * express an unconfined role, so live this button does not exist.
824
1163
  */
825
1164
  document.getElementById("kill").onclick=function(){
826
1165
  var anyOpen=ROLES.some(function(r){return !r.sandbox;});
827
1166
  ROLES.forEach(function(r){r.sandbox=anyOpen;});
828
- note("admin",anyOpen?"sandboxed":"unsandboxed","every role");
829
- draw();
1167
+ note(anyOpen?"confined":"unconfined","every role");
830
1168
  };
831
1169
 
1170
+ /* ── control files ────────────────────────────────────────────────────── */
1171
+
1172
+ /**
1173
+ * The two families a policy can hand a role, and the one global switch.
1174
+ *
1175
+ * A toggle never saves. It asks the server (dryRun) what each sandbox profile
1176
+ * would gain or lose and who could then write each family; that answer is
1177
+ * shown, and a separate button saves it. The server's list is the only list:
1178
+ * the families no role is ever handed are not offered here.
1179
+ */
1180
+ var FAM = {
1181
+ ide: { label: "Editor settings", files: ".vscode/ · .cursor/ · .windsurf/",
1182
+ what: "Tasks, terminal profiles and settings the editor applies the moment they change, outside the sandbox." },
1183
+ instructions: { label: "Instruction files", files: "CLAUDE.md · AGENTS.md · GEMINI.md · .cursorrules …",
1184
+ what: "What the next agent session reads as its instructions — how one role reaches another’s context." }
1185
+ };
1186
+ var PENDING = null; // { edit, from, fam, where, plan, error, busy, demo } — the change being looked at
1187
+
1188
+ function selRole(){ return ROLES.filter(function(x){ return x.name === sel; })[0]; }
1189
+
1190
+ function ctlRow(o){
1191
+ var row = document.createElement("div"); row.className = "ctl-row" + (o.glob ? " glob" : "");
1192
+ var l = document.createElement("div"); l.className = "lbl";
1193
+ l.innerHTML = "<b>" + esc(o.title) + "</b> <code>" + esc(o.code) + "</code><p>" + o.desc + "</p>";
1194
+ var b = document.createElement("button"); b.type = "button"; b.className = "sw"; b.id = o.id;
1195
+ b.setAttribute("data-state", o.open ? "open" : "shut");
1196
+ b.setAttribute("aria-pressed", o.pressed ? "true" : "false");
1197
+ // The accessible name starts with the words on the button (WCAG 2.5.3): a voice user says
1198
+ // what they see. It was "Protect instruction files for every role" on a button reading
1199
+ // "not protected".
1200
+ b.setAttribute("aria-label", o.text + " — " + o.aria);
1201
+ b.innerHTML = "<i></i>" + esc(o.text);
1202
+ if (o.disabled) { b.disabled = true; b.title = o.why || ""; }
1203
+ else b.onclick = o.click;
1204
+ row.appendChild(l); row.appendChild(b);
1205
+ return row;
1206
+ }
1207
+
1208
+ function drawControls(){
1209
+ var pw = document.getElementById("pw");
1210
+ var host = document.getElementById("ctl"); if (!host || !pw) return;
1211
+ var r = selRole();
1212
+ document.getElementById("ctl-title").textContent = r ? "Control files · " + r.name : "Control files";
1213
+ host.textContent = ""; pw.textContent = "";
1214
+ var prot = !!PROTECT.instructions;
1215
+ pw.appendChild(ctlRow({
1216
+ glob: true, id: "ctl-sw-protect", title: "Protect instruction files", code: "[protect] instructions",
1217
+ desc: prot
1218
+ ? "On: instruction files are denied to every role not handed them below."
1219
+ : "Off: every role may edit instruction files inside its own territory.",
1220
+ open: !prot, pressed: prot, text: prot ? "protected" : "not protected",
1221
+ aria: "instruction files, for every role",
1222
+ click: function(){ propose({ protect: !prot }, "ctl-sw-protect", "instructions", "pw"); }
1223
+ }));
1224
+ if (!r) { drawPreview(); return; }
1225
+ ["ide", "instructions"].forEach(function(f){
1226
+ var mine = (r.controlFiles || []).indexOf(f) !== -1;
1227
+ var idle = f === "instructions" && !prot;
1228
+ var desc = idle
1229
+ ? "Nothing to hand over while <code>[protect] instructions</code> is off: no role is denied these. Turn protection on under Policy-wide first."
1230
+ : (mine ? esc(r.name) + " may edit these inside its own territory. "
1231
+ : "Protected: " + esc(r.name) + " is denied writing them. ") + esc(FAM[f].what);
1232
+ host.appendChild(ctlRow({
1233
+ id: "ctl-sw-" + f, title: FAM[f].label + " (" + f + ")", code: FAM[f].files, desc: desc,
1234
+ open: mine || idle, pressed: mine, text: idle ? "not protected" : mine ? "may edit" : "protected",
1235
+ aria: FAM[f].label.toLowerCase() + ", " + r.name,
1236
+ disabled: idle, why: "Turn on [protect] instructions first",
1237
+ click: function(){
1238
+ var next = (r.controlFiles || []).filter(function(x){ return x !== f; });
1239
+ if (!mine) next.push(f);
1240
+ next = ["ide", "instructions"].filter(function(x){ return next.indexOf(x) !== -1; });
1241
+ propose({ role: r.name, families: next }, "ctl-sw-" + f, f, "ctl");
1242
+ }
1243
+ }));
1244
+ });
1245
+ drawPreview();
1246
+ }
1247
+
1248
+ async function callControls(body){
1249
+ var r = await fetch("/api/control-files", {
1250
+ method: "POST",
1251
+ headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
1252
+ body: JSON.stringify(body)
1253
+ });
1254
+ var out = await r.json();
1255
+ if (!r.ok) { var err = new Error(out.error || "the console did not accept that"); err.status = r.status; throw err; }
1256
+ return out;
1257
+ }
1258
+
1259
+ function sameEdit(a, b){ return JSON.stringify(a) === JSON.stringify(b); }
1260
+
1261
+ /** A toggle pressed: ask what it would do. Pressing the same one again puts it back. */
1262
+ async function propose(edit, from, fam, where){
1263
+ if (PENDING && sameEdit(PENDING.edit, edit)) { cancelEdit(); return; }
1264
+ var mine = PENDING = { edit: edit, from: from, fam: fam, where: where, plan: null, error: null, busy: LIVE, demo: !LIVE };
1265
+ drawPreview();
1266
+ if (!LIVE) return;
1267
+ try { var out = await callControls(Object.assign({ dryRun: true }, edit)); if (PENDING === mine) mine.plan = out; }
1268
+ catch (e) { if (PENDING === mine) mine.error = whyFailed(e, e.status); }
1269
+ if (PENDING !== mine) return;
1270
+ mine.busy = false;
1271
+ drawPreview();
1272
+ // Focus goes to the decision, so the keyboard path is toggle → Enter → reason → Save.
1273
+ var next = document.getElementById("ctl-why") || document.getElementById("ctl-save") || document.getElementById("ctl-cancel");
1274
+ if (next) next.focus();
1275
+ }
1276
+
1277
+ function cancelEdit(){
1278
+ var from = PENDING && PENDING.from;
1279
+ PENDING = null;
1280
+ drawPreview();
1281
+ var b = from && document.getElementById(from);
1282
+ if (b) b.focus();
1283
+ }
1284
+
1285
+ /** A path as a reader names it: relative to the repo, and a pattern for a file not
1286
+ * there yet (`.curso[r]`, see surface.js `exactly`) written as the name it matches. */
1287
+ function rel(p){
1288
+ var q = ROOT && p.indexOf(ROOT + "/") === 0 ? p.slice(ROOT.length + 1) : p;
1289
+ return q.replace(/\[([^\]\/])\]$/, "$1");
1290
+ }
1291
+
1292
+ function describe(edit){
1293
+ if (edit.protect !== undefined)
1294
+ return (edit.protect ? "Turn on" : "Turn off") + " protection of instruction files, for every role";
1295
+ return "Set " + edit.role + "’s control_files to [" +
1296
+ edit.families.map(function(f){ return '"' + f + '"'; }).join(", ") + "]";
1297
+ }
1298
+
1299
+ function whoMay(list){ return list && list.length ? "<b>" + list.map(esc).join(", ") + "</b>" : "<b>no role</b>"; }
1300
+
1301
+ /**
1302
+ * The profile changes, grouped: one line per direction ("7 instruction files in frontend,
1303
+ * backend and ops become denied"), the paths behind a disclosure. It listed every path of
1304
+ * every role, and turning protection on in a policy of twelve roles was eighty-four lines
1305
+ * saying the same thing.
1306
+ */
1307
+ function groupedDiff(diff, fam){
1308
+ var dirs = { shut: { roles: [], n: 0, created: 0, per: {} }, open: { roles: [], n: 0, created: 0, per: {} } };
1309
+ diff.forEach(function(x){
1310
+ if (x.error) return;
1311
+ [["shut", x.added], ["open", x.removed]].forEach(function(p){
1312
+ if (!p[1].length) return;
1313
+ var g = dirs[p[0]];
1314
+ g.roles.push(x.role); g.n += p[1].length;
1315
+ g.per[x.role] = p[1];
1316
+ p[1].forEach(function(e){ if (/if it is created$/.test(e.why)) g.created++; });
1317
+ });
1318
+ });
1319
+ var what = FAM[fam] ? FAM[fam].label.toLowerCase().replace(/s$/, "") : "control file";
1320
+ var h = "";
1321
+ [["shut", "become denied", "an agent that edits these today will be denied"],
1322
+ ["open", "become writable", "the role may edit them inside its territory"]].forEach(function(d){
1323
+ var g = dirs[d[0]]; if (!g.n) return;
1324
+ var exist = g.n - g.created;
1325
+ h += '<li class="' + d[0] + '"><b aria-hidden="true">' + (d[0] === "shut" ? "+" : "−") + "</b><span>" +
1326
+ '<span class="cnt">' + g.n + " " + esc(what) + (g.n === 1 ? "" : "s") + "</span> in " +
1327
+ esc(g.roles.join(", ")) + " " + d[1] +
1328
+ "<em>" + d[2] + (g.created ? " · " + exist + " exist now, " + g.created + " only if created" : "") + "</em>" +
1329
+ "<details><summary>show the " + g.n + " path" + (g.n === 1 ? "" : "s") + "</summary><ul>" +
1330
+ g.roles.map(function(r){
1331
+ return "<li>" + esc(r) + ": " + g.per[r].map(function(e){ return esc(rel(e.path)); }).join(", ") + "</li>";
1332
+ }).join("") + "</ul></details></span></li>";
1333
+ });
1334
+ diff.forEach(function(x){ if (x.error) h += '<li><b>!</b><span>' + esc(x.role) + ': <span class="err">' + esc(x.error) + "</span></span></li>"; });
1335
+ return h ? '<ul class="diff">' + h + "</ul>" : "";
1336
+ }
1337
+
1338
+ /** The exact lines of seisin.toml an edit writes, the way a diff shows them. */
1339
+ function tomlLines(lines, note){
1340
+ if (!lines) return "";
1341
+ var body = lines.removed.map(function(l){ return '<span class="d">- ' + esc(l) + "</span>"; })
1342
+ .concat(lines.added.map(function(l){ return '<span class="g">+ ' + esc(l) + "</span>"; })).join("\n");
1343
+ return "<p>" + note + " at line " + lines.at + ":</p>" + '<pre class="tomlline">' + body + "</pre>";
1344
+ }
1345
+
1346
+ function drawPreview(){
1347
+ ["pw", "ctl"].forEach(function(where){
1348
+ var box = document.getElementById(where + "-preview"); if (!box) return;
1349
+ var p = PENDING && PENDING.where === where ? PENDING : null;
1350
+ if (!p) { box.hidden = true; box.innerHTML = ""; return; }
1351
+ box.hidden = false;
1352
+ var h = "<h3>" + esc(describe(p.edit)) + "</h3>";
1353
+ var buttons = '<button class="btn" id="ctl-cancel" type="button">Cancel</button>';
1354
+ if (p.busy) h += "<p>Asking the server what this changes…</p>";
1355
+ else if (p.error) h += '<p class="err">' + esc(p.error) + "</p>";
1356
+ else if (p.demo) {
1357
+ h += "<p>This is the example, and nothing here is saved. Run by <code>seisin ui</code>, this panel lists " +
1358
+ "what each role’s sandbox settings gain or lose — computed by the server from the edited " +
1359
+ "policy — and the exact line written to seisin.toml, and nothing is written until you confirm.</p>";
1360
+ buttons = '<button class="btn primary" id="ctl-save" type="button">Apply to the example</button>' + buttons;
1361
+ } else if (p.plan && !p.plan.changed) {
1362
+ h += "<p>seisin.toml already says this. Nothing to save.</p>";
1363
+ } else if (p.plan) {
1364
+ var d = p.plan.diff || [];
1365
+ h += groupedDiff(d, p.fam);
1366
+ var idle = p.plan.unaffected || [];
1367
+ if (idle.length)
1368
+ h += "<p>No change for <b>" + esc(idle.join(", ")) + "</b>: no territory of " +
1369
+ (idle.length === 1 ? "that role" : "those roles") + " holds a project where these files are or could be created.</p>";
1370
+ var a = p.plan.access || {};
1371
+ h += "<p>Then editor settings may be edited by " + whoMay(a.ide) + "; instruction files " +
1372
+ (a.instructions === null || a.instructions === undefined
1373
+ ? "are not protected — <b>every role</b> may edit them in its own territory."
1374
+ : "by " + whoMay(a.instructions) + ".") + "</p>";
1375
+ h += tomlLines(p.plan.lines, "Written to seisin.toml (your reason is added at the end of the line)");
1376
+ buttons = '<input id="ctl-why" placeholder="reason (required) — written beside the line" aria-label="Reason for this change">' +
1377
+ '<button class="btn primary" id="ctl-save" type="button" data-write>Save to seisin.toml</button>' + buttons;
1378
+ }
1379
+ box.innerHTML = h + '<div class="act">' + buttons + '</div><p class="err" id="ctl-err" hidden></p>';
1380
+ document.getElementById("ctl-cancel").onclick = cancelEdit;
1381
+ var save = document.getElementById("ctl-save");
1382
+ if (save) save.onclick = saveEdit;
1383
+ box.onkeydown = function(e){ if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } };
1384
+ });
1385
+ lockWrites();
1386
+ }
1387
+
1388
+ /** What a save wrote, left under the box until the next change. */
1389
+ function doneNotice(where, html){
1390
+ ["pw", "ctl"].forEach(function(w){
1391
+ var el = document.getElementById(w + "-done");
1392
+ if (!el) return;
1393
+ if (w !== where || !html) { el.hidden = true; el.innerHTML = ""; return; }
1394
+ el.hidden = false;
1395
+ el.innerHTML = '<span class="txt">' + html + '</span><button type="button">dismiss</button>';
1396
+ el.querySelector("button").onclick = function(){ el.hidden = true; el.innerHTML = ""; };
1397
+ });
1398
+ }
1399
+
1400
+ /** The confirmation: the edit that was previewed, against the file it was previewed on. */
1401
+ async function saveEdit(){
1402
+ var p = PENDING; if (!p) return;
1403
+ if (p.demo) {
1404
+ if (p.edit.protect !== undefined) PROTECT = { instructions: p.edit.protect };
1405
+ else { var r = ROLES.filter(function(x){ return x.name === p.edit.role; })[0]; if (r) r.controlFiles = p.edit.families.slice(); }
1406
+ PENDING = null;
1407
+ doneNotice(p.where, "Applied to the example: " + esc(describe(p.edit)) + ". Nothing was saved — " +
1408
+ '<a href="#configfile">see the example seisin.toml</a>.');
1409
+ note("control files", describe(p.edit));
1410
+ var b = document.getElementById(p.from); if (b) b.focus();
1411
+ return;
1412
+ }
1413
+ var why = document.getElementById("ctl-why");
1414
+ if (why && !why.value.trim()) {
1415
+ why.setAttribute("aria-invalid", "true"); why.focus();
1416
+ var er = document.getElementById("ctl-err");
1417
+ er.hidden = false; er.textContent = "Write a reason first — it is saved beside the line in seisin.toml.";
1418
+ return;
1419
+ }
1420
+ var box = document.getElementById(p.where + "-preview");
1421
+ box.querySelectorAll("button,input").forEach(function(e){ e.disabled = true; });
1422
+ try {
1423
+ var out = await callControls(Object.assign({ base: p.plan.base, reason: (document.getElementById("ctl-why") || {}).value || "" }, p.edit));
1424
+ PENDING = null;
1425
+ var ln = out.lines;
1426
+ MARK = ln && ln.added.length ? ln.at : null;
1427
+ doneNotice(p.where, "Saved to seisin.toml: " + esc(describe(p.edit)) + ". The next <code>seisin run</code> of a role uses it." +
1428
+ (ln && ln.added.length ? '<span class="ln">line ' + ln.at + ": " +
1429
+ ln.added.filter(function(l){ return l.trim(); }).map(esc).join(" · ") + "</span>" : "") +
1430
+ ' <a href="#configfile">see Config file' + (MARK ? ", line " + MARK : "") + "</a>");
1431
+ await reload();
1432
+ drawPreview();
1433
+ var b2 = document.getElementById(p.from); if (b2) b2.focus();
1434
+ } catch (e) {
1435
+ if (PENDING === p) { p.error = whyFailed(e, e.status); p.plan = null; }
1436
+ drawPreview();
1437
+ }
1438
+ }
1439
+
832
1440
  /* ── requests ─────────────────────────────────────────────────────────── */
833
1441
 
834
1442
  var REQS = [];
1443
+ var LIVE = false; // served by `seisin ui`, reading a real repo
1444
+ var SETTINGS = {}; // role -> { settings } or { error }, from /api/settings; cleared when the policy changes
835
1445
  var ONDISK = null; // the file on disk, when there is one
836
1446
  var CAUSES = null; // this window's denials, grouped by what was denied
837
1447
  var WALLS = {}; // per role, what it keeps being denied and still would be
1448
+ var SHOW_ALL = false; // Denied: every cause, not the first twelve
1449
+
1450
+ /** The kind as a person reads it. The stored value "territory" reads as its opposite — a
1451
+ * path nobody owns — so it is said "ownable"; the value itself is a published field. */
1452
+ function kindSays(k){ return k === "territory" ? "ownable" : k; }
1453
+
1454
+ /** One cause's line under its path: whose it is, or what kind of thing it is. */
1455
+ function causeSays(c){
1456
+ if (c.about && c.about !== "file") return unownedSays(c.about);
1457
+ if (c.standing === "protected") return "protected: " + (c.why || "");
1458
+ if (c.standing === "outside") return c.why || "outside the map";
1459
+ if (c.owners && c.owners.length) return "owned by " + c.owners.join(", ");
1460
+ return "unowned" + (c.kind ? " · " + kindSays(c.kind) + (c.kind !== "territory" && c.hint ? ": " + c.hint : "") : "");
1461
+ }
1462
+
1463
+ function bar(c, max){
1464
+ var w = Math.max(2, Math.round((c.times / max) * 100));
1465
+ // A cause the policy no longer denies is history, not live: shown greyed, not dropped.
1466
+ var cold = c.stillRefused === 0;
1467
+ var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
1468
+ return '<div class="bar' + (cold ? ' cold' : '') + '">' +
1469
+ '<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
1470
+ '<div class="who">' + esc(who) + " · " + esc(causeSays(c)) + (cold ? ' · <b>granted since</b>' : '') + '</div>' +
1471
+ '<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
1472
+ '<div class="num">' + plural(c.times, "denial") + '<s>' + (c.share * 100).toFixed(1) + '% of all</s></div>' +
1473
+ '</div>';
1474
+ }
838
1475
 
839
- /**
840
- * The queue, as something a person acts on.
841
- *
842
- * Hidden entirely when empty rather than shown as an empty table: the point
843
- * of this panel is to be noticed on the days it has something in it.
844
- */
845
1476
  /**
846
1477
  * The shape of the window, grouped by cause.
847
1478
  *
848
- * The reading comes first and the bars second, because the number that
849
- * matters is almost never "how many" — it is "how much of it is one thing".
850
- * A day where three quarters of the denials are a single lock file is a
851
- * day where nobody disputed any territory, and that sentence is the whole
852
- * value of grouping. It is written out rather than left for the reader to
853
- * infer from twelve bars.
1479
+ * The reading comes first and the bars second. Files first, then keys, the network and MCP
1480
+ * tools each apart: "no role owns this path" is a sentence about paths. The list is cut at
1481
+ * twelve and says so — it used to stop at twelve with nothing to say there were more.
854
1482
  */
855
1483
  function drawCauses(){
856
1484
  var f = CAUSES;
857
1485
  var lede = document.getElementById("ca-lede");
1486
+ var bars = document.getElementById("ca-bars"), more = document.getElementById("ca-more"), other = document.getElementById("ca-other");
858
1487
  if (!f || !f.total) {
859
- if (lede) lede.textContent = "Nothing was denied in this window.";
860
- var bars = document.getElementById("ca-bars"); if (bars) bars.innerHTML = "";
1488
+ lede.textContent = "Nothing was denied in this window.";
1489
+ bars.innerHTML = ""; more.innerHTML = ""; other.innerHTML = "";
861
1490
  return;
862
1491
  }
863
- var top = f.causes[0];
864
1492
  var fam = (f.families || [])[0];
865
1493
  var famShare = fam ? Math.round(fam.share * 100) : 0;
866
- var lede = "<b>" + f.total.toLocaleString() + "</b> denial" + (f.total === 1 ? "" : "s") +
867
- " in this window, over <b>" + (f.distinct || f.causes.length).toLocaleString() +
868
- "</b> distinct path" + ((f.distinct || f.causes.length) === 1 ? "" : "s") + ". ";
1494
+ var distinct = f.distinct || f.causes.length;
1495
+ var h = "<b>" + plural(f.total, "denial") + "</b> in this window, over <b>" + distinct.toLocaleString() +
1496
+ "</b> distinct " + (distinct === 1 ? "thing" : "things") + ". ";
869
1497
  // The reading has to be taken at the level of the NAME, not the path, or it comes out
870
- // backwards. Measured here: six of the top seven causes were the same lock file in six
871
- // different repositories and no single path passed 17% \u2014 so "no cause dominates" was
872
- // what the arithmetic said and the opposite of the truth. One KIND of thing at that scale
873
- // is a tooling problem with a mechanical fix; the same volume spread across unrelated
874
- // paths is a territory question and needs a person. They take opposite responses, which
875
- // is why this sentence is written out instead of left to be inferred from twelve bars.
1498
+ // backwards: six lock files in six repositories, none above 17%, is one kind of thing.
876
1499
  if (fam && famShare >= 40 && fam.paths > 1)
877
- lede += "<span class=\"q\"><b>" + fam.times.toLocaleString() + "</b> of them \u2014 <b>" +
878
- famShare + "%</b> \u2014 are the same name in " + fam.paths + " places: " +
1500
+ h += "<span class=\"q\"><b>" + fam.times.toLocaleString() + "</b> of them — <b>" +
1501
+ famShare + "%</b> — are the same name in " + fam.paths + " places: " +
879
1502
  "<code>" + esc(fam.name) + "</code>. One kind of thing at that scale is a " +
880
1503
  "tooling problem with a mechanical fix, not something to grant.</span>";
881
1504
  else if (fam && famShare >= 40)
882
- lede += "<span class=\"q\"><b>" + famShare + "%</b> of it is a single path. That is " +
1505
+ h += "<span class=\"q\"><b>" + famShare + "%</b> of it is a single path. That is " +
883
1506
  "usually mechanical rather than something to grant.</span>";
884
1507
  else
885
- lede += "<span class=\"q\">No one kind of thing dominates, which is what a real boundary " +
1508
+ h += "<span class=\"q\">No one kind of thing dominates, which is what a real boundary " +
886
1509
  "question looks like rather than a tooling one.</span>";
887
- /* The half a grep cannot do, because it needs the policy: how many of these
888
- are on paths nobody claims. Those are the ones no grant resolves until a
889
- person decides who owns them — a decision, not a count. Added after a
890
- field review pointed out that the percentage above was the console
891
- reading the log back to somebody who had already read it. */
892
- /* Where each path stands against the policy NOW, not what the log wrote the day of the
893
- refusal: that was read here once, and every grant made since kept its paths counted as
894
- nobody's. Protected paths and ports are said apart, because they ask the opposite of a
895
- decision \u2014 there is nothing to grant. */
896
1510
  var st = f.standing || {};
897
1511
  if (f.unowned)
898
- lede += " <b>" + f.unowned.toLocaleString() + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
899
- " are on paths <b>no role owns</b> \u2014 a grant cannot settle those until " +
900
- "somebody claims them.";
1512
+ h += " <b>" + f.unowned.toLocaleString() + "</b> " + (f.unowned === 1 ? "is a path" : "are paths") +
1513
+ " <b>no role owns</b> — a grant cannot settle those until somebody claims them.";
1514
+ var kinds = st.unowned && st.unowned.kinds;
1515
+ if (f.unowned && kinds && kinds.territory && kinds.territory.paths < f.unowned)
1516
+ h += " Only <b>" + kinds.territory.paths.toLocaleString() + "</b> of those " +
1517
+ (kinds.territory.paths === 1 ? "is" : "are") + " ownable, a question of who owns what; the rest is " +
1518
+ ["git", "temporary", "build", "credential"].filter(function(k){ return kinds[k] && kinds[k].paths; })
1519
+ .map(function(k){ return kinds[k].paths.toLocaleString() + " " + k; }).join(", ") +
1520
+ ", each with its own fix and none of them an owner.";
901
1521
  var closed = (st.protected ? st.protected.paths : 0);
902
1522
  if (closed)
903
- lede += " <b>" + closed.toLocaleString() + "</b> " + (closed === 1 ? "is" : "are") +
904
- " protected on purpose \u2014 the boundary working, nothing to grant.";
905
- document.getElementById("ca-lede").innerHTML = lede;
906
- var win = document.getElementById("dn-window");
907
- if (win) win.textContent = rangeLabel() + " \u00b7 " + f.total.toLocaleString() + " denials \u00b7 " +
908
- (f.distinct || f.causes.length).toLocaleString() + " distinct paths";
909
-
910
- var max = top.times || 1;
911
- document.getElementById("ca-bars").innerHTML = f.causes.map(function(c){
912
- var w = Math.max(2, Math.round((c.times / max) * 100));
913
- // A cause the policy no longer denies is history, not live. It is
914
- // shown, greyed, rather than dropped: it explains a number somebody may
915
- // remember from yesterday instead of silently changing it.
916
- // `stillRefused` is what the server sends. This read `stillDeclined`, which it never
917
- // did, so nothing was ever greyed.
918
- var cold = c.stillRefused === 0;
919
- var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
920
- return '<div class="bar' + (cold ? ' cold' : '') + '">' +
921
- '<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
922
- '<div class="who">' + esc(who) +
923
- (c.standing === "protected" ? ' · protected: ' + esc(c.why || "") :
924
- c.standing === "outside" ? ' · ' + esc(c.why || "outside the map") :
925
- c.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
926
- (cold ? ' · <b>granted since</b>' : '') + '</div>' +
927
- '<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
928
- '<div class="num">' + c.times.toLocaleString() +
929
- '<s>' + (c.share * 100).toFixed(1) + '%</s></div>' +
930
- '</div>';
1523
+ h += " <b>" + closed.toLocaleString() + "</b> " + (closed === 1 ? "is" : "are") +
1524
+ " protected on purpose — the boundary working, nothing to grant.";
1525
+ lede.innerHTML = h;
1526
+ document.getElementById("dn-window").textContent = rangeLabel() + " · " + plural(f.total, "denial");
1527
+
1528
+ var files = f.causes.filter(function(c){ return (c.about || "file") === "file"; });
1529
+ var max = Math.max.apply(null, f.causes.map(function(c){ return c.times; }).concat([1]));
1530
+ var shown = SHOW_ALL ? files : files.slice(0, 12);
1531
+ var fileTotal = f.about ? f.about.file.paths : files.length;
1532
+ bars.innerHTML = (files.length ? '<h3 class="sr">Files</h3>' : "") + shown.map(function(c){ return bar(c, max); }).join("");
1533
+ more.innerHTML = "";
1534
+ if (fileTotal > 12) {
1535
+ var b = document.createElement("button"); b.type = "button"; b.className = "showall";
1536
+ b.textContent = SHOW_ALL ? "show the first 12" :
1537
+ shown.length + " of " + fileTotal.toLocaleString() + " paths · show all" +
1538
+ (files.length < fileTotal ? " (the first " + files.length + " are listed here)" : "");
1539
+ b.onclick = function(){ SHOW_ALL = !SHOW_ALL; drawCauses(); };
1540
+ more.appendChild(b);
1541
+ }
1542
+ var parts = [
1543
+ ["key", "Keys", "reads of a key the role does not declare"],
1544
+ ["network", "Network", "connections to a destination not in the network allow list"],
1545
+ ["tool", "MCP tools", "tools from an MCP server the role is not allowed to use"]
1546
+ ];
1547
+ other.innerHTML = parts.map(function(p){
1548
+ var list = f.causes.filter(function(c){ return c.about === p[0]; });
1549
+ var n = f.about ? f.about[p[0]] : { paths: list.length, denials: list.reduce(function(s, c){ return s + c.times; }, 0) };
1550
+ if (!n || !n.denials) return "";
1551
+ return '<div class="other"><h3>' + p[1] + " · " + plural(n.denials, "denial") + "</h3><p>" + p[2] + '.</p><div class="bars">' +
1552
+ list.map(function(c){ return bar(c, max); }).join("") + "</div></div>";
931
1553
  }).join("");
932
-
933
1554
  }
934
1555
 
935
1556
  /** Denied, by name — the cut that says what kind of day it was. */
936
1557
  function drawFamilies(){
937
- var f = CAUSES; if (!f) return;
938
- var fams = f.families || [];
1558
+ var f = CAUSES;
1559
+ var fams = (f && f.families) || [];
939
1560
  var top = fams[0];
940
1561
  document.getElementById("fa-lede").innerHTML = !top ? "Nothing was denied in this window."
941
- : "<b>" + Math.round(top.share * 100) + "%</b> of " + f.total.toLocaleString() +
942
- " denials is <code>" + esc(top.name) + "</code>, across <b>" + top.paths + "</b> path" +
943
- (top.paths === 1 ? "" : "s") + "." +
1562
+ : "The most common name is <code>" + esc(top.name) + "</code>: <b>" + Math.round(top.share * 100) + "%</b> of " +
1563
+ plural(f.total, "denial") + ", across <b>" + top.paths + "</b> path" + (top.paths === 1 ? "" : "s") + "." +
944
1564
  (top.share >= 0.4 && top.paths > 1
945
1565
  ? ' <span class="q">One name at that scale is usually mechanical. Fixing the tool ' +
946
1566
  'removes the denials; granting the paths only hides them.</span>'
947
- : ' <span class="q">No single name dominates \u2014 these are separate questions, ' +
1567
+ : ' <span class="q">No single name dominates — these are separate questions, ' +
948
1568
  'not one problem.</span>');
949
1569
 
950
1570
  document.getElementById("fa-list").innerHTML = fams.map(function(x){
951
1571
  var w = Math.max(2, Math.round((x.times / (top.times || 1)) * 100));
952
1572
  return '<div class="fam">' +
953
1573
  '<div class="fam-h"><code>' + esc(x.name) + '</code>' +
954
- '<span class="num">' + x.times.toLocaleString() + ' <s>' + (x.share*100).toFixed(1) + '%</s></span></div>' +
1574
+ '<span class="num">' + plural(x.times, "denial") + ' <s>' + (x.share*100).toFixed(1) + '%</s></span></div>' +
955
1575
  '<div class="t"><span class="f" style="width:' + w + '%"></span></div>' +
956
- '<div class="who">' + x.paths + ' path' + (x.paths===1?'':'s') + ' \u00b7 ' +
1576
+ '<div class="who">' + x.paths + ' path' + (x.paths===1?'':'s') + ' · ' +
957
1577
  x.roles.length + ' role' + (x.roles.length===1?'':'s') + ': ' + esc(x.roles.join(", ")) + '</div>' +
958
1578
  '<ul class="places">' + x.where.map(function(p){
959
1579
  return '<li><span>' + esc(p.target) + '</span><b>' + p.times.toLocaleString() + '</b></li>';
@@ -961,62 +1581,69 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
961
1581
  }).join("");
962
1582
  }
963
1583
 
964
- /** Walls — per role, what it keeps hitting and what that cost. */
1584
+ /** Retries on a list of walls: every call after the first denial of each. */
1585
+ function retriesOf(list){ return list.reduce(function(n, w){ return n + (w.retried !== undefined ? w.retried : w.times - 1); }, 0); }
1586
+
1587
+ /**
1588
+ * What a wall says under its path: the sentence Denied gives the same path, from the same
1589
+ * function. Two of them drifted — Walls put "has an owner" before "protected" and had no
1590
+ * case for a path outside the repository, so it told a person to add ~/.npmrc to a role's
1591
+ * keys where Denied said there was nothing to grant.
1592
+ */
1593
+ function wallSays(w){ return causeSays(w); }
1594
+
1595
+ /**
1596
+ * Walls — per role, what it keeps hitting and what that cost.
1597
+ *
1598
+ * One unit per number, said beside it. The role's figure was retries (times − 1 per wall)
1599
+ * and each row's was times, so a role with walls of 5× and 3× read "6" above two numbers
1600
+ * that add up to eight.
1601
+ */
965
1602
  function drawWalls(){
966
1603
  var rows = Object.keys(WALLS || {}).map(function(r){
967
1604
  var w = WALLS[r];
968
- return { role: r, walls: w, spent: w.reduce(function(n,x){ return n + x.times - 1; }, 0) };
1605
+ return { role: r, walls: w, spent: retriesOf(w), hits: w.reduce(function(n, x){ return n + x.times; }, 0) };
969
1606
  }).sort(function(a,b){ return b.spent - a.spent; });
970
1607
  var total = rows.reduce(function(n,x){ return n + x.spent; }, 0);
971
1608
 
972
1609
  document.getElementById("wl-lede").innerHTML = !rows.length
973
1610
  ? "No role is repeating a denial it has already been given."
974
- : "<b>" + total.toLocaleString() + "</b> call" + (total===1?"":"s") + " across <b>" +
975
- rows.length + "</b> role" + (rows.length===1?"":"s") +
976
- ' went into retrying something already denied. <span class="q">A denial is said once, ' +
1611
+ : "<b>" + plural(total, "retry", "retries") + "</b> across <b>" + plural(rows.length, "role") +
1612
+ '</b>: calls that tried again something already denied. <span class="q">A denial is said once, ' +
977
1613
  'mid-turn. Nothing carries it forward, so it gets tried again.</span>';
978
1614
 
979
1615
  document.getElementById("wl-list").innerHTML = rows.map(function(x){
980
1616
  return '<div class="fam">' +
981
1617
  '<div class="fam-h"><code>' + esc(x.role) + '</code>' +
982
- '<span class="num">' + x.spent.toLocaleString() + ' <s>calls retried</s></span></div>' +
1618
+ '<span class="num">' + plural(x.spent, "retry", "retries") + ' <s>of ' + plural(x.hits, "denial") + '</s></span></div>' +
983
1619
  '<ul class="places">' + x.walls.map(function(w){
984
1620
  return '<li><span>' + esc(w.action) + ' ' + esc(w.target) + '<br>' +
985
- '<em>' + esc(w.reason) + '</em></span><b>' + w.times + '\u00d7</b></li>';
1621
+ '<em>' + esc(wallSays(w)) + '</em></span><b>' + plural(w.times, "denial") + " · " +
1622
+ (w.retried !== undefined ? w.retried : w.times - 1) + " retried</b></li>";
986
1623
  }).join("") + '</ul></div>';
987
1624
  }).join("");
988
1625
  }
989
1626
 
990
- /** A count beside a nav item, or nothing. Zero is not news. */
991
- function pill(id, n){
1627
+ /** A count beside a nav item, or nothing — with its unit for a screen reader and a tooltip. */
1628
+ function pill(id, n, unit){
992
1629
  var el = document.getElementById(id);
993
1630
  if (!el) return;
994
- el.textContent = n ? (n > 999 ? Math.round(n/1000) + "k" : String(n)) : "";
1631
+ el.innerHTML = n ? esc(n > 999 ? Math.round(n/1000) + "k" : String(n)) + '<span class="sr"> ' + esc(unit) + "</span>" : "";
1632
+ el.title = n ? n.toLocaleString() + " " + unit : "";
995
1633
  el.hidden = !n;
996
1634
  }
997
1635
 
998
1636
  /**
999
- * The five numbers, on every screen.
1637
+ * The five numbers.
1000
1638
  *
1001
1639
  * Chosen by one rule: a KPI has to be something that can change and that you
1002
- * would do something about. The row this replaces failed both — four of its
1003
- * five read 31, 31, 0 and 31 every single day — and the one that moved was
1004
- * last and the same size as the rest.
1005
- *
1006
- * Emphasis is conditional, never constant. At zero they go quiet and say so
1007
- * in words, because "nothing to decide" is a state and an indicator that
1008
- * shouts at rest is one people stop seeing.
1009
- *
1010
- * Every value is read from the globals rather than passed in, so any refresh
1011
- * can call it — the queue refreshes on its own, and passing the role counts
1012
- * in once left the row stale on the number that moves most.
1640
+ * would do something about. Emphasis is conditional, never constant: at zero
1641
+ * they go quiet and say so in words.
1013
1642
  */
1014
1643
  function drawKpis(){
1015
1644
  var f = CAUSES || { total: 0, families: [], distinct: 0, causes: [] };
1016
1645
  var waiting = REQS.length;
1017
- var retry = Object.keys(WALLS || {}).reduce(function(n, r){
1018
- return n + WALLS[r].reduce(function(m, w){ return m + w.times - 1; }, 0);
1019
- }, 0);
1646
+ var retry = Object.keys(WALLS || {}).reduce(function(n, r){ return n + retriesOf(WALLS[r]); }, 0);
1020
1647
  var fam = f.families && f.families[0];
1021
1648
  var off = (ROLES || []).filter(function(r){ return !r.sandbox; }).length;
1022
1649
 
@@ -1031,59 +1658,69 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1031
1658
 
1032
1659
  set("k-wait", waiting ? "live" : "calm", waiting,
1033
1660
  waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
1034
- // The only number here that is an emergency, and it is almost always zero.
1035
1661
  set("k-open", off ? "alarm" : "calm", off,
1036
- off ? (off === 1 ? "role nothing is enforcing" : "roles nothing is enforcing") : "every role confined");
1662
+ off ? (off === 1 ? "role running unconfined" : "roles running unconfined") : "every role confined");
1037
1663
  set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
1038
- (f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied") +
1039
- (RANGE === "all" ? "" : " \u00b7 " + rangeLabel()));
1040
- // The shape, not the size: one name at 74% is a tooling problem, the same
1041
- // volume spread thin is a territory question, so the caption carries the name.
1042
- set("k-conc", fam && fam.share >= 0.4 ? "live" : "calm",
1043
- fam ? Math.round(fam.share * 100) + "%" : "\u2014",
1044
- fam ? "is " + fam.name : "of all denials");
1045
- // The only one that is money rather than state.
1664
+ (f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct things" : "nothing was denied") +
1665
+ (RANGE === "all" ? "" : " · " + rangeLabel()));
1666
+ // Under 40% no name is "the" cause, and the Denied page says so in words. The card said
1667
+ // "18% is index.lock" beside a lede saying nothing dominates.
1668
+ if (fam && fam.share >= 0.4)
1669
+ set("k-conc", "live", Math.round(fam.share * 100) + "%", "of denials are " + fam.name);
1670
+ else
1671
+ set("k-conc", "calm", fam ? "none" : "—",
1672
+ fam ? "no dominant cause · largest: " + fam.name + " " + Math.round(fam.share * 100) + "%" : "nothing was denied");
1046
1673
  set("k-retry", retry ? "live" : "calm", retry.toLocaleString(),
1047
- retry ? "calls on walls already denied" : "nothing retried");
1674
+ retry ? "retries on walls already denied" : "nothing retried");
1675
+
1676
+ pill("dn-pin", f.total, f.total === 1 ? "denial" : "denials");
1677
+ pill("wl-pin", retry, retry === 1 ? "retry on a wall" : "retries on walls");
1678
+ }
1048
1679
 
1049
- pill("dn-pin", f.total);
1050
- pill("wl-pin", Object.keys(WALLS || {}).length);
1680
+ /** What granting covers, said before the click: the file that was asked for, the folder
1681
+ * that would be granted, and whose it is. It said "wants to write docs/**" for a request
1682
+ * over docs/api.md, and the widening was a detail in grey under it. */
1683
+ function scopeOf(r){
1684
+ var owners = r.owners || [];
1685
+ var own = owners.length
1686
+ ? '<span class="own">' + esc(r.grant) + " belongs to <b>" + esc(owners.join(", ")) + "</b>. Granting lets <b>" +
1687
+ esc(r.role) + "</b> write it too.</span>"
1688
+ : '<span class="own">No role owns it today.</span>';
1689
+ if (r.action === "read")
1690
+ return '<div class="scope">Granting lets <b>' + esc(r.role) + "</b> read the key <code>" + esc(r.grant) + "</code>.</div>";
1691
+ if (r.grant === r.target)
1692
+ return '<div class="scope">Granting covers exactly <code>' + esc(r.grant) + "</code>." + own + "</div>";
1693
+ return '<div class="scope wide">Asked for <code>' + esc(r.target) + "</code> → granting covers <b>all of <code>" +
1694
+ esc(r.grant) + "</code></b>, not only that file." + own + "</div>";
1051
1695
  }
1052
1696
 
1053
1697
  function drawReqs(){
1054
- var box = document.getElementById("requests");
1055
- var pin = document.getElementById("req-pin");
1056
1698
  var host = document.getElementById("reqs");
1057
- pin.textContent = REQS.length ? String(REQS.length) : "";
1058
- // The box used to hide itself when empty — right, when it sat under the roles table and
1059
- // an empty one was just noise. Now that Requests is a view of its own, hiding it leaves
1060
- // a page with a heading and nothing under it. It stays, and says what would fill it.
1061
- box.hidden = false;
1699
+ pill("req-pin", REQS.length, REQS.length === 1 ? "request" : "requests");
1062
1700
  if (!REQS.length) {
1063
- // Compact when this is the landing view. The tall empty state was right
1064
- // when Requests was a page of its own — a heading with nothing under it
1065
- // reads as broken. At the top of the front page it is 180px of nothing
1066
- // standing between you and the only content there is.
1067
1701
  host.innerHTML = '<p class="blank quiet">No pending decisions. ' +
1068
1702
  '<span>Agents land here when they ask for a path the policy denies them.</span></p>';
1703
+ drawBulk();
1069
1704
  return;
1070
1705
  }
1071
1706
 
1072
1707
  host.innerHTML = REQS.map(function(r, i){
1073
- var owners = (r.owners && r.owners.length) ? " — belongs to <b>" + esc(r.owners.join(", ")) + "</b>" : "";
1074
- var times = r.times > 1 ? ' · <span class="asked">asked ' + r.times + "×</span>" : "";
1075
- // Marked, not moved: Approve and Decline post this row's id.
1708
+ var times = r.times > 1 ? 'asked ' + r.times + "×" : "asked once";
1709
+ var w = when(r.last || r.first);
1076
1710
  var stale = r.stale ? ' · not asked again in ' + Number(r.stale.runs) + ' runs since — likely no longer needed' : "";
1077
1711
  return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
1078
1712
  '<div class="who">' +
1079
- "<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) +
1080
- ' <span class="path">' + esc(r.grant) + "</span>" + owners + "</div>" +
1081
- '<div class="sub">first asked over ' + esc(r.target) + times + stale + "</div>" +
1713
+ "<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) + ' <span class="path">' + esc(r.target) + "</span></div>" +
1714
+ scopeOf(r) +
1715
+ '<div class="sub"><span class="asked">' + times + "</span>" +
1716
+ (w.text ? ' · last <span title="' + escA(w.title) + '">' + esc(w.text) + "</span>" : "") + stale + "</div>" +
1717
+ '<div class="err" id="err-' + i + '" role="alert" hidden></div>' +
1082
1718
  "</div>" +
1083
1719
  '<div class="act">' +
1084
- '<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
1085
- '<button class="btn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="granted" type="button">Approve</button>' +
1086
- '<button class="btn warn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="denied" type="button">Decline</button>' +
1720
+ '<input id="why-' + i + '" placeholder="reason (required)" aria-label="Reason for ' +
1721
+ escA(r.role) + '’s request" aria-describedby="err-' + i + '">' +
1722
+ '<button class="btn primary" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="granted" type="button" data-write>Grant</button>' +
1723
+ '<button class="btn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="declined" type="button" data-write>Decline</button>' +
1087
1724
  "</div>" +
1088
1725
  "</div>";
1089
1726
  }).join("");
@@ -1092,27 +1729,49 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1092
1729
  b.addEventListener("click", function(){ act(b); });
1093
1730
  });
1094
1731
  drawBulk();
1732
+ lockWrites();
1733
+ }
1734
+
1735
+ /** Two clicks: the first arms the button and says what the second does. */
1736
+ function arm(btn, text, rest){
1737
+ btn.dataset.armed = "1";
1738
+ btn.classList.add("armed");
1739
+ btn.textContent = text;
1740
+ clearTimeout(btn._t);
1741
+ btn._t = setTimeout(function(){ disarm(btn, rest); }, 5000);
1742
+ }
1743
+ function disarm(btn, rest){
1744
+ btn.dataset.armed = "";
1745
+ btn.classList.remove("armed");
1746
+ btn.textContent = rest;
1095
1747
  }
1096
1748
 
1097
1749
  /**
1098
- * Decline every request on screen — two clicks, no dialog.
1750
+ * Decline every request on screen — two clicks, with the reason in view.
1099
1751
  *
1100
- * There is no "approve all" on purpose: a grant is read one at a time. The
1752
+ * There is no "grant all" on purpose: a grant is read one at a time. The
1101
1753
  * keys sent are the ones drawn now, so a request that arrives in between is
1102
- * not refused unseen.
1754
+ * not declined unseen. A reason is required; an empty box is filled with a
1755
+ * default on the first click, so what is sent is on screen before it is sent.
1103
1756
  */
1104
1757
  function drawBulk(){
1105
1758
  var bulk = document.getElementById("bulk");
1106
1759
  var btn = document.getElementById("decline-all");
1760
+ var why = document.getElementById("why-all");
1107
1761
  bulk.hidden = REQS.length < 2;
1108
1762
  btn.disabled = false;
1109
- btn.dataset.armed = "";
1110
- btn.textContent = "Decline all (" + REQS.length + ")";
1763
+ var rest = "Decline all (" + REQS.length + ")";
1764
+ disarm(btn, rest);
1111
1765
  btn.onclick = async function(){
1112
1766
  if (!btn.dataset.armed) {
1113
- btn.dataset.armed = "1";
1114
- btn.textContent = "Click again to decline " + REQS.length;
1115
- setTimeout(function(){ if (btn.dataset.armed) { btn.dataset.armed = ""; btn.textContent = "Decline all (" + REQS.length + ")"; } }, 4000);
1767
+ if (!why.value.trim()) why.value = "declined in bulk from the console";
1768
+ arm(btn, "Click again to decline " + REQS.length + " with this reason", rest);
1769
+ return;
1770
+ }
1771
+ if (!LIVE) {
1772
+ DEMO.requests = [];
1773
+ decided("Declined " + plural(REQS.length, "request") + " in the example · reason «" + esc(why.value.trim()) + "». Nothing was saved.");
1774
+ note("declined all", why.value.trim());
1116
1775
  return;
1117
1776
  }
1118
1777
  btn.disabled = true;
@@ -1120,33 +1779,88 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1120
1779
  var r = await fetch("/api/decline-all", {
1121
1780
  method: "POST",
1122
1781
  headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
1123
- body: JSON.stringify({ keys: REQS.map(function(q){ return q.key; }),
1124
- reason: (document.getElementById("why-all") || {}).value || "declined in bulk from the console" })
1782
+ body: JSON.stringify({ keys: REQS.map(function(q){ return q.key; }), reason: why.value.trim() })
1125
1783
  });
1126
1784
  var out = await r.json();
1127
- if (!r.ok) { var err = new Error(out.error || "refused"); err.status = r.status; throw err; }
1785
+ if (!r.ok) { var err = new Error(out.error || "the console did not accept that"); err.status = r.status; throw err; }
1786
+ decided("Declined " + plural(out.declined, "request") + " · reason «" + esc(why.value.trim()) +
1787
+ "», recorded in the request queue. The policy was not changed.");
1788
+ why.value = "";
1128
1789
  await reload();
1129
1790
  } catch (e) {
1130
1791
  btn.disabled = false;
1131
- btn.textContent = "Decline all (" + REQS.length + ")";
1132
- var note = document.querySelector("#requests .note");
1133
- if (note) { note.textContent = whyFailed(e, e.status); note.style.color = "var(--amber)"; }
1792
+ disarm(btn, rest);
1793
+ var n = document.getElementById("req-note");
1794
+ n.textContent = whyFailed(e, e.status); n.style.color = "var(--amber)";
1134
1795
  }
1135
1796
  };
1136
1797
  }
1137
1798
 
1799
+ /** What the last decision did, until the next one. */
1800
+ function decided(html){
1801
+ var el = document.getElementById("decided");
1802
+ if (!html) { el.hidden = true; el.innerHTML = ""; return; }
1803
+ el.hidden = false;
1804
+ el.innerHTML = '<span class="txt">' + html + '</span><button type="button">dismiss</button>';
1805
+ el.querySelector("button").onclick = function(){ decided(null); };
1806
+ }
1807
+
1138
1808
  /**
1139
1809
  * One decision, sent once.
1140
1810
  *
1141
- * By the request's id, taken from the row as it was drawn: a position would
1142
- * be counted against a queue that agents are still writing to. A second click
1143
- * finds the request already settled and is refused.
1811
+ * By the request's id, taken from the row as it was drawn. A reason is required
1812
+ * for either answer. Granting a path that is another role's takes a second
1813
+ * click that names what is shared; declining with an empty box fills in the
1814
+ * default and asks again, so what is recorded is on screen before it is.
1144
1815
  */
1145
1816
  async function act(btn){
1146
1817
  var i = Number(btn.dataset.n);
1818
+ var req = REQS[i];
1147
1819
  var key = btn.dataset.k;
1148
- var why = (document.getElementById("why-" + i) || {}).value || "";
1820
+ var granting = btn.dataset.d === "granted";
1821
+ var input = document.getElementById("why-" + i);
1822
+ var err = document.getElementById("err-" + i);
1149
1823
  var row = btn.closest(".req");
1824
+ var rest = granting ? "Grant" : "Decline";
1825
+ function fail(msg){ err.hidden = false; err.textContent = msg; }
1826
+ err.hidden = true;
1827
+ input.removeAttribute("aria-invalid");
1828
+
1829
+ var filled = false;
1830
+ if (!input.value.trim()) {
1831
+ if (granting) {
1832
+ input.setAttribute("aria-invalid", "true");
1833
+ fail("Write a reason first — it is saved next to the grant in seisin.toml.");
1834
+ input.focus();
1835
+ return;
1836
+ }
1837
+ input.value = "declined from the console";
1838
+ filled = true;
1839
+ }
1840
+ var needsArm = granting ? (req.owners || []).length > 0 : filled;
1841
+ if (needsArm && !btn.dataset.armed) {
1842
+ arm(btn, granting ? "Click again to share " + req.grant + " with " + req.role
1843
+ : "Click again to decline with this reason", rest);
1844
+ return;
1845
+ }
1846
+ disarm(btn, rest);
1847
+ var why = input.value.trim();
1848
+
1849
+ if (!LIVE) {
1850
+ if (granting) {
1851
+ var r0 = ROLES.filter(function(x){ return x.name === req.role; })[0];
1852
+ if (r0 && r0.writes.indexOf(req.grant) === -1) r0.writes.push(req.grant);
1853
+ }
1854
+ DEMO.requests = DEMO.requests.filter(function(q){ return q.role + ":" + q.action + ":" + q.target !== key; });
1855
+ decided(granting
1856
+ ? "Granted <code>" + esc(req.grant) + "</code> to <b>" + esc(req.role) + "</b> in the example. Live, this line is written to seisin.toml:" +
1857
+ '<span class="ln">"' + esc(req.grant) + '" # granted … · «' + esc(why) + "»</span>Nothing was saved. " +
1858
+ '<a href="#configfile">see the example seisin.toml</a>'
1859
+ : "Declined <b>" + esc(req.role) + "</b>’s request for <code>" + esc(req.grant) + "</code> in the example · reason «" + esc(why) + "».");
1860
+ note(granting ? "granted" : "declined", req.role + " · " + req.grant);
1861
+ return;
1862
+ }
1863
+
1150
1864
  row.querySelectorAll("button,input").forEach(function(e){ e.disabled = true; });
1151
1865
  try {
1152
1866
  var r = await fetch("/api/decide", {
@@ -1155,47 +1869,43 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1155
1869
  body: JSON.stringify({ key: key, decision: btn.dataset.d, reason: why })
1156
1870
  });
1157
1871
  var out = await r.json();
1158
- if (!r.ok) { var err = new Error(out.error || "denied"); err.status = r.status; throw err; }
1872
+ if (!r.ok) { var e2 = new Error(out.error || "the console did not accept that"); e2.status = r.status; throw e2; }
1873
+ if (granting) {
1874
+ MARK = out.line ? out.line.number : null;
1875
+ decided("Granted <code>" + esc(out.grant) + "</code> to <b>" + esc(out.role) + "</b>" +
1876
+ (out.line
1877
+ ? " · written to seisin.toml, line " + out.line.number + ':<span class="ln">' + esc(out.line.text) + "</span>"
1878
+ : " · seisin.toml already had it; nothing new was written. ") +
1879
+ '<a href="#configfile">see Config file' + (out.line ? ", line " + out.line.number : "") + "</a>");
1880
+ } else {
1881
+ decided("Declined <b>" + esc(out.role) + "</b>’s request for <code>" + esc(out.grant) + "</code> · reason «" + esc(why) +
1882
+ "», recorded in the request queue. The policy was not changed.");
1883
+ }
1159
1884
  // Re-read rather than patch: the grant rewrote seisin.toml, so the
1160
1885
  // territory on screen is stale too, not just the queue.
1161
1886
  await reload();
1162
1887
  } catch (e) {
1163
1888
  row.querySelectorAll("button,input").forEach(function(el){ el.disabled = false; });
1164
- var s = row.querySelector(".sub");
1165
- s.innerHTML = '<span style="color:var(--amber)">' + esc(whyFailed(e, e.status)) + "</span>";
1889
+ fail(whyFailed(e, e.status));
1890
+ lockWrites();
1166
1891
  }
1167
1892
  }
1168
1893
 
1169
1894
  /**
1170
- * One view failing must not take the others down.
1171
- *
1172
- * Measured the hard way: `drawCauses` kept a reference to a box that had moved
1173
- * to its own page, threw on the first line, and every number on the screen —
1174
- * including the five that have nothing to do with it — rendered as zero. A
1175
- * console whose KPIs read zero because of a typo is worse than one that is
1176
- * down, because zero is a plausible answer.
1177
- */
1178
- function draw(){
1179
- [drawRows, drawCfg, drawToml, drawLog, drawReqs, drawCauses, drawFamilies, drawWalls]
1180
- .forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
1181
- }
1182
-
1183
- /**
1184
- * The page works two ways on purpose.
1185
- *
1186
- * Served by `seisin ui`, it reads the real config and the real log. Opened as
1187
- * a plain file — from the repo, from a docs site — the fetch fails and it
1188
- * falls back to the example policy below, so the console is still something
1189
- * you can look at before installing anything. What it must never do is show
1190
- * example data while looking live, so the label says which one you are in.
1895
+ * One view failing must not take the others down: a console whose KPIs read zero because
1896
+ * of a typo is worse than one that is down, because zero is a plausible answer.
1191
1897
  */
1898
+ function draw(){
1899
+ [drawRows, drawCfg, drawControls, drawToml, drawLog, drawReqs, drawCauses, drawFamilies, drawWalls]
1900
+ .forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
1901
+ }
1902
+
1192
1903
  /**
1193
1904
  * This run's token, from the link `seisin ui` opened.
1194
1905
  *
1195
1906
  * It arrives in the fragment (#t=…), which the browser never sends to any
1196
1907
  * server, and it is moved into this tab's storage and wiped from the address
1197
- * bar and the history entry at once. The server no longer puts it in the
1198
- * page: anything that could GET "/" used to walk away with it.
1908
+ * bar and the history entry at once.
1199
1909
  */
1200
1910
  var TOKEN = (function(){
1201
1911
  var m = /(?:^#|&)t=([a-f0-9]+)/.exec(location.hash || "");
@@ -1207,31 +1917,62 @@ function draw(){
1207
1917
  } catch (_) { return m ? m[1] : ""; }
1208
1918
  })();
1209
1919
 
1210
- /**
1211
- * What to tell a person when a request to this server fails.
1212
- *
1213
- * "Failed to fetch" was all the browser said when the console had been
1214
- * restarted under an open tab — the server was gone, or back with a new
1215
- * token that this tab does not hold. Both have the same fix, and it is not
1216
- * obvious from either message.
1217
- */
1920
+ /** What to tell a person when a request to this server fails. */
1218
1921
  function whyFailed(e, status){
1219
- if (status === 403)
1220
- return "this tab's link is stale — the console restarted. Open the new link `seisin ui` printed.";
1922
+ if (status === 401 || status === 403)
1923
+ return "this tab's link has expired — the console was restarted. Open the new one: seisin ui --link";
1221
1924
  if (e && /network|fetch/i.test(String(e.message || e)))
1222
- return "the console did not answer. If `seisin ui` restarted, open the new link it printed.";
1925
+ return "the console did not answer. If you stopped `seisin ui`, start it again and open the link it prints.";
1223
1926
  return (e && e.message) || "failed";
1224
1927
  }
1225
1928
 
1929
+ /* ── offline ──────────────────────────────────────────────────────────────
1930
+ * The header said "live" in green for as long as the tab was open, whether or
1931
+ * not anything was answering, and Grant stayed clickable against a server
1932
+ * that was gone or had a new token. After two failed polls in a row the page
1933
+ * says which of the two it is, since when, and turns the writes off. */
1934
+ var FAILS = 0, OFFLINE = null;
1935
+ function setOffline(kind){
1936
+ if (kind && OFFLINE && OFFLINE.kind === kind) return;
1937
+ OFFLINE = kind ? { kind: kind, since: (OFFLINE && OFFLINE.since) || new Date() } : null;
1938
+ var label = document.getElementById("source");
1939
+ var ban = document.getElementById("off-banner");
1940
+ if (!OFFLINE) {
1941
+ label.textContent = LIVE_LABEL; label.style.color = "var(--green)";
1942
+ ban.hidden = true; ban.innerHTML = "";
1943
+ } else {
1944
+ var t = pad(OFFLINE.since.getHours()) + ":" + pad(OFFLINE.since.getMinutes());
1945
+ label.style.color = "var(--amber)";
1946
+ if (kind === "expired") {
1947
+ label.textContent = "link expired — open the new one";
1948
+ ban.innerHTML = "<b>This tab's link has expired</b> (since " + t + "): the console was restarted with a new one. " +
1949
+ "Open the new link — run <code>seisin ui --link</code> in the repository. Decisions and saves are off in this tab.";
1950
+ } else {
1951
+ label.textContent = "offline since " + t;
1952
+ ban.innerHTML = "<b>The console is not answering</b> (since " + t + "). What is on screen is as of then. " +
1953
+ "Decisions and saves are off until it answers again; if you stopped <code>seisin ui</code>, start it and open the link it prints.";
1954
+ }
1955
+ ban.hidden = false;
1956
+ }
1957
+ lockWrites();
1958
+ }
1959
+ /** Every control that writes is off while the server is not there to take it. */
1960
+ function lockWrites(){
1961
+ document.querySelectorAll("[data-write]").forEach(function(b){
1962
+ if (OFFLINE) { b.disabled = true; b.title = "the console is not answering"; }
1963
+ else if (b.title === "the console is not answering") { b.disabled = false; b.title = ""; }
1964
+ });
1965
+ }
1966
+
1226
1967
  /* ── time window ──────────────────────────────────────────────────────── */
1227
1968
  // "1h" … "30d", "all", or "from:<ISO>". Remembered per browser, as a convenience.
1228
1969
  var RANGE = "all";
1229
1970
  try { RANGE = localStorage.getItem("seisin.range") || "all"; } catch (_) {}
1230
1971
  var SPANS = { "1h": 36e5, "4h": 4 * 36e5, "24h": 24 * 36e5, "7d": 7 * 864e5, "30d": 30 * 864e5 };
1972
+ if (!SPANS[RANGE] && RANGE !== "all" && RANGE.indexOf("from:") !== 0) RANGE = "all";
1231
1973
 
1232
- // Worked out on every request, so "last 4h" keeps moving while the page is open.
1233
1974
  function sinceIso(){
1234
- if (SPANS[RANGE]) return new Date(Date.now() - SPANS[RANGE]).toISOString();
1975
+ if (SPANS[RANGE]) return new Date(Math.floor((Date.now() - SPANS[RANGE]) / 6e4) * 6e4).toISOString();
1235
1976
  if (RANGE.indexOf("from:") === 0) return RANGE.slice(5);
1236
1977
  return null;
1237
1978
  }
@@ -1244,165 +1985,270 @@ function draw(){
1244
1985
  if (RANGE.indexOf("from:") === 0) return "since " + new Date(RANGE.slice(5)).toLocaleString();
1245
1986
  return "all time";
1246
1987
  }
1988
+ var CUSTOM = false; // the date field is open
1247
1989
  function drawRange(){
1990
+ var from = document.getElementById("range-from");
1991
+ var isFrom = RANGE.indexOf("from:") === 0;
1248
1992
  document.querySelectorAll("#range .seg button").forEach(function(b){
1249
- b.classList.toggle("on", b.dataset.r === RANGE);
1993
+ var on = b.dataset.r === "custom" ? (isFrom || CUSTOM) : (b.dataset.r === RANGE && !CUSTOM);
1994
+ b.classList.toggle("on", on);
1995
+ b.setAttribute("aria-pressed", on ? "true" : "false");
1250
1996
  });
1251
- var from = document.getElementById("range-from");
1252
- from.classList.toggle("on", RANGE.indexOf("from:") === 0);
1253
- if (RANGE.indexOf("from:") !== 0) from.value = "";
1997
+ document.getElementById("range-from-box").hidden = !(isFrom || CUSTOM);
1998
+ if (!isFrom) from.value = "";
1254
1999
  }
1255
2000
  function setRange(r){
1256
2001
  RANGE = r;
2002
+ CUSTOM = false;
1257
2003
  try { localStorage.setItem("seisin.range", r); } catch (_) {}
1258
2004
  drawRange();
1259
2005
  reload().catch(function(){ /* server went away: keep what is on screen */ });
1260
2006
  }
1261
2007
  document.querySelectorAll("#range .seg button").forEach(function(b){
1262
- b.addEventListener("click", function(){ setRange(b.dataset.r); });
2008
+ b.addEventListener("click", function(){
2009
+ if (b.dataset.r === "custom") { CUSTOM = true; drawRange(); document.getElementById("range-from").focus(); return; }
2010
+ setRange(b.dataset.r);
2011
+ });
1263
2012
  });
1264
2013
  document.getElementById("range-from").addEventListener("change", function(e){
1265
2014
  var t = Date.parse(e.target.value); // datetime-local is local time, which Date.parse reads as such
1266
2015
  setRange(isNaN(t) ? "all" : "from:" + new Date(t).toISOString());
1267
2016
  });
1268
2017
 
2018
+ /**
2019
+ * One read of the state, for boot, the poll and the reload after a decision.
2020
+ * The poll sends back the tag of what it last got; 304 when nothing moved.
2021
+ */
2022
+ var LAST = { url: null, etag: null };
2023
+ async function fetchState(fresh){
2024
+ var url = stateUrl();
2025
+ var h = { "x-seisin-token": TOKEN };
2026
+ if (!fresh && LAST.etag && LAST.url === url) h["if-none-match"] = LAST.etag;
2027
+ var r = await fetch(url, { cache: "no-store", headers: h });
2028
+ if (r.status === 304) return { same: true };
2029
+ if (!r.ok) return { status: r.status };
2030
+ var s = await r.json();
2031
+ if (s.error) return { status: r.status, error: s.error };
2032
+ LAST = { url: url, etag: r.headers.get("etag") };
2033
+ return { s: s };
2034
+ }
2035
+
2036
+ /**
2037
+ * What the server (or the example) said, applied — and only what changed is redrawn,
2038
+ * so a table someone is reading or a reason being typed is not thrown away every two
2039
+ * seconds.
2040
+ */
2041
+ var SEEN = {};
2042
+ function changed(part, v){
2043
+ var j = JSON.stringify(v === undefined ? null : v);
2044
+ if (SEEN[part] === j) return false;
2045
+ SEEN[part] = j;
2046
+ return true;
2047
+ }
2048
+ function applyState(s){
2049
+ var shape = s.roles.map(function(x){ var y = Object.assign({}, x); delete y.blocks; return y; });
2050
+ var policy = changed("toml", s.toml);
2051
+ policy = changed("shape", shape) || policy;
2052
+ policy = changed("keyDirs", s.keyDirs) || policy;
2053
+ policy = changed("protect", s.protect) || policy;
2054
+ BASE = s.base || null;
2055
+ if (policy && PENDING && PENDING.plan && PENDING.plan.base !== BASE) {
2056
+ PENDING.plan = null;
2057
+ PENDING.error = "seisin.toml changed on disk since this preview. Toggle again to see the change against what is there now.";
2058
+ }
2059
+ if (policy) {
2060
+ KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
2061
+ KEYS = s.allKeys ? s.allKeys.slice() : [...new Set(s.roles.flatMap(r => r.keys))].map(k => k.replace(/\.[^.]+$/, ""));
2062
+ ROLES = s.roles.map(r => ({
2063
+ name: r.name,
2064
+ writes: r.writes.slice(),
2065
+ keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
2066
+ sandbox: r.sandbox !== false,
2067
+ blocks: r.blocks,
2068
+ neverWrites: r.neverWrites || [],
2069
+ localBinding: !!r.localBinding,
2070
+ localPorts: (r.localPorts || []).slice(),
2071
+ trustd: !!r.trustd,
2072
+ controlFiles: (r.controlFiles || []).slice()
2073
+ }));
2074
+ PROTECT = s.protect || { instructions: false };
2075
+ ROOT = s.root || "";
2076
+ ONDISK = s.toml || null;
2077
+ SETTINGS = {};
2078
+ if (!ROLES.some(function(r){ return r.name === sel; })) sel = (ROLES[0] || {}).name || "";
2079
+ }
2080
+ var blocks = changed("blocks", s.roles.map(function(x){ return [x.name, x.blocks]; }));
2081
+ if (blocks && !policy) ROLES.forEach(function(r2){
2082
+ var fresh = s.roles.find(function(x){ return x.name === r2.name; });
2083
+ if (fresh) r2.blocks = fresh.blocks;
2084
+ });
2085
+ var log = changed("log", s.log);
2086
+ if (log) LOG = fromServer(s.log);
2087
+ var reqs = changed("requests", s.requests || []);
2088
+ if (reqs) REQS = s.requests || [];
2089
+ var causes = changed("causes", s.causes);
2090
+ if (causes) CAUSES = s.causes || null;
2091
+ var walls = changed("walls", s.walls);
2092
+ if (walls) WALLS = s.walls || {};
2093
+
2094
+ var views = [];
2095
+ if (policy) views.push(drawRows, drawCfg, drawControls, drawToml);
2096
+ else if (blocks) views.push(drawRows);
2097
+ if (log) views.push(drawLog);
2098
+ if (reqs) views.push(drawReqs);
2099
+ if (causes) views.push(drawCauses, drawFamilies);
2100
+ if (walls) views.push(drawWalls);
2101
+ views.push(drawKpis);
2102
+ views.forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
2103
+ }
2104
+
2105
+ /** One role's settings, asked of the server once per policy. */
2106
+ var ASKING = {};
2107
+ async function loadSettings(name){
2108
+ if (ASKING[name]) return;
2109
+ ASKING[name] = true;
2110
+ try {
2111
+ var r = await fetch("/api/settings?role=" + encodeURIComponent(name),
2112
+ { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
2113
+ var out = await r.json();
2114
+ SETTINGS[name] = r.ok ? { settings: out.settings } : { error: out.error || "could not read the settings" };
2115
+ } catch (e) {
2116
+ SETTINGS[name] = { error: whyFailed(e) };
2117
+ } finally {
2118
+ delete ASKING[name];
2119
+ }
2120
+ if (name === sel) { try { drawCfg(); } catch (e) { console.error("seisin: drawCfg failed", e); } }
2121
+ }
2122
+
2123
+ var LIVE_LABEL = "";
1269
2124
  async function boot(){
1270
- let s = null, stale = false;
2125
+ let got = null, stale = false;
1271
2126
  try {
1272
- const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
1273
- if (r.ok) s = await r.json();
2127
+ // Opened as a file there is no server to ask; the fetch only printed a CORS error.
2128
+ if (location.protocol === "file:") throw new Error("no server");
2129
+ got = await fetchState(true);
1274
2130
  // A live server that refuses this tab's token: `seisin ui` was restarted
1275
2131
  // and minted a new one. Falling back to the example here showed a policy
1276
2132
  // that is not the reader's, one F5 after a restart, with nothing saying why.
1277
- stale = r.status === 403;
1278
- } catch (_) { /* opened as a file: stay in demo */ }
2133
+ stale = got.status === 403 || got.status === 401;
2134
+ } catch (_) { /* opened as a file: stay in the example */ }
2135
+ const s = got && got.s;
1279
2136
 
1280
2137
  const label = document.getElementById("source");
1281
2138
  if (stale) {
1282
- label.textContent = "link from an earlier run";
2139
+ label.textContent = "link expired — open the new one";
1283
2140
  label.style.color = "var(--amber)";
1284
2141
  document.querySelector("main").innerHTML =
1285
2142
  '<div class="box" style="padding:22px 24px;max-width:640px">' +
1286
2143
  '<h2 style="margin:0 0 8px;font-size:17px">This tab has the link from an earlier run</h2>' +
1287
- '<p style="margin:0 0 8px">`seisin ui` was restarted, and every run gets a new token. ' +
1288
- 'Open the new link it printed in the terminal (it starts with ' +
1289
- '<code>http://127.0.0.1:' + location.port + '/#t=</code>).</p>' +
2144
+ '<p style="margin:0 0 8px"><code>seisin ui</code> was restarted, and every run gets a new link. ' +
2145
+ 'Open the new one: run <code>seisin ui --link</code> in the repository, or use the link it printed in the terminal.</p>' +
1290
2146
  '<p style="margin:0;color:var(--mid)">Nothing here is shown until then, so this page never ' +
1291
2147
  'passes the example policy off as yours.</p></div>';
1292
2148
  return;
1293
2149
  }
1294
- if (!s || s.error) {
2150
+ if (!s) {
1295
2151
  label.textContent = location.protocol.startsWith("http") && !TOKEN
1296
- ? "no token — open the link `seisin ui` printed · showing an example"
1297
- : "example policy — not your repo";
2152
+ ? "no token — showing an example"
2153
+ : "example — not your repo";
1298
2154
  label.style.color = "var(--amber)";
2155
+ document.getElementById("demo-banner").hidden = false;
2156
+ if (location.protocol.startsWith("http") && !TOKEN)
2157
+ document.getElementById("demo-banner").innerHTML += " This tab has no link token: open the link <code>seisin ui</code> printed, or run <code>seisin ui --link</code>.";
2158
+ applyState(demoState());
1299
2159
  draw();
2160
+ show(route());
1300
2161
  return;
1301
2162
  }
1302
2163
 
1303
- KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
1304
- KEYS = [...new Set(s.roles.flatMap(r => r.keys))].map(k => k.replace(/\.[^.]+$/, ""));
1305
- ROLES = s.roles.map(r => ({
1306
- name: r.name,
1307
- writes: r.writes.slice(),
1308
- keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
1309
- sandbox: true,
1310
- blocks: r.blocks,
1311
- // The three role keys that change the profile, drawn from what the
1312
- // server says rather than typed into this page (review of 2026-09-22, #5).
1313
- neverWrites: r.neverWrites || [],
1314
- localBinding: !!r.localBinding,
1315
- localPorts: (r.localPorts || []).slice(),
1316
- trustd: !!r.trustd
1317
- }));
1318
- LOG = fromServer(s.log);
1319
- REQS = s.requests || [];
1320
- CAUSES = s.causes || null;
1321
- WALLS = s.walls || {};
1322
- ONDISK = s.toml || null;
1323
- // The demo selection does not survive the live load. `sel` starts at "frontend", a role
1324
- // from the example policy, and a real repo has no reason to have one. `drawCfg` then
1325
- // reads `.name` off `undefined` and throws — which takes down everything `draw` renders
1326
- // after it, so the generated-settings box, the activity list AND the seisin.toml box all
1327
- // come up blank at once while the data behind them is fine. One stale default, three
1328
- // empty panels, and no error anywhere the reader can see.
1329
- if (!ROLES.some(function(r){ return r.name === sel; })) sel = (ROLES[0] || {}).name || "";
1330
- label.textContent = s.config.replace(/^.*\//, "") + " · live";
2164
+ LIVE = true;
2165
+ applyState(s);
2166
+ LIVE_LABEL = s.config.replace(/^.*\//, "") + " · live";
2167
+ label.textContent = LIVE_LABEL;
1331
2168
  label.style.color = "var(--green)";
1332
- // The footer used to claim this page never touches your repo. It reads it
1333
- // on every poll, and approving a request rewrites seisin.toml. Saying
1334
- // otherwise in the one place a cautious reader looks was the worst of the
1335
- // two possible lies.
1336
- // Live, the button goes for good: this page edits a policy in memory, and "unsandbox
1337
- // everything" against a real repo is not a preview. `dataset.live` is what `show()`
1338
- // reads, so the router never brings it back when you return to Roles.
2169
+ // Live, the what-if button goes for good: "run every role unconfined" against a real repo is
2170
+ // not a preview. `dataset.live` is what `show()` reads.
1339
2171
  document.getElementById("kill").dataset.live = "1";
1340
2172
  document.getElementById("range").hidden = false;
1341
2173
  drawRange();
1342
2174
  document.getElementById("kill").hidden = true;
2175
+ // And so does the card that counts what the button would do: live it could only say 0.
2176
+ document.getElementById("k-open").style.display = "none";
1343
2177
  document.getElementById("toml-note").textContent = "the file on disk, as it stands";
1344
2178
  document.getElementById("foot-mode").textContent = "Live.";
1345
2179
  document.getElementById("foot-text").innerHTML =
1346
- "This page reads <b>" + esc(s.config) + "</b> and the log on disk. Editing the policy above is a preview \u2014 copy the " +
1347
- "<b>seisin.toml</b> to keep it. Approving a request is the one action that writes: it edits your config in place, with its reason.";
2180
+ "This page reads <b>" + esc(s.config) + "</b> and the log on disk. Two actions write, each in place and stamped " +
2181
+ "with its reason: a decision on a request, and saving a control-files change after its preview. Territories " +
2182
+ "and keys are changed in seisin.toml itself.";
1348
2183
  draw();
1349
- setInterval(refreshLog, 2000);
2184
+ show(route());
2185
+ poll();
1350
2186
  }
1351
2187
 
1352
- /* ── the four views ─────────────────────────────────────────────────────────────────
2188
+ /** Every two seconds after the last answer, not every two seconds regardless. */
2189
+ function poll(){
2190
+ setTimeout(async function(){
2191
+ try { await refresh(); } finally { poll(); }
2192
+ }, 2000);
2193
+ }
2194
+
2195
+ /* ── the views ────────────────────────────────────────────────────────────
1353
2196
  * Hash routing and not a framework: this page is one file a server hands over whole, and
1354
- * it has to keep working opened straight off the disk with no server at all. A hash also
1355
- * means a view is a URL you can send someone — "look at Activity" is a link.
1356
- *
1357
- * The old hrefs (`#requests`, `#activity`, `#configfile`) are kept as the route names they
1358
- * already were, so a bookmark from before still lands where it used to. */
1359
- var VIEWS = {
1360
- policy: {view:"roles", title:"Roles",
1361
- sub:"Which folders each agent writes, which keys it may read, and whether it runs sandboxed."},
1362
- requests: {view:"requests", title:"Waiting on you",
1363
- sub:"Permissions an agent asked for and cannot have. Granting one edits your policy."},
1364
- denied: {view:"denied", title:"Denied",
1365
- sub:"Every denial in the window, grouped by the path it was about."},
1366
- causes: {view:"causes", title:"Causes",
1367
- sub:"The same denials by name, which is the cut that says what kind of day it was."},
1368
- walls: {view:"walls", title:"Walls",
1369
- sub:"What each role keeps being denied, and what retrying it cost."},
1370
- activity: {view:"activity", title:"Activity",
1371
- sub:"Every decision the runtime made, newest last, each with the role that owns the path."},
1372
- configfile: {view:"config", title:"Config file",
1373
- sub:"The policy as it stands on disk. Editing the table is a preview \u2014 this is the file."}
1374
- };
2197
+ * it has to keep working opened straight off the disk with no server at all. */
2198
+ function views(){
2199
+ return {
2200
+ policy: {view:"roles", title:"Roles",
2201
+ sub:"Each role's territory — the folders it may write — and the keys it may read. " +
2202
+ (LIVE ? "Read-only here: the policy is seisin.toml." : "In this example you can edit them; nothing is saved.")},
2203
+ requests: {view:"requests", title:"Waiting on you",
2204
+ sub:"Requests: access an agent asked for and cannot have. Granting one adds a line to seisin.toml."},
2205
+ denied: {view:"denied", title:"Denied", tab:"path",
2206
+ sub:"Everything an agent tried in the window and the OS denied, grouped by what it was about."},
2207
+ causes: {view:"denied", title:"Denied", tab:"name",
2208
+ sub:"The same denials grouped by file name — the cut that says whether a day was a tooling problem or a territory question."},
2209
+ walls: {view:"walls", title:"Walls",
2210
+ sub:"A wall is something a role was denied twice or more and would still be denied today. Retrying one costs a call every time."},
2211
+ activity: {view:"activity", title:"Activity",
2212
+ sub:"Every log entry, newest first — hook and kernel — each denial with whose it was."},
2213
+ configfile: {view:"config", title:"Config file",
2214
+ sub: LIVE ? "The policy as it stands on disk. Decisions and control-files saves write here, stamped."
2215
+ : "The example policy, generated in this page. Live, this is your seisin.toml as it is on disk."}
2216
+ };
2217
+ }
1375
2218
 
1376
2219
  function show(route){
1377
- var v = VIEWS[route] || VIEWS.requests;
2220
+ var all = views();
2221
+ var v = all[route] || all.requests;
1378
2222
  document.querySelectorAll("main section[data-view]").forEach(function(s){
1379
2223
  s.hidden = s.getAttribute("data-view") !== v.view;
1380
2224
  });
1381
2225
  document.getElementById("v-title").textContent = v.title;
1382
2226
  document.getElementById("v-sub").textContent = v.sub;
1383
- // "Unsandbox everything" acts on the policy table. Offering it from Activity would be a
1384
- // button that changes something you cannot see.
2227
+ if (v.view === "denied") {
2228
+ var byName = v.tab === "name";
2229
+ document.getElementById("dn-path").hidden = byName;
2230
+ document.getElementById("dn-name").hidden = !byName;
2231
+ document.getElementById("tab-path").setAttribute("aria-selected", byName ? "false" : "true");
2232
+ document.getElementById("tab-name").setAttribute("aria-selected", byName ? "true" : "false");
2233
+ }
2234
+ // Full cards on the landing view; a strip elsewhere.
2235
+ document.getElementById("kpis").classList.toggle("compact", v.view !== "requests");
1385
2236
  var kill = document.getElementById("kill");
1386
2237
  if (kill) kill.hidden = (v.view !== "roles") || kill.dataset.live === "1";
2238
+ var navRoute = route === "causes" ? "denied" : route;
1387
2239
  document.querySelectorAll(".side .list a[href^='#']").forEach(function(a){
1388
- var on = a.getAttribute("href").slice(1) === route;
2240
+ var on = a.getAttribute("href").slice(1) === navRoute;
1389
2241
  a.classList.toggle("on", on);
1390
2242
  if (on) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current");
1391
2243
  });
1392
- // Back to the top on a view change: landing halfway down a table you have never seen is
1393
- // what the anchors used to do.
1394
2244
  window.scrollTo(0, 0);
2245
+ // Straight to the line the last save wrote.
2246
+ var m = v.view === "config" && document.getElementById("toml-mark");
2247
+ if (m) m.scrollIntoView({ block: "center" });
1395
2248
  }
1396
2249
 
1397
- /**
1398
- * The landing view is what is waiting on you, not the policy table.
1399
- *
1400
- * It opened on Roles, which is reference: a wall of paths you consult when you
1401
- * have a question, rendered before anybody asked one. What a person opens this
1402
- * for is "does anything need me", and that has to be the first screen or it is
1403
- * a screen nobody reaches. Reference is looked up; decisions are shown.
1404
- */
1405
- function route(){ return (location.hash || "#requests").slice(1); }
2250
+ /** The landing view is what is waiting on you, not the policy table. */
2251
+ function route(){ return (location.hash || "#requests").slice(1); }
1406
2252
  window.addEventListener("hashchange", function(){ show(route()); });
1407
2253
  show(route());
1408
2254
 
@@ -1411,8 +2257,6 @@ function route(){ return (location.hash || "#requests").slice(1); }
1411
2257
  var f = document.getElementById("f");
1412
2258
  if (!f) return;
1413
2259
  f.addEventListener("input", function(){ FILTER = f.value.trim(); drawRows(); });
1414
- // Escape clears without reaching for the mouse: the field is meant to be typed into and
1415
- // abandoned dozens of times while reading, not filled in once like a form.
1416
2260
  f.addEventListener("keydown", function(e){
1417
2261
  if (e.key === "Escape" && f.value) { e.preventDefault(); f.value = ""; FILTER = ""; drawRows(); }
1418
2262
  });
@@ -1421,60 +2265,35 @@ function route(){ return (location.hash || "#requests").slice(1); }
1421
2265
  /** The server's entries, in the shape the renderer already speaks. */
1422
2266
  function fromServer(entries){
1423
2267
  return (entries || []).map(e => ({
1424
- t: (e.at || "").slice(11, 19),
1425
- kind: e.verdict === "denied" ? "deny" : e.verdict === "observed" ? "admin" : "allow",
2268
+ at: e.at || "",
2269
+ kind: e.verdict === "denied" ? "deny" : e.verdict === "observed" ? "observed" : e.verdict === "admin" ? "admin" : "allow",
1426
2270
  role: e.role,
1427
2271
  act: e.action,
1428
2272
  target: e.target,
1429
2273
  owners: e.owners || [],
1430
- // Which writer produced it. Absent on hook lines and on every line from before the
1431
- // kernel could speak, and absent is what the renderer treats as "the hook".
2274
+ about: e.kind || "file",
1432
2275
  source: e.source
1433
2276
  }));
1434
2277
  }
1435
2278
 
1436
- async function refreshLog(){
1437
- try {
1438
- const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
1439
- if (!r.ok) return;
1440
- const s = await r.json();
1441
- if (s.error) return;
1442
- LOG = fromServer(s.log);
1443
- // The queue is filled by the hook, in another process, so it arrives here
1444
- // the same way the log does: by polling. Re-rendered only when it changed,
1445
- // so a reason someone is halfway through typing is not wiped every 2s.
1446
- var fresh = JSON.stringify(s.requests || []);
1447
- if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); drawKpis(); }
1448
- // Only the counters that come from the log are refreshed. Whatever the
1449
- // reader has been editing on screen stays put — a page that resets your
1450
- // work every two seconds is worse than one that does not update.
1451
- ROLES.forEach(r2 => {
1452
- const fresh = s.roles.find(x => x.name === r2.name);
1453
- if (fresh) r2.blocks = fresh.blocks;
1454
- });
1455
- drawLog(); drawRows();
1456
- } catch (_) { /* server went away: keep what is on screen */ }
2279
+ async function refresh(){
2280
+ var got;
2281
+ try { got = await fetchState(false); } catch (_) { got = { down: true }; }
2282
+ if (got.s || got.same) {
2283
+ FAILS = 0;
2284
+ if (OFFLINE) setOffline(null);
2285
+ if (got.s) applyState(got.s);
2286
+ return;
2287
+ }
2288
+ FAILS++;
2289
+ if (FAILS >= 2) setOffline(got.status === 401 || got.status === 403 ? "expired" : "down");
1457
2290
  }
1458
2291
 
1459
2292
  /** Everything again, after a decision rewrote the policy on disk. */
1460
2293
  async function reload(){
1461
- const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
1462
- const s = await r.json();
1463
- if (s.error) return;
1464
- KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
1465
- KEYS = [...new Set(s.roles.flatMap(x => x.keys))].map(k => k.replace(/\.[^.]+$/, ""));
1466
- ROLES = s.roles.map(x => ({
1467
- name: x.name, writes: x.writes.slice(),
1468
- keys: x.keys.map(k => k.replace(/\.[^.]+$/, "")),
1469
- sandbox: true, blocks: x.blocks
1470
- }));
1471
- LOG = fromServer(s.log);
1472
- REQS = s.requests || [];
1473
- CAUSES = s.causes || null;
1474
- WALLS = s.walls || {};
1475
- ONDISK = s.toml || null;
1476
- draw();
1477
- drawKpis();
2294
+ if (!LIVE) { demoRefresh(); return; }
2295
+ var got = await fetchState(true);
2296
+ if (got.s) applyState(got.s);
1478
2297
  }
1479
2298
 
1480
2299
  boot();