seisin 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/index.html CHANGED
@@ -4,12 +4,12 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>seisin console</title>
7
- <!-- A plot map, which is what the word means: seisin is the legal term for possession
8
- of a freehold estate in LAND. The square is the repo, the lines divide it into
9
- parcels, and one parcel is claimed. Two strokes and one fill so the divisions
10
- survive at 16px, where a favicon actually lives. Also in ui/favicon.svg for anyone
11
- who wants the file; inline here so the console needs no second route to serve it. -->
12
- <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%228%22%20fill%3D%22%2312161c%22%2F%3E%3Cg%20clip-path%3D%22url%28%23p%29%22%3E%3CclipPath%20id%3D%22p%22%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%2220%22%20height%3D%2220%22%20rx%3D%224%22%2F%3E%3C%2FclipPath%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%229%22%20height%3D%2211%22%20fill%3D%22%233fb950%22%2F%3E%3C%2Fg%3E%3Crect%20x%3D%226%22%20y%3D%226%22%20width%3D%2220%22%20height%3D%2220%22%20rx%3D%224%22%20fill%3D%22none%22%20stroke%3D%22%23e6edf3%22%20stroke-width%3D%222.6%22%2F%3E%3Cpath%20d%3D%22M15%206v11M6%2017h20%22%20stroke%3D%22%23e6edf3%22%20stroke-width%3D%222.6%22%20stroke-linecap%3D%22square%22%2F%3E%3C%2Fsvg%3E">
7
+ <!-- A boundary, which is what the word is about: seisin is the legal term for who
8
+ actually holds a piece of LAND. The S is a property line splitting one plot between
9
+ two holders, and the filled side is held. One stroke and one fill so it survives at
10
+ 16px, where a favicon actually lives. Also in ui/favicon.svg for anyone who wants
11
+ the file; inline here so the console needs no second route to serve it. -->
12
+ <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227%22%20fill%3D%22%231f2328%22%2F%3E%3Cpath%20d%3D%22M16%205.5C8%205.5%208%2016%2016%2016S24%2026.5%2016%2026.5H26.5V5.5Z%22%20fill%3D%22%232da44e%22%2F%3E%3Cpath%20d%3D%22M16%205.5C8%205.5%208%2016%2016%2016S24%2026.5%2016%2026.5%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222.8%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E">
13
13
  <meta name="description" content="Which folders each agent writes, which keys it may read, and whose a path is when the answer is no.">
14
14
  <meta name="color-scheme" content="light dark">
15
15
  <style>
@@ -60,6 +60,8 @@ body{margin:0;background:var(--bg);color:var(--fg);
60
60
  .brand{padding:15px 14px 13px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:12px}
61
61
  .brand b{font-size:15px;font-weight:600;letter-spacing:-.01em}
62
62
  .brand small{font-size:11px;color:var(--mid);display:block;margin-top:1px}
63
+ .brand .id{display:flex;align-items:center;gap:10px}
64
+ .brand .mark{width:28px;height:28px;flex:none}
63
65
  .theme{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
64
66
  .theme button{flex:1;border:0;background:none;color:var(--mid);padding:4px 0;border-radius:4px;
65
67
  cursor:pointer;font:inherit;font-size:12px}
@@ -86,7 +88,16 @@ button.btn:hover{background:var(--hover)}
86
88
  button.btn.warn{border-color:var(--amber);color:var(--amber)}
87
89
  button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
88
90
 
89
- .cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(168px,1fr))}
91
+ .cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
92
+ /* Time window: quick presets plus a free "from". Filters what comes from the log. */
93
+ .range{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--mid)}
94
+ .range .seg{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
95
+ .range .seg button{border:0;background:none;color:var(--mid);padding:4px 10px;border-radius:4px;
96
+ cursor:pointer;font:inherit;font-size:12px}
97
+ .range .seg button.on{background:var(--panel);color:var(--fg);box-shadow:0 1px 2px #0002}
98
+ .range input{font:inherit;font-size:12px;padding:4px 8px;border:1px solid var(--line2);
99
+ border-radius:6px;background:var(--bg);color:var(--fg)}
100
+ .range input.on{border-color:var(--fg)}
90
101
  .card{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line2);
91
102
  border-radius:8px;padding:14px 16px;min-height:96px;display:flex;flex-direction:column}
92
103
  .card.g{border-left-color:var(--green)} .card.a{border-left-color:var(--amber)} .card.b{border-left-color:var(--blue)}
@@ -94,12 +105,60 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
94
105
  .card .n{font-size:28px;font-weight:600;line-height:1;margin-top:12px;font-variant-numeric:tabular-nums;min-width:2.2ch}
95
106
  .card .s{font-size:11px;color:var(--mid);margin-top:5px;min-height:16px}
96
107
  .card.g .n{color:var(--green)} .card.a .n{color:var(--amber)} .card.b .n{color:var(--blue)}
108
+ .card.kpi{text-decoration:none;color:inherit;transition:border-color .12s,transform .12s}
109
+ .card.kpi:hover{border-color:var(--line2)}
110
+ .card.kpi:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
111
+ /* Only when it is not zero. An indicator that shouts at rest is one people
112
+ learn to ignore, and these two are the ones that mean "do something". */
113
+ .card.kpi.live{border-left-color:var(--blue)} .card.kpi.live .n{color:var(--blue)}
114
+ .card.kpi.alarm{border-left-color:var(--amber)} .card.kpi.alarm .n{color:var(--amber)}
115
+ .card.kpi.calm .n{color:var(--dim)}
116
+ /* Left border follows the state too. A card that is amber at zero has cried
117
+ wolf before you read the number. */
118
+ .card.kpi{border-left-color:var(--line2)}
119
+
120
+ /* The shape of the day. A hundred identical rows is not a reading, so each cause gets a
121
+ bar proportional to the others and the eye does the arithmetic. */
122
+ .lede{font-size:14px;line-height:1.55;margin:0 0 16px;color:var(--fg)}
123
+ .lede b{font-weight:600}
124
+ .lede .q{color:var(--mid)}
125
+ .lede code{font-family:var(--mono);color:var(--fg);font-size:13px}
126
+ .pad{padding:16px}
127
+ .bars{display:flex;flex-direction:column;gap:10px}
128
+ .bar{display:grid;grid-template-columns:1fr 132px;gap:14px;align-items:baseline}
129
+ .bar .lbl{min-width:0;font-size:12px}
130
+ .bar .p{font-family:var(--mono);color:var(--fg);overflow-wrap:anywhere}
131
+ .bar .who{color:var(--mid);font-size:11px;margin-top:2px}
132
+ .bar .t{height:6px;border-radius:3px;background:var(--line);overflow:hidden;margin-top:6px}
133
+ .bar .f{height:100%;border-radius:3px;background:var(--blue);display:block}
134
+ .bar.cold .f{background:var(--line2)}
135
+ .bar .num{text-align:right;font-variant-numeric:tabular-nums;font-size:12px;color:var(--fg)}
136
+ .bar .num s{display:block;color:var(--mid);font-size:11px;text-decoration:none;margin-top:2px}
137
+ .bar.cold .p,.bar.cold .num{color:var(--mid)}
138
+ .fam{padding:14px 0;border-top:1px solid var(--line)}
139
+ .fam:first-child{border-top:0;padding-top:0}
140
+ .fam-h{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
141
+ .fam-h code{font-family:var(--mono);font-size:13px;color:var(--fg);font-weight:600}
142
+ .fam-h .num{font-variant-numeric:tabular-nums;font-size:13px;color:var(--fg);white-space:nowrap}
143
+ .fam-h .num s{color:var(--mid);text-decoration:none;font-size:11px;margin-left:4px}
144
+ .fam .t{height:5px;border-radius:3px;background:var(--line);overflow:hidden;margin:8px 0 6px}
145
+ .fam .f{display:block;height:100%;border-radius:3px;background:var(--blue)}
146
+ .fam .who{font-size:11px;color:var(--mid)}
147
+ .places{margin:8px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
148
+ .places li{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
149
+ font-family:var(--mono);font-size:11px;color:var(--mid)}
150
+ .places li span{overflow-wrap:anywhere}
151
+ .places li em{font-family:inherit;font-style:normal;color:var(--dim)}
152
+ .places li b{color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
97
153
 
98
154
  .box{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}
99
155
  .box>header{padding:10px 14px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;
100
156
  justify-content:space-between;gap:12px}
101
157
  .box>header h2{font-size:13px;font-weight:600;margin:0}
102
158
  .box>header .note{font-size:11px;color:var(--dim)}
159
+ .box>header .bulk{margin-left:auto;display:flex;gap:6px;align-items:center}
160
+ .box>header .bulk[hidden]{display:none}
161
+ .box>header .bulk input{font-size:12px;padding:4px 6px;min-width:180px}
103
162
 
104
163
  .scrollx{overflow-x:auto}
105
164
  table{width:100%;border-collapse:collapse;font-size:13px;min-width:720px}
@@ -154,7 +213,7 @@ pre .c{color:var(--dim)} pre .k{color:var(--blue)} pre .d{color:var(--amber)} pr
154
213
  .ev .who em{font-style:normal;color:var(--blue);font-weight:600}
155
214
  .ev .when{font-size:11px;color:var(--dim);flex:0 0 auto;font-variant-numeric:tabular-nums}
156
215
  /* Which writer saw it. The hook reports an attempt and can be talked around; the kernel
157
- reports a refusal and cannot. When the two disagree about a run the reader needs to know
216
+ reports a denial and cannot. When the two disagree about a run the reader needs to know
158
217
  which line carries more weight, so the one that does is the one that is marked — a small
159
218
  word in the margin, not a column. Lines without the field render exactly as before. */
160
219
  .ev .src{font:10px/1.6 var(--mono);color:var(--dim);flex:0 0 auto;padding:0 5px;
@@ -201,6 +260,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
201
260
  /* What a view says when it has nothing to show. Not a shrug: the two reasons a log is empty
202
261
  need different answers, so it names them. */
203
262
  .blank{margin:0;padding:34px 16px;color:var(--fg);font-size:13px;line-height:1.7;text-align:center}
263
+ .blank.quiet{padding:16px;font-size:12px;color:var(--mid)}
264
+ .blank.quiet span{display:inline}
204
265
  .blank span{color:var(--mid);display:block;margin-top:6px;font-size:12px;
205
266
  max-width:58ch;margin-inline:auto;text-wrap:pretty}
206
267
  .blank code{font-family:var(--mono);font-size:11.5px;background:var(--hover);
@@ -208,8 +269,11 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
208
269
 
209
270
  @media (max-width:980px){.two{grid-template-columns:1fr}}
210
271
  @media (max-width:720px){
211
- .frame{grid-template-columns:1fr}
212
- .side{border-right:0;border-bottom:1px solid var(--line)}
272
+ /* minmax(0,1fr), not 1fr: a bare 1fr never shrinks below its widest child's
273
+ min-content, and the nav's unwrapped links made the whole page 793px wide on a
274
+ 375px phone. The tabs scroll inside .list instead. */
275
+ .frame{grid-template-columns:minmax(0,1fr)}
276
+ .side{border-right:0;border-bottom:1px solid var(--line);min-width:0}
213
277
  .side .list{display:flex;overflow-x:auto;padding:6px 8px}
214
278
  .side .cap{display:none}
215
279
  .side a{border-left:0;border-bottom:2px solid transparent;white-space:nowrap}
@@ -221,6 +285,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
221
285
  /* Requests. A queue nobody opens is not human-in-the-loop, so it looks like
222
286
  something waiting rather than another table of facts. */
223
287
  .req{display:flex;gap:14px;align-items:flex-start;padding:14px 16px;border-top:1px solid var(--line)}
288
+ .req.stale .who{opacity:.6}
224
289
  .req:first-child{border-top:0}
225
290
  .req .who{flex:1;min-width:0}
226
291
  .req .who b{font-weight:600}
@@ -239,7 +304,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
239
304
 
240
305
  <nav class="side">
241
306
  <div class="brand">
242
- <div><b>seisin</b><small id="source">folders and keys, per agent</small></div>
307
+ <div class="id"><svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="var(--fg)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5H26.5V5.5Z" fill="var(--green)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5" fill="none" stroke="var(--bg)" stroke-width="2.8" stroke-linecap="round"/></svg><div><b>seisin</b><small id="source">folders and keys, per agent</small></div></div>
243
308
  <div class="theme" role="group" aria-label="Theme">
244
309
  <button type="button" id="t-light">light</button>
245
310
  <button type="button" id="t-dark">dark</button>
@@ -248,8 +313,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
248
313
  </div>
249
314
  <div class="list">
250
315
  <div class="cap">Policy</div>
251
- <a class="on" href="#policy" aria-current="page"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
252
- <a href="#requests" id="nav-req"><i>⚖</i>Requests<span class="pin" id="req-pin"></span></a>
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>
253
320
  <a href="#activity"><i>◷</i>Activity</a>
254
321
  <a href="#configfile"><i>⚙</i>Config file</a>
255
322
  <div class="cap">Docs</div>
@@ -270,15 +337,45 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
270
337
  <button class="btn warn" id="kill" type="button">Unsandbox everything</button>
271
338
  </div>
272
339
 
273
- <section data-view="roles">
274
- <div class="cards">
275
- <div class="card"><h2>roles</h2><div class="n" id="c-roles">0</div><div class="s">declared in seisin.toml</div></div>
276
- <div class="card g"><h2>sandboxed</h2><div class="n" id="c-on">0</div><div class="s">enforced by the OS</div></div>
277
- <div class="card a"><h2>unsandboxed</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">nothing enforces these</div></div>
278
- <div class="card"><h2>no keys</h2><div class="n" id="c-nokeys">0</div><div class="s">declare none</div></div>
279
- <div class="card b"><h2>blocks today</h2><div class="n" id="c-blocks">0</div><div class="s">asked outside territory</div></div>
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. -->
351
+ <div class="range" id="range" hidden>
352
+ <span>Window</span>
353
+ <div class="seg" role="group" aria-label="Time window">
354
+ <button type="button" data-r="1h">1h</button>
355
+ <button type="button" data-r="4h">4h</button>
356
+ <button type="button" data-r="24h">24h</button>
357
+ <button type="button" data-r="7d">7d</button>
358
+ <button type="button" data-r="30d">30d</button>
359
+ <button type="button" data-r="all">All</button>
360
+ </div>
361
+ <label>from <input type="datetime-local" id="range-from" aria-label="From"></label>
280
362
  </div>
281
363
 
364
+ <div class="cards" id="kpis">
365
+ <a class="card kpi" href="#requests" id="k-wait">
366
+ <h2>waiting on you</h2><div class="n" id="c-wait">0</div><div class="s" id="c-wait-s">nothing to decide</div></a>
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>
369
+ <a class="card kpi" href="#denied" id="k-ref">
370
+ <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">
374
+ <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
+ </div>
376
+
377
+ <section data-view="roles">
378
+
282
379
  <div class="box">
283
380
  <header id="policy">
284
381
  <h2>Policy</h2>
@@ -312,16 +409,69 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
312
409
  </div>
313
410
  </section>
314
411
 
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
+
315
416
  <section data-view="requests" hidden>
316
417
  <div class="box" id="requests">
317
418
  <header>
318
- <h2>Waiting on you</h2>
319
- <span class="note">what an agent asked for and cannot have yet — approving happens here or in the terminal, never through MCP</span>
419
+ <h2>Pending decisions</h2>
420
+ <span class="note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
421
+ <span class="bulk" id="bulk" hidden>
422
+ <input id="why-all" placeholder="why, for all of them" aria-label="reason for declining all">
423
+ <button class="btn warn" id="decline-all" type="button">Decline all</button>
424
+ </span>
320
425
  </header>
321
426
  <div id="reqs"></div>
322
427
  </div>
323
428
  </section>
324
429
 
430
+ <!-- KPI: denied. Grouped by WHAT was denied, never by who asked — per role
431
+ it is the same list a hundred times over. -->
432
+ <section data-view="denied" hidden>
433
+ <div class="box">
434
+ <header>
435
+ <h2>Denied, by path</h2>
436
+ <span class="note" id="dn-window"></span>
437
+ </header>
438
+ <div class="pad">
439
+ <p class="lede" id="ca-lede"></p>
440
+ <div class="bars" id="ca-bars"></div>
441
+ </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">
455
+ <p class="lede" id="fa-lede"></p>
456
+ <div id="fa-list"></div>
457
+ </div>
458
+ </div>
459
+ </section>
460
+
461
+ <!-- KPI: spent retrying. -->
462
+ <section data-view="walls" hidden>
463
+ <div class="box">
464
+ <header>
465
+ <h2>Walls</h2>
466
+ <span class="note">denied twice or more, and the policy still denies it</span>
467
+ </header>
468
+ <div class="pad">
469
+ <p class="lede" id="wl-lede"></p>
470
+ <div id="wl-list"></div>
471
+ </div>
472
+ </div>
473
+ </section>
474
+
325
475
  <section data-view="activity" hidden>
326
476
  <div class="box">
327
477
  <header><h2>Activity</h2><span class="note">every decision, with its owner</span></header>
@@ -341,7 +491,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
341
491
  <div class="foot">
342
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
343
493
  would be generated. Copy the <b>seisin.toml</b> above to keep a change.</span><br>
344
- The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.76</b>, whose schema the test suite exercises against the real binary.
494
+ The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.77</b>, whose schema the test suite exercises against the real binary.
345
495
  </div>
346
496
  </main>
347
497
  </div>
@@ -526,12 +676,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
526
676
 
527
677
  var on=0,off=0,nok=0,bl=0;
528
678
  ROLES.forEach(function(r){r.sandbox?on++:off++; if(!r.keys.length)nok++; bl+=r.blocks;});
529
- document.getElementById("c-roles").textContent=ROLES.length;
530
- document.getElementById("c-on").textContent=on;
531
- document.getElementById("c-off").textContent=off;
532
- document.getElementById("c-off-s").textContent=off?"nothing enforces these":"none are open";
533
- document.getElementById("c-nokeys").textContent=nok;
534
- document.getElementById("c-blocks").textContent=bl;
679
+ drawKpis();
535
680
  var pin=document.getElementById("pin");
536
681
  if(off){pin.textContent=off;pin.hidden=false;}else{pin.hidden=true;}
537
682
  var k=document.getElementById("kill");
@@ -558,14 +703,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
558
703
  }
559
704
  L.push("{");
560
705
  L.push(' <span class="k">"network"</span>: {');
561
- L.push(' "allowedDomains": ["github.com"],');
706
+ var lp=(r.localPorts||[]).map(function(n){return '"localhost:'+Number(n)+'"';});
707
+ L.push(' "allowedDomains": ["github.com"'+(lp.length?', <span class="g">'+lp.join(', ')+'</span>':'')+']'+(lp.length?', <span class="c">// local_ports, through the proxy</span>':','));
562
708
  L.push(' "deniedDomains": [], "allowUnixSockets": [],');
563
- L.push(' "allowLocalBinding": false');
709
+ L.push(' "allowLocalBinding": '+(r.localBinding?'<span class="d">true</span> <span class="c">// can reach every localhost port (macOS)</span>':'false'));
564
710
  L.push(" },");
565
711
  L.push(' <span class="k">"filesystem"</span>: {');
566
712
  // KEYDIR comes from seisin.toml, which is a file the console may be
567
713
  // pointed at without having written it. Unescaped it was HTML injection
568
- // into a page that carries SEISIN_TOKEN — and that token approves grants.
714
+ // into a page that holds the run's token — and that token approves grants.
569
715
  L.push(' <span class="d">"denyRead"</span>: ["'+esc(KEYDIR)+'"],');
570
716
  if(r.keys.length){
571
717
  L.push(' <span class="g">"allowRead"</span>: [');
@@ -580,8 +726,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
580
726
  });
581
727
  else L.push(' <span class="c">// no territory</span>');
582
728
  L.push(" ],");
583
- L.push(' "denyWrite": []');
584
- L.push(" }");
729
+ if((r.neverWrites||[]).length){
730
+ L.push(' <span class="d">"denyWrite"</span>: [ <span class="c">// never_writes</span>');
731
+ r.neverWrites.forEach(function(p,i){ L.push(' "'+esc(p.replace(/\/\*\*$/,""))+'"'+(i<r.neverWrites.length-1?",":"")); });
732
+ L.push(" ]");
733
+ } else L.push(' "denyWrite": []');
734
+ L.push(" }"+(r.trustd?",":""));
735
+ if(r.trustd) L.push(' <span class="d">"enableWeakerNetworkIsolation"</span>: true <span class="c">// trustd = true</span>');
585
736
  L.push("}");
586
737
  document.getElementById("cfg").innerHTML=L.join("\n");
587
738
  }
@@ -650,7 +801,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
650
801
  // Only the kernel's own lines get the mark. A hook line and an older line look the
651
802
  // same as they always did — the absence is the default, not a third state.
652
803
  var src=e.source==="kernel"
653
- ? '<span class="src" title="Recorded from the kernel: what the OS actually refused, '+
804
+ ? '<span class="src" title="Recorded from the kernel: what the OS actually denied, '+
654
805
  'whether or not the hook saw the attempt. This line cannot be talked around.">kernel</span>'
655
806
  : "";
656
807
  d.innerHTML="<b>"+label+'</b><span class="body"><span class="what">'+line+"</span>"+who+
@@ -679,6 +830,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
679
830
 
680
831
  var REQS = [];
681
832
  var ONDISK = null; // the file on disk, when there is one
833
+ var CAUSES = null; // this window's denials, grouped by what was denied
834
+ var WALLS = {}; // per role, what it keeps being denied and still would be
682
835
 
683
836
  /**
684
837
  * The queue, as something a person acts on.
@@ -686,6 +839,201 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
686
839
  * Hidden entirely when empty rather than shown as an empty table: the point
687
840
  * of this panel is to be noticed on the days it has something in it.
688
841
  */
842
+ /**
843
+ * The shape of the window, grouped by cause.
844
+ *
845
+ * The reading comes first and the bars second, because the number that
846
+ * matters is almost never "how many" — it is "how much of it is one thing".
847
+ * A day where three quarters of the denials are a single lock file is a
848
+ * day where nobody disputed any territory, and that sentence is the whole
849
+ * value of grouping. It is written out rather than left for the reader to
850
+ * infer from twelve bars.
851
+ */
852
+ function drawCauses(){
853
+ var f = CAUSES;
854
+ var lede = document.getElementById("ca-lede");
855
+ if (!f || !f.total) {
856
+ if (lede) lede.textContent = "Nothing was denied in this window.";
857
+ var bars = document.getElementById("ca-bars"); if (bars) bars.innerHTML = "";
858
+ return;
859
+ }
860
+ var top = f.causes[0];
861
+ var fam = (f.families || [])[0];
862
+ var famShare = fam ? Math.round(fam.share * 100) : 0;
863
+ var lede = "<b>" + f.total.toLocaleString() + "</b> denial" + (f.total === 1 ? "" : "s") +
864
+ " in this window, over <b>" + (f.distinct || f.causes.length).toLocaleString() +
865
+ "</b> distinct path" + ((f.distinct || f.causes.length) === 1 ? "" : "s") + ". ";
866
+ // The reading has to be taken at the level of the NAME, not the path, or it comes out
867
+ // backwards. Measured here: six of the top seven causes were the same lock file in six
868
+ // different repositories and no single path passed 17% \u2014 so "no cause dominates" was
869
+ // what the arithmetic said and the opposite of the truth. One KIND of thing at that scale
870
+ // is a tooling problem with a mechanical fix; the same volume spread across unrelated
871
+ // paths is a territory question and needs a person. They take opposite responses, which
872
+ // is why this sentence is written out instead of left to be inferred from twelve bars.
873
+ if (fam && famShare >= 40 && fam.paths > 1)
874
+ lede += "<span class=\"q\"><b>" + fam.times.toLocaleString() + "</b> of them \u2014 <b>" +
875
+ famShare + "%</b> \u2014 are the same name in " + fam.paths + " places: " +
876
+ "<code>" + esc(fam.name) + "</code>. One kind of thing at that scale is a " +
877
+ "tooling problem with a mechanical fix, not something to grant.</span>";
878
+ else if (fam && famShare >= 40)
879
+ lede += "<span class=\"q\"><b>" + famShare + "%</b> of it is a single path. That is " +
880
+ "usually mechanical rather than something to grant.</span>";
881
+ else
882
+ lede += "<span class=\"q\">No one kind of thing dominates, which is what a real boundary " +
883
+ "question looks like rather than a tooling one.</span>";
884
+ /* The half a grep cannot do, because it needs the policy: how many of these
885
+ are on paths nobody claims. Those are the ones no grant resolves until a
886
+ person decides who owns them — a decision, not a count. Added after a
887
+ field review pointed out that the percentage above was the console
888
+ reading the log back to somebody who had already read it. */
889
+ if (f.unowned)
890
+ lede += " <b>" + f.unowned + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
891
+ " are on paths <b>no role owns</b> \u2014 a grant cannot settle those until " +
892
+ "somebody claims them.";
893
+ document.getElementById("ca-lede").innerHTML = lede;
894
+ var win = document.getElementById("dn-window");
895
+ if (win) win.textContent = rangeLabel() + " \u00b7 " + f.total.toLocaleString() + " denials \u00b7 " +
896
+ (f.distinct || f.causes.length).toLocaleString() + " distinct paths";
897
+
898
+ var max = top.times || 1;
899
+ document.getElementById("ca-bars").innerHTML = f.causes.map(function(c){
900
+ var w = Math.max(2, Math.round((c.times / max) * 100));
901
+ // A cause the policy no longer denies is history, not live. It is
902
+ // shown, greyed, rather than dropped: it explains a number somebody may
903
+ // remember from yesterday instead of silently changing it.
904
+ var cold = c.stillDeclined === 0;
905
+ var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
906
+ return '<div class="bar' + (cold ? ' cold' : '') + '">' +
907
+ '<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
908
+ '<div class="who">' + esc(who) +
909
+ (c.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
910
+ (cold ? ' · <b>granted since</b>' : '') + '</div>' +
911
+ '<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
912
+ '<div class="num">' + c.times.toLocaleString() +
913
+ '<s>' + (c.share * 100).toFixed(1) + '%</s></div>' +
914
+ '</div>';
915
+ }).join("");
916
+
917
+ }
918
+
919
+ /** Denied, by name — the cut that says what kind of day it was. */
920
+ function drawFamilies(){
921
+ var f = CAUSES; if (!f) return;
922
+ var fams = f.families || [];
923
+ var top = fams[0];
924
+ document.getElementById("fa-lede").innerHTML = !top ? "Nothing was denied in this window."
925
+ : "<b>" + Math.round(top.share * 100) + "%</b> of " + f.total.toLocaleString() +
926
+ " denials is <code>" + esc(top.name) + "</code>, across <b>" + top.paths + "</b> path" +
927
+ (top.paths === 1 ? "" : "s") + "." +
928
+ (top.share >= 0.4 && top.paths > 1
929
+ ? ' <span class="q">One name at that scale is usually mechanical. Fixing the tool ' +
930
+ 'removes the denials; granting the paths only hides them.</span>'
931
+ : ' <span class="q">No single name dominates \u2014 these are separate questions, ' +
932
+ 'not one problem.</span>');
933
+
934
+ document.getElementById("fa-list").innerHTML = fams.map(function(x){
935
+ var w = Math.max(2, Math.round((x.times / (top.times || 1)) * 100));
936
+ return '<div class="fam">' +
937
+ '<div class="fam-h"><code>' + esc(x.name) + '</code>' +
938
+ '<span class="num">' + x.times.toLocaleString() + ' <s>' + (x.share*100).toFixed(1) + '%</s></span></div>' +
939
+ '<div class="t"><span class="f" style="width:' + w + '%"></span></div>' +
940
+ '<div class="who">' + x.paths + ' path' + (x.paths===1?'':'s') + ' \u00b7 ' +
941
+ x.roles.length + ' role' + (x.roles.length===1?'':'s') + ': ' + esc(x.roles.join(", ")) + '</div>' +
942
+ '<ul class="places">' + x.where.map(function(p){
943
+ return '<li><span>' + esc(p.target) + '</span><b>' + p.times.toLocaleString() + '</b></li>';
944
+ }).join("") + '</ul></div>';
945
+ }).join("");
946
+ }
947
+
948
+ /** Walls — per role, what it keeps hitting and what that cost. */
949
+ function drawWalls(){
950
+ var rows = Object.keys(WALLS || {}).map(function(r){
951
+ var w = WALLS[r];
952
+ return { role: r, walls: w, spent: w.reduce(function(n,x){ return n + x.times - 1; }, 0) };
953
+ }).sort(function(a,b){ return b.spent - a.spent; });
954
+ var total = rows.reduce(function(n,x){ return n + x.spent; }, 0);
955
+
956
+ document.getElementById("wl-lede").innerHTML = !rows.length
957
+ ? "No role is repeating a denial it has already been given."
958
+ : "<b>" + total.toLocaleString() + "</b> call" + (total===1?"":"s") + " across <b>" +
959
+ rows.length + "</b> role" + (rows.length===1?"":"s") +
960
+ ' went into retrying something already denied. <span class="q">A denial is said once, ' +
961
+ 'mid-turn. Nothing carries it forward, so it gets tried again.</span>';
962
+
963
+ document.getElementById("wl-list").innerHTML = rows.map(function(x){
964
+ return '<div class="fam">' +
965
+ '<div class="fam-h"><code>' + esc(x.role) + '</code>' +
966
+ '<span class="num">' + x.spent.toLocaleString() + ' <s>calls retried</s></span></div>' +
967
+ '<ul class="places">' + x.walls.map(function(w){
968
+ return '<li><span>' + esc(w.action) + ' ' + esc(w.target) + '<br>' +
969
+ '<em>' + esc(w.reason) + '</em></span><b>' + w.times + '\u00d7</b></li>';
970
+ }).join("") + '</ul></div>';
971
+ }).join("");
972
+ }
973
+
974
+ /** A count beside a nav item, or nothing. Zero is not news. */
975
+ function pill(id, n){
976
+ var el = document.getElementById(id);
977
+ if (!el) return;
978
+ el.textContent = n ? (n > 999 ? Math.round(n/1000) + "k" : String(n)) : "";
979
+ el.hidden = !n;
980
+ }
981
+
982
+ /**
983
+ * The five numbers, on every screen.
984
+ *
985
+ * Chosen by one rule: a KPI has to be something that can change and that you
986
+ * would do something about. The row this replaces failed both — four of its
987
+ * five read 31, 31, 0 and 31 every single day — and the one that moved was
988
+ * last and the same size as the rest.
989
+ *
990
+ * Emphasis is conditional, never constant. At zero they go quiet and say so
991
+ * in words, because "nothing to decide" is a state and an indicator that
992
+ * shouts at rest is one people stop seeing.
993
+ *
994
+ * Every value is read from the globals rather than passed in, so any refresh
995
+ * can call it — the queue refreshes on its own, and passing the role counts
996
+ * in once left the row stale on the number that moves most.
997
+ */
998
+ function drawKpis(){
999
+ var f = CAUSES || { total: 0, families: [], distinct: 0, causes: [] };
1000
+ var waiting = REQS.length;
1001
+ var retry = Object.keys(WALLS || {}).reduce(function(n, r){
1002
+ return n + WALLS[r].reduce(function(m, w){ return m + w.times - 1; }, 0);
1003
+ }, 0);
1004
+ var fam = f.families && f.families[0];
1005
+ var off = (ROLES || []).filter(function(r){ return !r.sandbox; }).length;
1006
+
1007
+ function set(id, cls, value, sub){
1008
+ var el = document.getElementById(id);
1009
+ if (!el) return;
1010
+ el.classList.remove("live","alarm","calm");
1011
+ if (cls) el.classList.add(cls);
1012
+ el.querySelector(".n").textContent = value;
1013
+ el.querySelector(".s").textContent = sub;
1014
+ }
1015
+
1016
+ set("k-wait", waiting ? "live" : "calm", waiting,
1017
+ waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
1018
+ // The only number here that is an emergency, and it is almost always zero.
1019
+ set("k-open", off ? "alarm" : "calm", off,
1020
+ off ? (off === 1 ? "role nothing is enforcing" : "roles nothing is enforcing") : "every role confined");
1021
+ set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
1022
+ (f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied") +
1023
+ (RANGE === "all" ? "" : " \u00b7 " + rangeLabel()));
1024
+ // The shape, not the size: one name at 74% is a tooling problem, the same
1025
+ // volume spread thin is a territory question, so the caption carries the name.
1026
+ set("k-conc", fam && fam.share >= 0.4 ? "live" : "calm",
1027
+ fam ? Math.round(fam.share * 100) + "%" : "\u2014",
1028
+ fam ? "is " + fam.name : "of all denials");
1029
+ // The only one that is money rather than state.
1030
+ set("k-retry", retry ? "live" : "calm", retry.toLocaleString(),
1031
+ retry ? "calls on walls already denied" : "nothing retried");
1032
+
1033
+ pill("dn-pin", f.total);
1034
+ pill("wl-pin", Object.keys(WALLS || {}).length);
1035
+ }
1036
+
689
1037
  function drawReqs(){
690
1038
  var box = document.getElementById("requests");
691
1039
  var pin = document.getElementById("req-pin");
@@ -696,26 +1044,30 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
696
1044
  // a page with a heading and nothing under it. It stays, and says what would fill it.
697
1045
  box.hidden = false;
698
1046
  if (!REQS.length) {
699
- host.innerHTML = '<p class="blank">Nothing waiting on you.' +
700
- '<span>An agent lands here when the policy denies it a path and it asks for the path ' +
701
- 'rather than working around it. Approving is the one action in this console that ' +
702
- 'writes to your policy \u2014 it edits <code>seisin.toml</code> in place, with your reason.</span></p>';
1047
+ // Compact when this is the landing view. The tall empty state was right
1048
+ // when Requests was a page of its own — a heading with nothing under it
1049
+ // reads as broken. At the top of the front page it is 180px of nothing
1050
+ // standing between you and the only content there is.
1051
+ host.innerHTML = '<p class="blank quiet">No pending decisions. ' +
1052
+ '<span>Agents land here when they ask for a path the policy denies them.</span></p>';
703
1053
  return;
704
1054
  }
705
1055
 
706
1056
  host.innerHTML = REQS.map(function(r, i){
707
1057
  var owners = (r.owners && r.owners.length) ? " — belongs to <b>" + esc(r.owners.join(", ")) + "</b>" : "";
708
1058
  var times = r.times > 1 ? ' · <span class="asked">asked ' + r.times + "×</span>" : "";
709
- return '<div class="req">' +
1059
+ // Marked, not moved: Approve and Decline post this row's number.
1060
+ var stale = r.stale ? ' · not asked again in ' + Number(r.stale.runs) + ' runs since — likely no longer needed' : "";
1061
+ return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
710
1062
  '<div class="who">' +
711
1063
  "<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) +
712
1064
  ' <span class="path">' + esc(r.grant) + "</span>" + owners + "</div>" +
713
- '<div class="sub">first asked over ' + esc(r.target) + times + "</div>" +
1065
+ '<div class="sub">first asked over ' + esc(r.target) + times + stale + "</div>" +
714
1066
  "</div>" +
715
1067
  '<div class="act">' +
716
1068
  '<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
717
1069
  '<button class="btn" data-n="' + (i+1) + '" data-d="granted" type="button">Approve</button>' +
718
- '<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">Refuse</button>' +
1070
+ '<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">Decline</button>' +
719
1071
  "</div>" +
720
1072
  "</div>";
721
1073
  }).join("");
@@ -723,6 +1075,48 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
723
1075
  host.querySelectorAll("button[data-n]").forEach(function(b){
724
1076
  b.addEventListener("click", function(){ act(b); });
725
1077
  });
1078
+ drawBulk();
1079
+ }
1080
+
1081
+ /**
1082
+ * Decline every request on screen — two clicks, no dialog.
1083
+ *
1084
+ * There is no "approve all" on purpose: a grant is read one at a time. The
1085
+ * keys sent are the ones drawn now, so a request that arrives in between is
1086
+ * not refused unseen.
1087
+ */
1088
+ function drawBulk(){
1089
+ var bulk = document.getElementById("bulk");
1090
+ var btn = document.getElementById("decline-all");
1091
+ bulk.hidden = REQS.length < 2;
1092
+ btn.disabled = false;
1093
+ btn.dataset.armed = "";
1094
+ btn.textContent = "Decline all (" + REQS.length + ")";
1095
+ btn.onclick = async function(){
1096
+ if (!btn.dataset.armed) {
1097
+ btn.dataset.armed = "1";
1098
+ btn.textContent = "Click again to decline " + REQS.length;
1099
+ setTimeout(function(){ if (btn.dataset.armed) { btn.dataset.armed = ""; btn.textContent = "Decline all (" + REQS.length + ")"; } }, 4000);
1100
+ return;
1101
+ }
1102
+ btn.disabled = true;
1103
+ try {
1104
+ var r = await fetch("/api/decline-all", {
1105
+ method: "POST",
1106
+ headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
1107
+ body: JSON.stringify({ keys: REQS.map(function(q){ return q.key; }),
1108
+ reason: (document.getElementById("why-all") || {}).value || "declined in bulk from the console" })
1109
+ });
1110
+ var out = await r.json();
1111
+ if (!r.ok) { var err = new Error(out.error || "refused"); err.status = r.status; throw err; }
1112
+ await reload();
1113
+ } catch (e) {
1114
+ btn.disabled = false;
1115
+ btn.textContent = "Decline all (" + REQS.length + ")";
1116
+ var note = document.querySelector("#requests .note");
1117
+ if (note) { note.textContent = whyFailed(e, e.status); note.style.color = "var(--amber)"; }
1118
+ }
1119
+ };
726
1120
  }
727
1121
 
728
1122
  /**
@@ -739,22 +1133,34 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
739
1133
  try {
740
1134
  var r = await fetch("/api/decide", {
741
1135
  method: "POST",
742
- headers: { "content-type": "application/json", "x-seisin-token": window.SEISIN_TOKEN || "" },
1136
+ headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
743
1137
  body: JSON.stringify({ number: n, decision: btn.dataset.d, reason: why })
744
1138
  });
745
1139
  var out = await r.json();
746
- if (!r.ok) throw new Error(out.error || "refused");
1140
+ if (!r.ok) { var err = new Error(out.error || "denied"); err.status = r.status; throw err; }
747
1141
  // Re-read rather than patch: the grant rewrote seisin.toml, so the
748
1142
  // territory on screen is stale too, not just the queue.
749
1143
  await reload();
750
1144
  } catch (e) {
751
1145
  row.querySelectorAll("button,input").forEach(function(el){ el.disabled = false; });
752
1146
  var s = row.querySelector(".sub");
753
- s.innerHTML = '<span style="color:var(--amber)">' + esc(e.message) + "</span>";
1147
+ s.innerHTML = '<span style="color:var(--amber)">' + esc(whyFailed(e, e.status)) + "</span>";
754
1148
  }
755
1149
  }
756
1150
 
757
- function draw(){drawRows();drawCfg();drawToml();drawLog();drawReqs();}
1151
+ /**
1152
+ * One view failing must not take the others down.
1153
+ *
1154
+ * Measured the hard way: `drawCauses` kept a reference to a box that had moved
1155
+ * to its own page, threw on the first line, and every number on the screen —
1156
+ * including the five that have nothing to do with it — rendered as zero. A
1157
+ * console whose KPIs read zero because of a typo is worse than one that is
1158
+ * down, because zero is a plausible answer.
1159
+ */
1160
+ function draw(){
1161
+ [drawRows, drawCfg, drawToml, drawLog, drawReqs, drawCauses, drawFamilies, drawWalls]
1162
+ .forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
1163
+ }
758
1164
 
759
1165
  /**
760
1166
  * The page works two ways on purpose.
@@ -765,16 +1171,112 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
765
1171
  * you can look at before installing anything. What it must never do is show
766
1172
  * example data while looking live, so the label says which one you are in.
767
1173
  */
1174
+ /**
1175
+ * This run's token, from the link `seisin ui` opened.
1176
+ *
1177
+ * It arrives in the fragment (#t=…), which the browser never sends to any
1178
+ * server, and it is moved into this tab's storage and wiped from the address
1179
+ * bar and the history entry at once. The server no longer puts it in the
1180
+ * page: anything that could GET "/" used to walk away with it.
1181
+ */
1182
+ var TOKEN = (function(){
1183
+ var m = /(?:^#|&)t=([a-f0-9]+)/.exec(location.hash || "");
1184
+ try {
1185
+ if (m) { sessionStorage.setItem("seisin-token", m[1]); }
1186
+ var t = m ? m[1] : (sessionStorage.getItem("seisin-token") || "");
1187
+ if (m) history.replaceState(null, "", location.pathname + location.search);
1188
+ return t;
1189
+ } catch (_) { return m ? m[1] : ""; }
1190
+ })();
1191
+
1192
+ /**
1193
+ * What to tell a person when a request to this server fails.
1194
+ *
1195
+ * "Failed to fetch" was all the browser said when the console had been
1196
+ * restarted under an open tab — the server was gone, or back with a new
1197
+ * token that this tab does not hold. Both have the same fix, and it is not
1198
+ * obvious from either message.
1199
+ */
1200
+ function whyFailed(e, status){
1201
+ if (status === 403)
1202
+ return "this tab's link is stale — the console restarted. Open the new link `seisin ui` printed.";
1203
+ if (e && /network|fetch/i.test(String(e.message || e)))
1204
+ return "the console did not answer. If `seisin ui` restarted, open the new link it printed.";
1205
+ return (e && e.message) || "failed";
1206
+ }
1207
+
1208
+ /* ── time window ──────────────────────────────────────────────────────── */
1209
+ // "1h" … "30d", "all", or "from:<ISO>". Remembered per browser, as a convenience.
1210
+ var RANGE = "all";
1211
+ try { RANGE = localStorage.getItem("seisin.range") || "all"; } catch (_) {}
1212
+ var SPANS = { "1h": 36e5, "4h": 4 * 36e5, "24h": 24 * 36e5, "7d": 7 * 864e5, "30d": 30 * 864e5 };
1213
+
1214
+ // Worked out on every request, so "last 4h" keeps moving while the page is open.
1215
+ function sinceIso(){
1216
+ if (SPANS[RANGE]) return new Date(Date.now() - SPANS[RANGE]).toISOString();
1217
+ if (RANGE.indexOf("from:") === 0) return RANGE.slice(5);
1218
+ return null;
1219
+ }
1220
+ function stateUrl(){
1221
+ var s = sinceIso();
1222
+ return "/api/state" + (s ? "?since=" + encodeURIComponent(s) : "");
1223
+ }
1224
+ function rangeLabel(){
1225
+ if (SPANS[RANGE]) return "last " + RANGE;
1226
+ if (RANGE.indexOf("from:") === 0) return "since " + new Date(RANGE.slice(5)).toLocaleString();
1227
+ return "all time";
1228
+ }
1229
+ function drawRange(){
1230
+ document.querySelectorAll("#range .seg button").forEach(function(b){
1231
+ b.classList.toggle("on", b.dataset.r === RANGE);
1232
+ });
1233
+ var from = document.getElementById("range-from");
1234
+ from.classList.toggle("on", RANGE.indexOf("from:") === 0);
1235
+ if (RANGE.indexOf("from:") !== 0) from.value = "";
1236
+ }
1237
+ function setRange(r){
1238
+ RANGE = r;
1239
+ try { localStorage.setItem("seisin.range", r); } catch (_) {}
1240
+ drawRange();
1241
+ reload().catch(function(){ /* server went away: keep what is on screen */ });
1242
+ }
1243
+ document.querySelectorAll("#range .seg button").forEach(function(b){
1244
+ b.addEventListener("click", function(){ setRange(b.dataset.r); });
1245
+ });
1246
+ document.getElementById("range-from").addEventListener("change", function(e){
1247
+ var t = Date.parse(e.target.value); // datetime-local is local time, which Date.parse reads as such
1248
+ setRange(isNaN(t) ? "all" : "from:" + new Date(t).toISOString());
1249
+ });
1250
+
768
1251
  async function boot(){
769
- let s = null;
1252
+ let s = null, stale = false;
770
1253
  try {
771
- const r = await fetch("/api/state", { cache: "no-store" });
1254
+ const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
772
1255
  if (r.ok) s = await r.json();
1256
+ // A live server that refuses this tab's token: `seisin ui` was restarted
1257
+ // and minted a new one. Falling back to the example here showed a policy
1258
+ // that is not the reader's, one F5 after a restart, with nothing saying why.
1259
+ stale = r.status === 403;
773
1260
  } catch (_) { /* opened as a file: stay in demo */ }
774
1261
 
775
1262
  const label = document.getElementById("source");
1263
+ if (stale) {
1264
+ label.textContent = "link from an earlier run";
1265
+ label.style.color = "var(--amber)";
1266
+ document.querySelector("main").innerHTML =
1267
+ '<div class="box" style="padding:22px 24px;max-width:640px">' +
1268
+ '<h2 style="margin:0 0 8px;font-size:17px">This tab has the link from an earlier run</h2>' +
1269
+ '<p style="margin:0 0 8px">`seisin ui` was restarted, and every run gets a new token. ' +
1270
+ 'Open the new link it printed in the terminal (it starts with ' +
1271
+ '<code>http://127.0.0.1:' + location.port + '/#t=</code>).</p>' +
1272
+ '<p style="margin:0;color:var(--mid)">Nothing here is shown until then, so this page never ' +
1273
+ 'passes the example policy off as yours.</p></div>';
1274
+ return;
1275
+ }
776
1276
  if (!s || s.error) {
777
- label.textContent = "example policy — not your repo";
1277
+ label.textContent = location.protocol.startsWith("http") && !TOKEN
1278
+ ? "no token — open the link `seisin ui` printed · showing an example"
1279
+ : "example policy — not your repo";
778
1280
  label.style.color = "var(--amber)";
779
1281
  draw();
780
1282
  return;
@@ -787,10 +1289,18 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
787
1289
  writes: r.writes.slice(),
788
1290
  keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
789
1291
  sandbox: true,
790
- blocks: r.blocks
1292
+ blocks: r.blocks,
1293
+ // The three role keys that change the profile, drawn from what the
1294
+ // server says rather than typed into this page (review of 2026-09-22, #5).
1295
+ neverWrites: r.neverWrites || [],
1296
+ localBinding: !!r.localBinding,
1297
+ localPorts: (r.localPorts || []).slice(),
1298
+ trustd: !!r.trustd
791
1299
  }));
792
1300
  LOG = fromServer(s.log);
793
1301
  REQS = s.requests || [];
1302
+ CAUSES = s.causes || null;
1303
+ WALLS = s.walls || {};
794
1304
  ONDISK = s.toml || null;
795
1305
  // The demo selection does not survive the live load. `sel` starts at "frontend", a role
796
1306
  // from the example policy, and a real repo has no reason to have one. `drawCfg` then
@@ -809,6 +1319,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
809
1319
  // everything" against a real repo is not a preview. `dataset.live` is what `show()`
810
1320
  // reads, so the router never brings it back when you return to Roles.
811
1321
  document.getElementById("kill").dataset.live = "1";
1322
+ document.getElementById("range").hidden = false;
1323
+ drawRange();
812
1324
  document.getElementById("kill").hidden = true;
813
1325
  document.getElementById("toml-note").textContent = "the file on disk, as it stands";
814
1326
  document.getElementById("foot-mode").textContent = "Live.";
@@ -830,7 +1342,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
830
1342
  policy: {view:"roles", title:"Roles",
831
1343
  sub:"Which folders each agent writes, which keys it may read, and whether it runs sandboxed."},
832
1344
  requests: {view:"requests", title:"Waiting on you",
833
- sub:"What an agent asked for and cannot have yet. Approving is the one action that writes to your policy."},
1345
+ sub:"Permissions an agent asked for and cannot have. Granting one edits your policy."},
1346
+ denied: {view:"denied", title:"Denied",
1347
+ sub:"Every denial in the window, grouped by the path it was about."},
1348
+ causes: {view:"causes", title:"Causes",
1349
+ sub:"The same denials by name, which is the cut that says what kind of day it was."},
1350
+ walls: {view:"walls", title:"Walls",
1351
+ sub:"What each role keeps being denied, and what retrying it cost."},
834
1352
  activity: {view:"activity", title:"Activity",
835
1353
  sub:"Every decision the runtime made, newest last, each with the role that owns the path."},
836
1354
  configfile: {view:"config", title:"Config file",
@@ -838,7 +1356,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
838
1356
  };
839
1357
 
840
1358
  function show(route){
841
- var v = VIEWS[route] || VIEWS.policy;
1359
+ var v = VIEWS[route] || VIEWS.requests;
842
1360
  document.querySelectorAll("main section[data-view]").forEach(function(s){
843
1361
  s.hidden = s.getAttribute("data-view") !== v.view;
844
1362
  });
@@ -858,7 +1376,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
858
1376
  window.scrollTo(0, 0);
859
1377
  }
860
1378
 
861
- function route(){ return (location.hash || "#policy").slice(1); }
1379
+ /**
1380
+ * The landing view is what is waiting on you, not the policy table.
1381
+ *
1382
+ * It opened on Roles, which is reference: a wall of paths you consult when you
1383
+ * have a question, rendered before anybody asked one. What a person opens this
1384
+ * for is "does anything need me", and that has to be the first screen or it is
1385
+ * a screen nobody reaches. Reference is looked up; decisions are shown.
1386
+ */
1387
+ function route(){ return (location.hash || "#requests").slice(1); }
862
1388
  window.addEventListener("hashchange", function(){ show(route()); });
863
1389
  show(route());
864
1390
 
@@ -891,7 +1417,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
891
1417
 
892
1418
  async function refreshLog(){
893
1419
  try {
894
- const r = await fetch("/api/state", { cache: "no-store" });
1420
+ const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
895
1421
  if (!r.ok) return;
896
1422
  const s = await r.json();
897
1423
  if (s.error) return;
@@ -900,7 +1426,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
900
1426
  // the same way the log does: by polling. Re-rendered only when it changed,
901
1427
  // so a reason someone is halfway through typing is not wiped every 2s.
902
1428
  var fresh = JSON.stringify(s.requests || []);
903
- if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); }
1429
+ if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); drawKpis(); }
904
1430
  // Only the counters that come from the log are refreshed. Whatever the
905
1431
  // reader has been editing on screen stays put — a page that resets your
906
1432
  // work every two seconds is worse than one that does not update.
@@ -914,7 +1440,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
914
1440
 
915
1441
  /** Everything again, after a decision rewrote the policy on disk. */
916
1442
  async function reload(){
917
- const r = await fetch("/api/state", { cache: "no-store" });
1443
+ const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
918
1444
  const s = await r.json();
919
1445
  if (s.error) return;
920
1446
  KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
@@ -926,8 +1452,11 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
926
1452
  }));
927
1453
  LOG = fromServer(s.log);
928
1454
  REQS = s.requests || [];
1455
+ CAUSES = s.causes || null;
1456
+ WALLS = s.walls || {};
929
1457
  ONDISK = s.toml || null;
930
1458
  draw();
1459
+ drawKpis();
931
1460
  }
932
1461
 
933
1462
  boot();