attesso 0.4.0 → 0.6.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.
@@ -0,0 +1,1695 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="bounds-token" content="__BOUNDS_TOKEN__">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>Bounds</title>
8
+ <style>
9
+ :root {
10
+ --paper:#f7f8fa; --ink:#0b0d10; --rail:#f1f4f8; --line:#e1e4e8;
11
+ --muted:#667085; --body:#344054; --accent:#c93f63;
12
+ --deny:#a63a39; --deny-bg:#fff7f6; --deny-line:#f1c9c4;
13
+ --allow:#2f7d5a; --allow-bg:#f1f8f3; --allow-line:#cfe3d6;
14
+ --ask:#8a5a00; --ask-bg:#fff8e8; --ask-line:#e7c37a;
15
+ --mono:ui-monospace,SFMono-Regular,Menlo,monospace;
16
+ }
17
+ *{box-sizing:border-box}
18
+ html,body{margin:0;padding:0;background:var(--paper);color:var(--ink)}
19
+ body{font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
20
+ button,input,select{font:inherit}
21
+ .shell{display:grid;grid-template-columns:210px minmax(0,1fr);min-height:100vh}
22
+ .rail{background:var(--rail);border-right:1px solid var(--line);padding:8px}
23
+ .wordmark{display:block;padding:10px 8px 22px;font:13px var(--mono);text-decoration:none;color:var(--ink)}
24
+ .rail-label{padding:14px 8px 6px;color:var(--muted);font:10px var(--mono);letter-spacing:.05em}
25
+ .workspace{border-top:1px solid #d8dee7;border-bottom:1px solid #d8dee7;margin:0 8px;padding:13px 0}
26
+ .workspace-name{margin-top:8px;display:flex;align-items:center;gap:8px;font-size:14px}
27
+ .avatar{width:20px;height:20px;border-radius:3px;background:#635bff;color:#fff;display:inline-grid;place-items:center;font:11px var(--mono)}
28
+ .nav{display:block;padding:8px;border-left:2px solid transparent;text-decoration:none;color:var(--body);font-size:13px}
29
+ .nav.active{background:#fff;border-left-color:var(--accent);color:var(--ink);font-weight:600}
30
+ .nav.muted{color:var(--muted)}
31
+ .bottom{margin:22px 8px 0;padding-top:14px;border-top:1px solid #d8dee7;display:flex;justify-content:space-between;gap:12px;align-items:center;color:var(--muted);font:10px var(--mono)}
32
+ .main{min-width:0}
33
+ .agentbar{position:sticky;top:0;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 30px;background:#fff8e8;border-bottom:1px solid var(--ask-line);color:var(--body)}
34
+ .agentbar.connected{background:var(--allow-bg);border-bottom-color:var(--allow-line)}
35
+ .agent-main{display:flex;align-items:center;gap:9px;min-width:0}
36
+ .agent-dot{width:7px;height:7px;border-radius:50%;background:#d58d3b;flex:0 0 auto}
37
+ .agentbar.connected .agent-dot{background:var(--allow)}
38
+ .agent-title{font:11px var(--mono);font-weight:600;display:block}
39
+ .agent-sub{display:block;color:var(--muted);font:9px var(--mono);margin-top:2px}
40
+ .agent-actions{display:flex;gap:5px;align-items:center}
41
+ .agent-setup{border-bottom:1px solid var(--line);background:#fff;padding:12px 30px 14px}
42
+ .agent-setup h2{font-size:13px;margin:0 0 5px}
43
+ .agent-setup p{color:var(--muted);font-size:11px;margin:4px 0}
44
+ .agent-setup pre{margin:7px 0;background:var(--paper);border:1px solid var(--line);padding:8px 10px;font:10px var(--mono);overflow:auto;white-space:pre-wrap}
45
+ .agent-setup-head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}
46
+ .agent-setup-head h2{margin:0}
47
+ .agent-setup-head p{margin:4px 0 0}
48
+ .agent-state{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:11px 0 13px}
49
+ .agent-kv{border-top:1px solid var(--line);padding-top:6px;min-width:0}
50
+ .agent-kv span{display:block;color:var(--muted);font:9px var(--mono);margin-bottom:3px}
51
+ .agent-kv b{display:block;font:10px var(--mono);font-weight:500;overflow-wrap:anywhere;white-space:normal;line-height:1.35}
52
+ .agent-control{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
53
+ .agent-modes{display:inline-flex;border:1px solid var(--line)}
54
+ .agent-modes button{border:0;border-right:1px solid var(--line);background:#fff;color:var(--body);padding:6px 9px;font:10px var(--mono);cursor:pointer}
55
+ .agent-modes button:last-child{border-right:0}
56
+ .agent-modes button:hover{background:var(--paper)}
57
+ .agent-modes button.on{background:var(--ink);color:#fff}
58
+ .agent-modes button[data-agent-mode="enforce"].on{background:var(--deny);border-color:var(--deny)}
59
+ .agent-modes button[data-agent-mode="off"].on{background:var(--muted)}
60
+ .agent-modes button:disabled{opacity:.4;cursor:default}
61
+ .agent-mode-note{color:var(--muted);font-size:10px;margin:6px 0 0;line-height:1.45}
62
+ .agent-setup-details{margin-top:10px;border-top:1px solid var(--line);padding-top:9px}
63
+ .agent-setup-details summary{cursor:pointer;color:var(--muted);font:10px var(--mono)}
64
+ .agent-setup-details summary:hover{color:var(--ink)}
65
+ .agent-setup-details p{font-size:10px}
66
+ .topbar{height:56px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 30px}
67
+ .crumbs{display:flex;align-items:center;gap:8px;min-width:0;color:var(--muted);font:11px var(--mono)}
68
+ .crumbs .current{color:var(--ink)}
69
+ .badge{border:1px solid #ead6b3;background:#fbf5e8;color:#7a4b12;padding:2px 6px;font-size:10px}
70
+ .actions{display:flex;gap:7px;align-items:center}
71
+ .button{border:1px solid #d0d5dd;background:#fff;color:var(--body);padding:7px 11px;font-size:12px;cursor:pointer}
72
+ .button.primary{border-color:var(--ink);background:var(--ink);color:#fff}
73
+ .button:hover{background:#f2f4f7}
74
+ .page{max-width:1440px;padding:24px 30px 48px}
75
+ .title-row{display:flex;align-items:end;justify-content:space-between;gap:20px;flex-wrap:wrap}
76
+ h1{font-size:30px;line-height:1.1;letter-spacing:-.03em;margin:0}
77
+ h2{font-size:15px;margin:0}
78
+ .hint{color:var(--muted);font-size:11px}
79
+ .daybar{display:grid;grid-template-columns:minmax(220px,1fr) auto minmax(160px,.4fr);gap:22px;align-items:center;border-bottom:1px solid var(--line);padding:16px 0;margin-top:18px}
80
+ .daynav{display:flex;align-items:center;gap:10px;min-width:0}
81
+ .dayarrow{width:24px;height:24px;border:1px solid var(--line);background:#fff;color:var(--muted);font:12px var(--mono);cursor:pointer}
82
+ .dayarrow:hover{color:var(--ink);border-color:#c8ced8}
83
+ .dayarrow:disabled{opacity:.35;cursor:default}
84
+ .daylabel{font-size:13px;font-weight:600;white-space:nowrap}
85
+ .daystats{color:var(--muted);font:10px var(--mono);margin-top:5px;white-space:nowrap}
86
+ .daystats strong{color:var(--body);font-weight:500}
87
+ .rate{display:inline-block;border:1px solid var(--deny-line);background:var(--deny-bg);color:var(--deny);padding:1px 4px;margin-left:6px;font-size:9px}
88
+ .week{display:flex;gap:8px;align-items:end;justify-content:center}
89
+ .day{display:flex;flex-direction:column;align-items:center;gap:5px;border:0;background:transparent;padding:0 4px;cursor:pointer;color:var(--muted)}
90
+ .day:hover .daydate{color:var(--ink)}
91
+ .day.active .daydate{color:var(--ink);font-weight:600}
92
+ .day .bar{height:38px;width:15px;display:flex;align-items:end;background:#e9edf2;position:relative;border:1px solid transparent}
93
+ .day.active .bar{border-color:var(--accent)}
94
+ .day .bar i{display:block;width:100%;background:#9aa6b5}
95
+ .day .bar b{position:absolute;left:0;bottom:0;width:100%;background:var(--deny);opacity:.72}
96
+ .daydate{font:9px var(--mono);white-space:nowrap}
97
+ .daymeta{color:var(--muted);font:9px var(--mono);white-space:nowrap}
98
+ .micro{color:var(--muted);font:9px var(--mono);margin-top:4px}
99
+
100
+ .flow-wrap{border:1px solid var(--line);background:#fff;margin-top:16px;position:relative}
101
+ .flow-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid var(--line)}
102
+ .flow-title{font-size:13px;font-weight:600}
103
+ .flow-sub{color:var(--muted);font:10px var(--mono);margin-top:4px}
104
+ .flow-tools{display:flex;gap:5px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
105
+ .zoom-level{min-width:24px;text-align:center;color:var(--muted);font:9px var(--mono)}
106
+ .small-button{border:1px solid var(--line);background:#fff;color:var(--body);padding:4px 7px;font:10px var(--mono);cursor:pointer}
107
+ .small-button:hover{background:var(--paper);color:var(--ink)}
108
+ .flowfilters{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--line);background:#fcfcfd}
109
+ .flowfilters input{flex:1;min-width:160px;border:1px solid var(--line);background:#fff;padding:5px 6px;font:10px var(--mono)}
110
+ .flowfilters select{border:1px solid var(--line);background:#fff;padding:4px 5px;font:10px var(--mono)}
111
+ .flowfilters .search-count{margin-left:auto}
112
+ .search-count{color:var(--muted);font:9px var(--mono);white-space:nowrap}
113
+ .flow-scroller{overflow-x:auto;overflow-y:hidden;scrollbar-color:#c3c9d3 #f2f4f7}
114
+ .flow-track{position:relative;width:2880px;height:250px}
115
+ .flow-hours{position:absolute;top:0;left:0;right:0;height:22px;border-bottom:1px solid var(--line)}
116
+ .flow-hour{position:absolute;top:0;bottom:0;border-left:1px solid #edf0f4;color:var(--muted);font:8px var(--mono);padding:4px 0 0 3px;white-space:nowrap}
117
+ .flow-hour.major{border-left-color:#dce2ea}
118
+ .flow-lanes{position:absolute;top:22px;left:0;right:0;bottom:0}
119
+ .f-lane{position:relative;height:45px;border-bottom:1px solid var(--line)}
120
+ .f-label{position:sticky;left:0;z-index:4;display:flex;align-items:center;width:66px;height:100%;padding-left:14px;background:#fff;color:var(--muted);font:9px var(--mono);letter-spacing:.04em}
121
+ .f-track{position:absolute;top:0;bottom:0;left:66px;right:0}
122
+ .f-lane:last-child{border-bottom:0}
123
+ .fm{position:absolute;top:7px;width:3px;height:30px;border:0;padding:0;background:#9aa6b5;cursor:pointer;transform:translateX(-50%);border-radius:2px}
124
+ .fm.allow{background:#6f9d86}
125
+ .fm.deny{background:var(--accent);opacity:.9}
126
+ .fm:hover{outline:1px solid var(--ink);outline-offset:1px;z-index:5}
127
+ .fm.selected{outline:2px solid var(--ink);outline-offset:1px;z-index:6}
128
+ .fm.draft-allow{background:var(--allow)}
129
+ .fm.draft-deny{background:var(--deny)}
130
+ .fm.draft-ask{background:var(--ask)}
131
+ .fm.detail{height:34px;padding:2px 4px;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:1px;color:#fff;font:7px/1.15 var(--mono);overflow:hidden;text-align:left}
132
+ .fm.detail .fm-time{font-size:8px;font-weight:600;letter-spacing:.01em}
133
+ .fm.detail .fm-label{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.92}
134
+ .fm.detail .fm-count{position:absolute;right:3px;bottom:2px;font-size:8px;font-weight:600;opacity:.86}
135
+ .fm.detail.wide{font-size:8px;padding:3px 6px}
136
+ .fm.detail.wide .fm-time{font-size:9px}
137
+ .fm.detail.wide .fm-count{font-size:9px}
138
+ .fm.mixed{background:linear-gradient(180deg,var(--accent) 0 50%,#6f9d86 50% 100%)}
139
+ .fm.has-draft{box-shadow:0 0 0 1px var(--ask-line)}
140
+ .fp-list{display:grid;gap:0;margin-top:9px;border-top:1px solid var(--line)}
141
+ .fp-row{display:grid;grid-template-columns:48px 1fr auto;gap:8px;align-items:center;border:0;border-bottom:1px solid var(--line);background:#fff;color:var(--body);padding:7px 3px;text-align:left;font:10px var(--mono);cursor:pointer}
142
+ .fp-row[hidden]{display:none}
143
+ .fp-row:hover{background:var(--paper)}
144
+ .fp-row .deny{color:var(--deny)}.fp-row .allow{color:#1f5c40}
145
+ .fp-more{border:0;border-bottom:1px solid var(--line);background:#fff;color:var(--muted);font:10px var(--mono);padding:8px 2px;text-align:left;cursor:pointer}
146
+ .fp-more:hover{color:var(--ink);background:var(--paper)}
147
+ .fp-list.expanded{max-height:220px;overflow-y:auto}
148
+ .flow-legend{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:8px 14px;border-top:1px solid var(--line);color:var(--muted);font:9px var(--mono)}
149
+ .flow-legend span{display:inline-flex;align-items:center;gap:5px}
150
+ .swatch{display:inline-block;width:6px;height:12px;background:#9aa6b5}
151
+ .swatch.allow{background:#6f9d86}.swatch.deny{background:var(--accent)}.swatch.ask{background:var(--ask)}
152
+
153
+ .fp{position:absolute;z-index:20;width:420px;background:#fff;border:1px solid var(--ink);box-shadow:0 10px 28px rgba(11,13,16,.16);padding:14px 16px}
154
+ .fp-head{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--muted);font:10px var(--mono)}
155
+ .fp-close{border:0;background:transparent;color:var(--muted);font:16px/1 var(--mono);cursor:pointer;padding:0 2px}
156
+ .fp-close:hover{color:var(--ink)}
157
+ .fp-title{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin:7px 0 9px}
158
+ .fp-title strong{font-size:17px;font-weight:600}
159
+ .fp-code{font:10px var(--mono);color:var(--deny)}
160
+ .fp-code.allow{color:#1f5c40}
161
+ .fp-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px 16px;border-top:1px solid var(--line);padding-top:9px}
162
+ .fp-k{color:var(--muted);font:9px var(--mono);display:block}
163
+ .fp-v{font:11px var(--mono);color:var(--body);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
164
+ .fp-actions{display:flex;gap:6px;margin-top:12px}
165
+ .fp-actions button{border:1px solid var(--line);background:#fff;color:var(--body);font:10px var(--mono);padding:7px 10px;cursor:pointer}
166
+ .fp-actions button:hover{background:var(--paper)}
167
+ .fp-actions button.on[data-state="allow"]{background:var(--allow-bg);border-color:var(--allow-line);color:#1f5c40;font-weight:600}
168
+ .fp-actions button.on[data-state="deny"]{background:var(--deny-bg);border-color:var(--deny-line);color:var(--deny);font-weight:600}
169
+ .fp-actions button.on[data-state="ask"]{background:var(--ask-bg);border-color:var(--ask-line);color:var(--ask);font-weight:600}
170
+ .fp-note{color:var(--muted);font-size:10px;line-height:1.5;margin:9px 0 0}
171
+ .fp-scope{color:var(--muted);font:9px var(--mono);margin-top:8px}
172
+ .fp-scope label{display:flex;align-items:center;gap:5px;cursor:pointer}
173
+ .fp-scope input{accent-color:var(--ink)}
174
+ .fp-draft{margin-left:auto;color:var(--muted);font:9px var(--mono)}
175
+
176
+ .tokens{border:1px solid var(--line);background:#fff;margin-top:16px;padding:12px 14px}
177
+ .tokens-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
178
+ .tokens-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:10px}
179
+ .token-stat{border-top:1px solid var(--line);padding-top:7px}
180
+ .token-stat span{display:block;color:var(--muted);font:9px var(--mono);margin-bottom:4px}
181
+ .token-stat strong{font:13px var(--mono);font-weight:500;color:var(--body)}
182
+ .tokens-line{display:flex;gap:8px 18px;flex-wrap:wrap;border-top:1px solid var(--line);margin-top:9px;padding-top:8px;color:var(--muted);font:9px var(--mono)}
183
+ .tokens-line b{color:var(--body);font-weight:500}
184
+ .tokens-note{color:var(--muted);font-size:10px;line-height:1.5;margin:7px 0 0}
185
+
186
+ .panel{border-top:1px solid var(--line);margin-top:22px;padding-top:12px}
187
+ .panel-title{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:9px}
188
+ .paths{display:grid;grid-template-columns:1fr 1fr;gap:16px;min-width:0}
189
+ .path-group{border:1px solid var(--line);background:#fff;padding:9px 10px;min-width:0}
190
+ .path-head{display:flex;justify-content:space-between;color:var(--muted);font:9px var(--mono);margin-bottom:7px}
191
+ .path-row{display:flex;justify-content:space-between;gap:10px;align-items:center;border-top:1px solid var(--line);padding:5px 0;font:10px var(--mono);min-width:0}
192
+ .path-row .path-remove{border:1px solid var(--line);background:#fff;color:var(--muted);font:9px var(--mono);padding:2px 5px;cursor:pointer}
193
+ .path-row .path-remove:hover{color:var(--deny);border-color:var(--deny-line)}
194
+ .path-add{display:flex;gap:5px;flex-wrap:wrap;align-items:center;margin-top:7px}
195
+ .path-add input{flex:1;min-width:0;border:1px solid var(--line);background:#fff;padding:5px 6px;font:10px var(--mono)}
196
+ .path-add select{border:1px solid var(--line);background:#fff;padding:4px;font:10px var(--mono)}
197
+ .path-actions{display:flex;gap:8px;align-items:center;margin-top:9px}
198
+ .path-actions .button{font-size:11px;padding:5px 9px}
199
+ .path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
200
+ .tag{border:1px solid var(--line);color:var(--muted);padding:1px 4px;font:8px var(--mono);white-space:nowrap}
201
+ .tag.deny{border-color:var(--deny-line);color:var(--deny);background:var(--deny-bg)}
202
+ .tag.allow{border-color:var(--allow-line);color:#1f5c40;background:var(--allow-bg)}
203
+ .legend{margin-top:8px;color:var(--muted);font:9px var(--mono)}
204
+
205
+ .calls-drawer{border:1px solid var(--ink);background:#fff;padding:12px;margin-top:16px}
206
+ .calls-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:8px}
207
+ .calls-head h2{margin:0}
208
+ .calls-filter{display:flex;gap:5px;flex-wrap:wrap;align-items:center;margin-bottom:7px}
209
+ .calls-filter input{flex:1;min-width:140px;border:1px solid var(--line);background:var(--paper);padding:5px 6px;font:10px var(--mono)}
210
+ .calls-filter select{border:1px solid var(--line);background:var(--paper);padding:4px;font:10px var(--mono)}
211
+ .calls-filter .search-count{margin-left:auto}
212
+ .calls-lifetime{display:flex;gap:5px 14px;flex-wrap:wrap;align-items:center;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:6px 0;margin-bottom:6px;color:var(--muted);font:9px var(--mono)}
213
+ .calls-lifetime b{color:var(--body);font-weight:500}
214
+ .calls-drawer .table-wrap{max-height:360px;overflow:auto}
215
+ .calls-table{width:100%;border-collapse:collapse;font:10px var(--mono)}
216
+ .calls-table th{padding:5px 6px;border-bottom:1px solid var(--line);color:var(--muted);font:9px var(--mono);text-align:left;font-weight:500;white-space:nowrap}
217
+ .calls-table td{padding:4px 6px;border-bottom:1px solid var(--line);white-space:nowrap;max-width:250px;overflow:hidden;text-overflow:ellipsis}
218
+ .calls-table tbody tr{cursor:pointer}
219
+ .calls-table tbody tr:hover td{background:#f7f8fa}
220
+ .calls-table .deny{color:var(--deny)}.calls-table .allow{color:#1f5c40}
221
+ .calls-foot{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:6px;color:var(--muted);font:9px var(--mono)}
222
+ .calls-foot button{border:1px solid var(--line);background:#fff;color:var(--body);font:9px var(--mono);padding:4px 6px;cursor:pointer}
223
+ .calls-foot button:disabled{opacity:.35;cursor:default}
224
+
225
+ @media(max-width:1000px){
226
+ .shell{grid-template-columns:1fr}
227
+ .rail{display:none}
228
+ .page{padding:20px 16px 36px}
229
+ .topbar{padding:0 16px}
230
+ .daybar{grid-template-columns:1fr;gap:12px}
231
+ .week{justify-content:flex-start}
232
+ .tokens-grid{grid-template-columns:1fr 1fr}
233
+ .paths{grid-template-columns:1fr}
234
+ .agent-state{grid-template-columns:1fr 1fr}
235
+ }
236
+ @media(max-width:560px){
237
+ .topbar{height:auto;padding:10px 14px;align-items:flex-start;gap:8px;flex-wrap:wrap}
238
+ .page{padding:16px 12px 30px}
239
+ .flow-head{flex-direction:column}
240
+ .flow-tools{justify-content:flex-start}
241
+ .flowfilters input{flex-basis:100%}
242
+ .flowfilters .search-count{margin-left:0}
243
+ .flow-track{width:2400px;height:230px}
244
+ .flow-hour{font-size:7px}
245
+ .fp{width:min(340px,calc(100vw - 24px))}
246
+ .calls-filter select,.calls-filter input{flex:1 1 110px}
247
+ .agent-setup{padding:12px 16px 14px}
248
+ .agent-state{grid-template-columns:1fr}
249
+ .agent-control{gap:6px}
250
+ .agent-modes{width:100%}
251
+ .agent-modes button{flex:1}
252
+ }
253
+ </style>
254
+ </head>
255
+ <body>
256
+ <div class="shell">
257
+ <aside class="rail">
258
+ <a class="wordmark" href="#">Bounds</a>
259
+ <div class="workspace">
260
+ <div class="rail-label">Workspace</div>
261
+ <div class="workspace-name"><span class="avatar">A</span><span>local</span></div>
262
+ </div>
263
+ <div class="rail-label">Work</div>
264
+ <a class="nav active" href="#">Observed flow</a>
265
+ <a class="nav muted" href="#tokens">Impact</a>
266
+ <a class="nav muted" href="#paths">Paths</a>
267
+ <div class="bottom"><span>ledger</span><span id="ledger-total">0</span></div>
268
+ </aside>
269
+ <main class="main">
270
+ <div class="agentbar" id="agentbar">
271
+ <div class="agent-main"><span class="agent-dot"></span><div><span class="agent-title" id="agent-title">Checking agent</span><span class="agent-sub" id="agent-sub">Reading local status</span></div></div>
272
+ <div class="agent-actions"><button class="small-button" type="button" id="agent-action">Connect agent</button></div>
273
+ </div>
274
+ <div class="agent-setup" id="agent-setup" hidden>
275
+ <div class="agent-setup-head">
276
+ <div><h2 id="agent-setup-title">Agent control</h2><p id="agent-setup-copy">Choose how much Bounds may block, then wire the local hook.</p></div>
277
+ <button class="small-button" type="button" id="agent-setup-close">Close</button>
278
+ </div>
279
+ <div class="agent-state" id="agent-state"></div>
280
+ <div class="agent-control">
281
+ <span class="hint">Permission</span>
282
+ <div class="agent-modes" id="agent-modes" role="group" aria-label="Agent permission mode">
283
+ <button type="button" data-agent-mode="observe">Observe</button>
284
+ <button type="button" data-agent-mode="enforce">Enforce</button>
285
+ <button type="button" data-agent-mode="off">Off</button>
286
+ </div>
287
+ <button class="small-button" type="button" id="agent-connect-now">Connect now</button>
288
+ <button class="small-button" type="button" id="agent-manage-paths">Edit path permissions</button>
289
+ <span class="hint" id="agent-setup-status"></span>
290
+ </div>
291
+ <p class="agent-mode-note" id="agent-mode-note">Observe records every call without blocking. Enforce refuses calls outside the policy.</p>
292
+ <details class="agent-setup-details">
293
+ <summary>Agent setup commands</summary>
294
+ <p>An agent can run this itself. It initializes the ledger, writes the mode, wires the detected harness and writes AGENTS.md.</p>
295
+ <pre>npx attesso connect --write --mode observe</pre>
296
+ <p>Inspect the machine-readable result without a dashboard:</p>
297
+ <pre>npx attesso status --json</pre>
298
+ <p>For a custom harness, send one proposed tool call as JSON on stdin and read the verdict on stdout:</p>
299
+ <pre>npx attesso hook --observe</pre>
300
+ <p>Use <code>npx attesso mode enforce</code> when the observe pass looks right.</p>
301
+ </details>
302
+ </div>
303
+ <div class="topbar">
304
+ <div class="crumbs"><span>Bounds</span><span>/</span><span class="current">local</span><span class="badge">ledger</span></div>
305
+ <div class="actions">
306
+ <button class="button" type="button" id="export-draft">Export draft</button>
307
+ <button class="button" type="button" id="all-calls">All calls</button>
308
+ </div>
309
+ </div>
310
+ <div class="page">
311
+ <div class="title-row">
312
+ <div><h1>Bounds</h1><div class="micro" id="page-sub">Every tool call, decided locally before it runs</div></div>
313
+ <div class="micro">Decision edits are drafts. Connection and Paths save to the local ledger.</div>
314
+ </div>
315
+
316
+ <section class="daybar">
317
+ <div class="daynav">
318
+ <button class="dayarrow" type="button" id="prev-day" aria-label="Previous day">&lt;</button>
319
+ <div class="daycurrent"><div class="daylabel" id="day-label"></div><div class="daystats" id="day-stats"></div></div>
320
+ <button class="dayarrow" type="button" id="next-day" aria-label="Next day">&gt;</button>
321
+ </div>
322
+ <div class="week" id="week"></div>
323
+ <div><div class="micro" id="day-source">local ledger</div><div class="micro" id="day-coverage">00:00 - 24:00</div></div>
324
+ </section>
325
+
326
+ <section class="flow-wrap" aria-label="Observed flow">
327
+ <div class="flow-head">
328
+ <div><div class="flow-title">Observed flow</div><div class="flow-sub" id="flow-sub">loading</div></div>
329
+ <div class="flow-tools">
330
+ <button class="small-button" type="button" id="zoom-out" aria-label="Zoom out">&minus;</button>
331
+ <span class="zoom-level" id="zoom-level">16x</span>
332
+ <button class="small-button" type="button" id="zoom-in" aria-label="Zoom in">+</button>
333
+ <button class="small-button" type="button" id="flow-prev" aria-label="Scroll flow left">&lt;</button>
334
+ <button class="small-button" type="button" id="flow-next" aria-label="Scroll flow right">&gt;</button>
335
+ <button class="small-button" type="button" id="flow-start">00:00</button>
336
+ <button class="small-button" type="button" id="flow-end">24:00</button>
337
+ <button class="small-button" type="button" id="flow-calls">All calls</button>
338
+ </div>
339
+ </div>
340
+ <div class="flowfilters">
341
+ <input id="flow-search" placeholder="tool, task, code, #seq" autocomplete="off" aria-label="Search calls">
342
+ <select id="flow-cap" aria-label="Capability">
343
+ <option value="">All capabilities</option>
344
+ <option value="read">Read</option>
345
+ <option value="write">Write</option>
346
+ <option value="execute">Execute</option>
347
+ <option value="send">Send</option>
348
+ <option value="spend">Spend</option>
349
+ </select>
350
+ <select id="flow-decision" aria-label="Decision">
351
+ <option value="">All decisions</option>
352
+ <option value="ALLOW">Allowed</option>
353
+ <option value="DENY">Blocked</option>
354
+ </select>
355
+ <span class="search-count" id="flow-count">0 calls</span>
356
+ </div>
357
+ <div class="flow-scroller" id="flow-scroller">
358
+ <div class="flow-track" id="flow-track">
359
+ <div class="flow-hours" id="flow-hours"></div>
360
+ <div class="flow-lanes" id="flow-lanes"></div>
361
+ </div>
362
+ </div>
363
+ <div class="fp" id="flow-popover" hidden></div>
364
+ <div class="flow-legend"><span><i class="swatch allow"></i>allowed</span><span><i class="swatch deny"></i>blocked</span><span><i class="swatch ask"></i>draft ask</span><span>click a block to inspect and set a rule draft</span><span>scroll horizontally through the day</span></div>
365
+ </section>
366
+
367
+ <section class="tokens" id="tokens">
368
+ <div class="tokens-head"><h2>Impact</h2><span class="hint">blocked calls; measured usage only</span></div>
369
+ <div class="tokens-grid">
370
+ <div class="token-stat"><span>Blocked calls</span><strong id="tok-blocked">0</strong></div>
371
+ <div class="token-stat"><span id="token-label">Measured task tokens</span><strong id="tok-saved">not measured</strong></div>
372
+ <div class="token-stat"><span>Blocked share</span><strong id="tok-share">0%</strong></div>
373
+ <div class="token-stat"><span>Token coverage</span><strong id="tok-coverage">0%</strong></div>
374
+ </div>
375
+ <div class="tokens-line"><span>Blocked tasks <b id="tok-tasks">0</b></span><span>Blocked tools <b id="tok-tools">0</b></span><span>Top code <b id="tok-rule">n/a</b></span><span>Calls measured <b id="tok-covered">0</b></span></div>
376
+ <p class="tokens-note" id="tok-note">No usage data for this day. Blocked call counts are still exact.</p>
377
+ </section>
378
+
379
+ <section class="panel" id="paths">
380
+ <div class="panel-title"><h2>Paths</h2><span class="hint">file tools only; shell is separate</span></div>
381
+ <div class="paths">
382
+ <div class="path-group"><div class="path-head"><span>Allowed roots</span><button class="small-button" type="button" id="root-add">Add root</button></div><div id="allowed-list"></div></div>
383
+ <div class="path-group"><div class="path-head"><span>Excluded everywhere</span><button class="small-button" type="button" id="exclusion-add">Add exclusion</button></div><div id="exclusion-list"></div></div>
384
+ </div>
385
+ <div class="path-add"><input id="root-input" placeholder="/absolute/root or ~/project" autocomplete="off"><select id="root-access"><option value="read,write">read + write</option><option value="read">read only</option></select><button class="small-button" type="button" id="root-commit">Add</button></div>
386
+ <div class="path-add"><input id="exclusion-input" placeholder="**/.env or **/.ssh/**" autocomplete="off"><button class="small-button" type="button" id="exclusion-commit">Add exclusion</button></div>
387
+ <div class="path-actions"><button class="button" type="button" id="paths-save">Save paths</button><span class="hint" id="paths-status">Loading paths</span></div>
388
+ <div class="legend">Deny wins. Unresolved paths are refused. Saving writes the local ledger; the next hook call reads the new file.</div>
389
+ </section>
390
+
391
+ <section class="calls-drawer" id="calls-drawer">
392
+ <div class="calls-head"><h2>All calls</h2><div class="actions"><button class="small-button" type="button" id="calls-csv">CSV</button></div></div>
393
+ <div class="calls-filter">
394
+ <select id="calls-day" aria-label="Day"><option value="*">Recent</option></select>
395
+ <input id="calls-search" placeholder="tool, task, code, #seq" autocomplete="off">
396
+ <select id="calls-tool" aria-label="Tool"><option value="">All tools</option></select>
397
+ <select id="calls-capability" aria-label="Capability"><option value="">All capabilities</option></select>
398
+ <select id="calls-decision" aria-label="Decision"><option value="">All decisions</option><option value="ALLOW">Allowed</option><option value="DENY">Blocked</option></select>
399
+ <select id="calls-hour" aria-label="Hour"><option value="">All hours</option></select>
400
+ <span class="search-count" id="calls-count">0 calls</span>
401
+ </div>
402
+ <div class="calls-lifetime" id="calls-lifetime"></div>
403
+ <div class="table-wrap"><table class="calls-table"><thead><tr><th>Day</th><th>Time</th><th>Tool</th><th>Capability</th><th>Decision</th><th>Code</th><th>Task</th></tr></thead><tbody id="calls-body"></tbody></table></div>
404
+ <div class="calls-foot"><span id="calls-page"></span><span><button type="button" id="calls-prev">Previous</button> <button type="button" id="calls-next">Next</button></span></div>
405
+ </section>
406
+ </div>
407
+ </main>
408
+ </div>
409
+ <script>
410
+ (async function () {
411
+ window.history.scrollRestoration = 'manual';
412
+ const el = (id) => document.getElementById(id);
413
+ // All ledger fields are escaped below; the data is local and not
414
+ // attacker-controlled, but escaping keeps the DOM contract explicit.
415
+ const esc = (value) => String(value ?? '').replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
416
+ const fmt = (n) => Number(n || 0).toLocaleString('en-US');
417
+ const compact = (n) => {
418
+ const value = Number(n || 0);
419
+ if (value >= 1e9) return (value / 1e9).toFixed(1) + 'B';
420
+ if (value >= 1e6) return (value / 1e6).toFixed(1) + 'M';
421
+ if (value >= 1e3) return (value / 1e3).toFixed(1) + 'k';
422
+ return fmt(value);
423
+ };
424
+ let calls = {};
425
+ let tokenData = {};
426
+ let usageData = null;
427
+ let agentData = {};
428
+ let selectedAgentMode = null;
429
+ let pathState = null;
430
+ let v1 = false;
431
+ let dayStats = new Map();
432
+ let summaryCache = new Map();
433
+ let flowCache = new Map();
434
+ let currentCalls = 0;
435
+ let dayRequest = 0;
436
+ let callsRequest = 0;
437
+ let allRows = [];
438
+ let allCursor = '';
439
+ let allHasMore = false;
440
+ let allCursors = [];
441
+ let allKey = '';
442
+ const TOKEN = document.querySelector('meta[name="bounds-token"]')?.content ?? '';
443
+ const AUTH = TOKEN && TOKEN !== '__BOUNDS_TOKEN__' ? { 'x-bounds-token': TOKEN } : {};
444
+ const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
445
+ let controlApi = false;
446
+ let initialHead = 0;
447
+ let days = [];
448
+ try {
449
+ const response = await fetch('/api/status', { cache: 'no-store', headers: AUTH });
450
+ if (response.ok) {
451
+ agentData = await response.json();
452
+ if (agentData.paths) pathState = agentData.paths;
453
+ controlApi = true;
454
+ }
455
+ } catch {
456
+ controlApi = false;
457
+ }
458
+ try {
459
+ if (controlApi) {
460
+ const response = await fetch('/api/v1/days?limit=14&tz=' + encodeURIComponent(TZ), { cache: 'no-store', headers: AUTH });
461
+ if (!response.ok) throw new Error('days ' + response.status);
462
+ const payload = await response.json();
463
+ v1 = true;
464
+ for (const item of payload.days || []) {
465
+ days.push(item.day);
466
+ dayStats.set(item.day, item);
467
+ calls[item.day] = [];
468
+ }
469
+ const current = days[days.length - 1];
470
+ if (current) {
471
+ const summaryResponse = await fetch('/api/v1/summary?day=' + encodeURIComponent(current) + '&tz=' + encodeURIComponent(TZ), { cache: 'no-store', headers: AUTH });
472
+ if (summaryResponse.ok) {
473
+ const summaryValue = await summaryResponse.json();
474
+ summaryCache.set(current, summaryValue);
475
+ initialHead = Number(summaryValue.head || 0);
476
+ }
477
+ }
478
+ } else {
479
+ calls = await fetch('calls.json').then((response) => { if (!response.ok) throw new Error('calls.json ' + response.status); return response.json(); });
480
+ tokenData = await fetch('tokens.json').then((response) => (response.ok ? response.json() : {})).catch(() => ({}));
481
+ agentData = await fetch('agent.json').then((response) => (response.ok ? response.json() : {})).catch(() => ({}));
482
+ }
483
+ } catch (error) {
484
+ el('flow-sub').textContent = 'Could not load ledger: ' + error.message;
485
+ return;
486
+ }
487
+ if (!v1) days = Object.keys(calls).sort();
488
+ else days.sort();
489
+ if (!days.length) {
490
+ const today = new Date();
491
+ const pad = (value) => String(value).padStart(2, '0');
492
+ const placeholder = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
493
+ calls[placeholder] = [];
494
+ days = [placeholder];
495
+ }
496
+ const CAPS = ['read', 'write', 'execute', 'send', 'spend'];
497
+ const BASE_WIDTH = 2880;
498
+ const GUTTER = 66;
499
+ const ZOOM_LEVELS = [1, 2, 4, 8, 16];
500
+ let zoom = 16;
501
+ const PAGE = 50;
502
+ let selectedDay = days[days.length - 1];
503
+ let callsDay = selectedDay;
504
+ let callsPage = 0;
505
+ let callsHour = '';
506
+ let selectedSeq = null;
507
+ let selectedMarker = null;
508
+ let drafts = new Map();
509
+ let bySeq = new Map();
510
+ let bucketRows = new Map();
511
+ let markerBySeq = new Map();
512
+ let currentRows = [];
513
+
514
+ function dayDate(day) {
515
+ return new Date(day + 'T12:00:00');
516
+ }
517
+ function dayLabel(day) {
518
+ return dayDate(day).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' });
519
+ }
520
+ function statusFor(row) {
521
+ return row[4] === 'DENY' ? 'deny' : 'allow';
522
+ }
523
+ function minuteOf(time) {
524
+ const parts = String(time).split(':');
525
+ return Number(parts[0]) * 60 + Number(parts[1]);
526
+ }
527
+ function bucketTime(time) {
528
+ const minutes = minuteOf(time);
529
+ const start = Math.floor(minutes / 5) * 5;
530
+ return String(Math.floor(start / 60)).padStart(2, '0') + ':' + String(start % 60).padStart(2, '0');
531
+ }
532
+ function summarize(day) {
533
+ if (v1) {
534
+ const stats = summaryCache.get(day) || dayStats.get(day) || { calls: 0, blocked: 0 };
535
+ return { rows: calls[day] || [], calls: Number(stats.calls || 0), blocked: Number(stats.blocked || 0), allowed: Number(stats.calls || 0) - Number(stats.blocked || 0), caps: {} };
536
+ }
537
+ const rows = calls[day] || [];
538
+ let blocked = 0;
539
+ let allowed = 0;
540
+ const caps = {};
541
+ for (const row of rows) {
542
+ if (!caps[row[3]]) caps[row[3]] = 0;
543
+ caps[row[3]] += 1;
544
+ if (row[4] === 'DENY') blocked += 1; else allowed += 1;
545
+ }
546
+ return { rows, calls: rows.length, blocked, allowed, caps };
547
+ }
548
+ function tokenImpact(day) {
549
+ if (v1) {
550
+ const stats = summarize(day);
551
+ const buckets = flowCache.get(day)?.buckets || [];
552
+ const tools = new Set();
553
+ const tasks = new Set();
554
+ const rules = new Map();
555
+ for (const bucket of buckets) {
556
+ if (bucket.blocked > 0) {
557
+ for (const tool of bucket.tools || []) tools.add(tool);
558
+ for (const task of bucket.tasks || []) tasks.add(task);
559
+ for (const code of bucket.codes || []) rules.set(code, (rules.get(code) || 0) + 1);
560
+ }
561
+ }
562
+ return {
563
+ calls: stats.calls,
564
+ blocked: stats.blocked,
565
+ tasks: tasks.size,
566
+ tools: tools.size,
567
+ topRule: [...rules.entries()].sort((a, b) => b[1] - a[1])[0] || null,
568
+ usageCalls: 0,
569
+ usageBlocked: 0,
570
+ estimated: 0,
571
+ coverage: 0,
572
+ };
573
+ }
574
+ const rows = calls[day] || [];
575
+ const tasks = new Map();
576
+ const rules = new Map();
577
+ const blockedTools = new Set();
578
+ for (const row of rows) {
579
+ const task = tasks.get(row[6]) || { calls: 0, blocked: 0 };
580
+ task.calls += 1;
581
+ if (row[4] === 'DENY') task.blocked += 1;
582
+ tasks.set(row[6], task);
583
+ if (row[4] === 'DENY') {
584
+ blockedTools.add(row[2]);
585
+ rules.set(row[5] || 'policy', (rules.get(row[5] || 'policy') || 0) + 1);
586
+ }
587
+ }
588
+ let usageCalls = 0;
589
+ let usageBlocked = 0;
590
+ let measuredTokens = 0;
591
+ for (const [task, counts] of tasks) {
592
+ const usage = tokenData[task];
593
+ if (!usage) continue;
594
+ usageCalls += counts.calls;
595
+ usageBlocked += counts.blocked;
596
+ measuredTokens += (usage['in'] || 0) + (usage.out || 0);
597
+ }
598
+ const blocked = rows.filter((row) => row[4] === 'DENY').length;
599
+ const average = usageCalls ? measuredTokens / usageCalls : 0;
600
+ const estimated = average * blocked;
601
+ const topRule = [...rules.entries()].sort((a, b) => b[1] - a[1])[0] || null;
602
+ return {
603
+ calls: rows.length,
604
+ blocked,
605
+ tasks: [...tasks.values()].filter((task) => task.blocked > 0).length,
606
+ tools: blockedTools.size,
607
+ topRule,
608
+ usageCalls,
609
+ usageBlocked,
610
+ estimated,
611
+ coverage: rows.length ? usageCalls / rows.length : 0,
612
+ };
613
+ }
614
+
615
+ function ingestLive(payload) {
616
+ if (!payload || !payload.day || !payload.row) return;
617
+ const day = payload.day;
618
+ if (v1) {
619
+ const stats = dayStats.get(day) || { day, calls: 0, blocked: 0 };
620
+ stats.calls += 1;
621
+ if (payload.row[4] === 'DENY') stats.blocked += 1;
622
+ dayStats.set(day, stats);
623
+ if (!days.includes(day)) { days.push(day); days.sort(); }
624
+ initialHead = Math.max(initialHead, Number(payload.seq || 0));
625
+ summaryCache.delete(day);
626
+ flowCache.delete(day);
627
+ if (day === selectedDay) renderDay(day); else renderDayStrip();
628
+ return;
629
+ }
630
+ if (!calls[day]) {
631
+ calls[day] = [];
632
+ days.push(day);
633
+ days.sort();
634
+ const option = document.createElement('option');
635
+ option.value = day;
636
+ option.textContent = dayDate(day).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' });
637
+ el('calls-day').append(option);
638
+ }
639
+ if (calls[day].some((row) => String(row[0]) === String(payload.row[0]))) return;
640
+ calls[day].push(payload.row);
641
+ initialHead = Math.max(initialHead, Number(payload.seq || 0));
642
+ if (day === selectedDay && !selectedSeq) {
643
+ renderDay(day);
644
+ } else {
645
+ renderLifetime();
646
+ }
647
+ }
648
+
649
+ async function startLive() {
650
+ if (!controlApi) return;
651
+ try {
652
+ const response = await fetch('/api/events?after=' + encodeURIComponent(initialHead), {
653
+ headers: AUTH,
654
+ cache: 'no-store'
655
+ });
656
+ if (!response.ok || !response.body) return;
657
+ const reader = response.body.getReader();
658
+ const decoder = new TextDecoder();
659
+ let buffer = '';
660
+ while (true) {
661
+ const { value, done } = await reader.read();
662
+ if (done) break;
663
+ buffer += decoder.decode(value, { stream: true });
664
+ let boundary;
665
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
666
+ const block = buffer.slice(0, boundary);
667
+ buffer = buffer.slice(boundary + 2);
668
+ const data = block.split('\n').find((line) => line.startsWith('data: '));
669
+ if (!data) continue;
670
+ try { ingestLive(JSON.parse(data.slice(6))); } catch { /* a malformed event is ignored; the ledger is authoritative */ }
671
+ }
672
+ }
673
+ } catch {
674
+ // The server can restart; retry with the last sequence we saw.
675
+ }
676
+ if (controlApi) setTimeout(startLive, 2000);
677
+ }
678
+
679
+ function renderAgentStatus() {
680
+ const connected = Boolean(agentData.connected);
681
+ const bar = el('agentbar');
682
+ bar.classList.toggle('connected', connected);
683
+ el('agent-title').textContent = connected ? 'Agent connected' : 'No agent connected';
684
+ if (connected) {
685
+ const seq = agentData.last_seq ? ' - last decision #' + agentData.last_seq : '';
686
+ el('agent-sub').textContent = 'Hook active' + seq;
687
+ el('agent-action').textContent = 'Manage agent';
688
+ } else {
689
+ el('agent-sub').textContent = 'Hook is ' + (agentData.mode || 'not configured') + ' - connect any agent to start observing';
690
+ el('agent-action').textContent = 'Connect agent';
691
+ }
692
+ renderAgentPanel();
693
+ }
694
+
695
+ function renderAgentPanel() {
696
+ const rawMode = selectedAgentMode || agentData.mode || 'observe';
697
+ const mode = rawMode.charAt(0).toUpperCase() + rawMode.slice(1);
698
+ const modeValue = rawMode.toLowerCase();
699
+ const connected = Boolean(agentData.connected);
700
+ const wired = Array.isArray(agentData.wired) && agentData.wired.length ? agentData.wired.join(', ') : 'none';
701
+ const ledger = agentData.ledger || 'not initialized';
702
+ const status = connected ? 'Connected' : (agentData.mode === 'off' ? 'Off' : 'Not connected');
703
+ el('agent-setup-title').textContent = connected ? 'Agent control' : 'Connect an agent';
704
+ el('agent-setup-copy').textContent = connected
705
+ ? 'Choose how much Bounds may block. Path rules decide which files file tools can touch.'
706
+ : 'Wire a local hook first, then choose how much Bounds may block.';
707
+ const state = el('agent-state');
708
+ state.replaceChildren();
709
+ for (const [label, value, title] of [
710
+ ['Status', status, ''],
711
+ ['Mode', mode, ''],
712
+ ['Wired', wired, wired],
713
+ ['Ledger', ledger, ledger],
714
+ ]) {
715
+ const item = document.createElement('div');
716
+ item.className = 'agent-kv';
717
+ const key = document.createElement('span');
718
+ key.textContent = label;
719
+ const val = document.createElement('b');
720
+ val.textContent = value;
721
+ if (title) val.title = title;
722
+ item.append(key, val);
723
+ state.append(item);
724
+ }
725
+ for (const button of el('agent-modes').querySelectorAll('[data-agent-mode]')) {
726
+ button.classList.toggle('on', button.dataset.agentMode === modeValue);
727
+ button.setAttribute('aria-pressed', String(button.dataset.agentMode === modeValue));
728
+ button.disabled = !controlApi;
729
+ }
730
+ el('agent-mode-note').textContent = modeValue === 'enforce'
731
+ ? 'Enforce refuses calls outside the policy. Review observe output before turning this on.'
732
+ : modeValue === 'off'
733
+ ? 'Off stops the hook completely. Nothing is recorded or blocked.'
734
+ : 'Observe records every call without blocking. This is the safe default.';
735
+ el('agent-connect-now').textContent = connected ? 'Reconnect' : 'Connect now';
736
+ }
737
+
738
+ function renderPaths() {
739
+ const allowed = el('allowed-list');
740
+ const excluded = el('exclusion-list');
741
+ allowed.replaceChildren();
742
+ excluded.replaceChildren();
743
+ if (!pathState) {
744
+ const empty = document.createElement('div');
745
+ empty.className = 'empty';
746
+ empty.textContent = 'Start the local control server to edit paths.';
747
+ allowed.append(empty);
748
+ excluded.append(empty.cloneNode(true));
749
+ el('paths-status').textContent = 'Local control server not connected.';
750
+ return;
751
+ }
752
+ if (el('paths-status').textContent === 'Loading paths') el('paths-status').textContent = 'Loaded from the local ledger.';
753
+ for (const [index, entry] of pathState.allowed_roots.entries()) {
754
+ const row = document.createElement('div');
755
+ row.className = 'path-row';
756
+ const path = document.createElement('span');
757
+ path.className = 'path';
758
+ path.textContent = entry.path;
759
+ const access = document.createElement('span');
760
+ access.className = 'tag';
761
+ access.textContent = entry.access.includes('write') ? 'read + write' : 'read';
762
+ const remove = document.createElement('button');
763
+ remove.type = 'button';
764
+ remove.className = 'path-remove';
765
+ remove.textContent = 'Remove';
766
+ remove.addEventListener('click', () => { pathState.allowed_roots.splice(index, 1); renderPaths(); });
767
+ row.append(path, access, remove);
768
+ allowed.append(row);
769
+ }
770
+ for (const [index, exclusion] of pathState.exclusions.entries()) {
771
+ const row = document.createElement('div');
772
+ row.className = 'path-row';
773
+ const path = document.createElement('span');
774
+ path.className = 'path';
775
+ path.textContent = exclusion;
776
+ const remove = document.createElement('button');
777
+ remove.type = 'button';
778
+ remove.className = 'path-remove';
779
+ remove.textContent = 'Remove';
780
+ remove.addEventListener('click', () => { pathState.exclusions.splice(index, 1); renderPaths(); });
781
+ row.append(path, remove);
782
+ excluded.append(row);
783
+ }
784
+ if (!pathState.allowed_roots.length) {
785
+ const empty = document.createElement('div');
786
+ empty.className = 'empty';
787
+ empty.textContent = 'No allowed roots. Every path-scoped tool call is refused.';
788
+ allowed.append(empty);
789
+ }
790
+ if (!pathState.exclusions.length) {
791
+ const empty = document.createElement('div');
792
+ empty.className = 'empty';
793
+ empty.textContent = 'No exclusions.';
794
+ excluded.append(empty);
795
+ }
796
+ }
797
+
798
+ function addRoot() {
799
+ const value = el('root-input').value.trim();
800
+ if (!value || !pathState) return;
801
+ const access = el('root-access').value.split(',');
802
+ pathState.allowed_roots.push({ path: value, access });
803
+ el('root-input').value = '';
804
+ renderPaths();
805
+ }
806
+
807
+ function addExclusion() {
808
+ const value = el('exclusion-input').value.trim();
809
+ if (!value || !pathState) return;
810
+ pathState.exclusions.push(value);
811
+ el('exclusion-input').value = '';
812
+ renderPaths();
813
+ }
814
+
815
+ async function savePaths() {
816
+ const status = el('paths-status');
817
+ status.textContent = 'Saving...';
818
+ if (!pathState || !controlApi) {
819
+ status.textContent = 'Local control server is not running; download a draft instead.';
820
+ return;
821
+ }
822
+ try {
823
+ const response = await fetch('/api/paths', {
824
+ method: 'POST',
825
+ headers: { ...AUTH, 'content-type': 'application/json' },
826
+ body: JSON.stringify(pathState)
827
+ });
828
+ const body = await response.json();
829
+ if (!response.ok) throw new Error(body.error || 'save failed');
830
+ pathState = body.paths;
831
+ renderPaths();
832
+ status.textContent = 'Saved to the local ledger. The next hook call reads this file.';
833
+ } catch (error) {
834
+ status.textContent = 'Could not save: ' + error.message;
835
+ }
836
+ }
837
+
838
+ async function refreshStatus() {
839
+ if (!controlApi) return;
840
+ try {
841
+ const response = await fetch('/api/status', { cache: 'no-store', headers: AUTH });
842
+ if (!response.ok) return;
843
+ const status = await response.json();
844
+ agentData = status;
845
+ if (status.paths) pathState = status.paths;
846
+ renderAgentStatus();
847
+ renderPaths();
848
+ } catch {
849
+ // The local server can be restarted without breaking the page.
850
+ }
851
+ }
852
+
853
+ async function setAgentMode(mode) {
854
+ selectedAgentMode = mode;
855
+ renderAgentPanel();
856
+ const status = el('agent-setup-status');
857
+ if (!controlApi) {
858
+ status.textContent = 'Start the local control server to change modes.';
859
+ return;
860
+ }
861
+ if (!agentData.connected) {
862
+ status.textContent = 'Press Connect now to apply ' + mode + '.';
863
+ return;
864
+ }
865
+ status.textContent = 'Applying ' + mode + '...';
866
+ try {
867
+ const response = await fetch('/api/mode', {
868
+ method: 'POST',
869
+ headers: { ...AUTH, 'content-type': 'application/json' },
870
+ body: JSON.stringify({ mode })
871
+ });
872
+ const body = await response.json();
873
+ if (!response.ok) throw new Error(body.error || 'mode change failed');
874
+ agentData = body.status || agentData;
875
+ selectedAgentMode = null;
876
+ renderAgentStatus();
877
+ status.textContent = 'Mode updated to ' + mode + '.';
878
+ } catch (error) {
879
+ status.textContent = 'Could not change mode: ' + error.message;
880
+ }
881
+ }
882
+
883
+ async function connectNow() {
884
+ const status = el('agent-setup-status');
885
+ const mode = selectedAgentMode || (agentData.mode === 'off' ? 'observe' : agentData.mode) || 'observe';
886
+ status.textContent = 'Connecting...';
887
+ if (!controlApi) {
888
+ status.textContent = 'Start the local control server to connect.';
889
+ return;
890
+ }
891
+ try {
892
+ const response = await fetch('/api/connect', {
893
+ method: 'POST',
894
+ headers: { ...AUTH, 'content-type': 'application/json' },
895
+ body: JSON.stringify({ mode })
896
+ });
897
+ const body = await response.json();
898
+ if (!response.ok) throw new Error(body.error || 'connect failed');
899
+ agentData = body.status || agentData;
900
+ selectedAgentMode = null;
901
+ agentData = { ...agentData, paths: body.status?.paths || agentData.paths };
902
+ controlApi = true;
903
+ renderAgentStatus();
904
+ if (body.status?.paths) pathState = body.status.paths;
905
+ renderPaths();
906
+ status.textContent = agentData.connected ? 'Agent connected.' : 'Hook is ' + mode + '.';
907
+ } catch (error) {
908
+ status.textContent = 'Connect failed: ' + error.message;
909
+ }
910
+ }
911
+
912
+ function renderDayStrip() {
913
+ const max = Math.max(...days.map((day) => summarize(day).calls), 1);
914
+ el('week').replaceChildren();
915
+ for (const day of days) {
916
+ const summary = summarize(day);
917
+ const button = document.createElement('button');
918
+ button.type = 'button';
919
+ button.className = 'day' + (day === selectedDay ? ' active' : '');
920
+ button.title = dayLabel(day) + ': ' + fmt(summary.calls) + ' calls';
921
+ button.innerHTML = '<span class="bar"><i style="height:' + Math.max(3, Math.round((summary.calls / max) * 100)) + '%"></i><b style="height:' + Math.round((summary.blocked / Math.max(summary.calls, 1)) * 100) + '%"></b></span><span class="daydate">' + esc(dayDate(day).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' })) + '</span><span class="daymeta">' + compact(summary.calls) + '</span>';
922
+ button.addEventListener('click', () => renderDay(day));
923
+ el('week').append(button);
924
+ }
925
+ }
926
+
927
+ function renderDayHeader(day) {
928
+ const summary = summarize(day);
929
+ const share = summary.calls ? Math.round((summary.blocked / summary.calls) * 100) : 0;
930
+ el('day-label').textContent = dayLabel(day);
931
+ el('day-stats').innerHTML = '<strong>' + fmt(summary.calls) + '</strong> calls<span class="sep">/</span><strong>' + fmt(summary.blocked) + '</strong> blocked<span class="rate">' + share + '%</span>';
932
+ el('day-source').textContent = 'local ledger';
933
+ el('day-coverage').textContent = summary.calls ? '00:00 - 24:00' : 'empty day';
934
+ const index = days.indexOf(day);
935
+ el('prev-day').disabled = index <= 0;
936
+ el('next-day').disabled = index >= days.length - 1;
937
+ }
938
+
939
+ function flowWidth() {
940
+ return BASE_WIDTH * zoom;
941
+ }
942
+
943
+ function renderFlow(day, preserveCenter = null) {
944
+ if (v1) return renderV1Flow(day, preserveCenter);
945
+ const summary = summarize(day);
946
+ const width = flowWidth();
947
+ const inner = width - GUTTER;
948
+ el('flow-track').style.width = width + 'px';
949
+ el('zoom-level').textContent = zoom + 'x';
950
+ currentRows = summary.rows;
951
+ bySeq = new Map(currentRows.map((row) => [String(row[0]), row]));
952
+ el('flow-sub').textContent = fmt(summary.calls) + ' calls / ' + fmt(summary.blocked) + ' blocked / ' + esc(day);
953
+ el('flow-count').textContent = fmt(summary.calls) + ' calls';
954
+ const hours = el('flow-hours');
955
+ hours.replaceChildren();
956
+ for (let hour = 0; hour <= 24; hour += 1) {
957
+ const line = document.createElement('div');
958
+ line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
959
+ line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
960
+ const labelEvery = zoom >= 2 ? 1 : 3;
961
+ if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
962
+ hours.append(line);
963
+ }
964
+ const lanes = el('flow-lanes');
965
+ lanes.replaceChildren();
966
+ const fragments = new Map(CAPS.map((cap) => [cap, document.createDocumentFragment()]));
967
+ bucketRows = new Map();
968
+ markerBySeq = new Map();
969
+ const groups = new Map();
970
+ for (const row of summary.rows) {
971
+ const cap = CAPS.includes(row[3]) ? row[3] : 'read';
972
+ const key = cap + '|' + bucketTime(row[1]);
973
+ if (!groups.has(key)) groups.set(key, []);
974
+ groups.get(key).push(row);
975
+ }
976
+ const buckets = new Map();
977
+ for (const [key, rows] of groups) {
978
+ bucketRows.set(key, rows);
979
+ const cap = key.split('|')[0];
980
+ const index = buckets.get(cap) || 0;
981
+ buckets.set(cap, index + 1);
982
+ const denied = rows.some((row) => row[4] === 'DENY');
983
+ const allowed = rows.some((row) => row[4] === 'ALLOW');
984
+ const tools = [...new Set(rows.map((row) => row[2]))];
985
+ const time = key.split('|')[1];
986
+ const detail = zoom >= 8;
987
+ const marker = document.createElement('button');
988
+ marker.type = 'button';
989
+ marker.className = 'fm ' + (denied && allowed ? 'mixed' : denied ? 'deny' : 'allow') + (detail ? ' detail' : '') + (zoom >= 16 ? ' wide' : '');
990
+ marker.style.left = ((minuteOf(time) / 1440) * 100) + '%';
991
+ marker.style.top = detail ? '4px' : '7px';
992
+ marker.style.width = (detail
993
+ ? (zoom >= 16 ? Math.max(92, Math.min(140, 92 + Math.log2(rows.length) * 10)) : Math.max(44, Math.min(70, 44 + Math.log2(rows.length) * 6)))
994
+ : zoom >= 4 ? Math.max(7, Math.min(20, 7 + Math.log2(rows.length) * 2.5)) : Math.max(4, Math.min(10, 4 + Math.log2(rows.length) * 1.5))) + 'px';
995
+ marker.dataset.key = key;
996
+ marker.dataset.count = String(rows.length);
997
+ marker.dataset.tool = tools.join(',');
998
+ marker.dataset.capability = cap;
999
+ marker.dataset.allow = allowed ? '1' : '0';
1000
+ marker.dataset.deny = denied ? '1' : '0';
1001
+ marker.dataset.search = rows.map((row) => [row[0], row[1], row[2], row[3], row[4], row[5], row[6]].join(' ')).join(' ').toLowerCase();
1002
+ marker.title = time + ' / ' + rows.length + ' calls / ' + tools.join(', ');
1003
+ marker.setAttribute('aria-label', marker.title);
1004
+ if (detail) {
1005
+ const label = tools.length === 1 ? tools[0] : tools[0] + ' +' + (tools.length - 1);
1006
+ marker.innerHTML = '<span class="fm-time">' + esc(time) + '</span><span class="fm-label">' + esc(label) + '</span><span class="fm-count">' + rows.length + '</span>';
1007
+ }
1008
+ for (const row of rows) markerBySeq.set(String(row[0]), marker);
1009
+ fragments.get(cap).append(marker);
1010
+ }
1011
+ for (const cap of CAPS) {
1012
+ const lane = document.createElement('div');
1013
+ lane.className = 'f-lane';
1014
+ lane.dataset.capability = cap;
1015
+ const label = document.createElement('div');
1016
+ label.className = 'f-label';
1017
+ label.textContent = cap.toUpperCase();
1018
+ const track = document.createElement('div');
1019
+ track.className = 'f-track';
1020
+ track.append(fragments.get(cap));
1021
+ lane.append(label, track);
1022
+ lanes.append(lane);
1023
+ }
1024
+ const scroller = el('flow-scroller');
1025
+ if (preserveCenter == null) {
1026
+ const firstMinute = summary.rows.length ? Math.min(...summary.rows.map((row) => minuteOf(row[1]))) : 0;
1027
+ const firstX = GUTTER + (firstMinute / 1440) * inner;
1028
+ scroller.scrollLeft = Math.max(0, firstX - scroller.clientWidth / 2);
1029
+ } else {
1030
+ scroller.scrollLeft = Math.max(0, (preserveCenter / 1440) * width - scroller.clientWidth / 2);
1031
+ }
1032
+ closePopover();
1033
+ applyFlowFilter();
1034
+ renderTokens(day);
1035
+ }
1036
+
1037
+ function renderV1Flow(day, preserveCenter = null) {
1038
+ const summary = summarize(day);
1039
+ const buckets = flowCache.get(day)?.buckets || [];
1040
+ const width = flowWidth();
1041
+ const inner = width - GUTTER;
1042
+ el('flow-track').style.width = width + 'px';
1043
+ el('zoom-level').textContent = zoom + 'x';
1044
+ currentCalls = summary.calls;
1045
+ currentRows = [];
1046
+ bySeq = new Map();
1047
+ bucketRows = new Map();
1048
+ markerBySeq = new Map();
1049
+ el('flow-sub').textContent = fmt(summary.calls) + ' calls / ' + fmt(summary.blocked) + ' blocked / ' + esc(day);
1050
+ el('flow-count').textContent = fmt(summary.calls) + ' calls';
1051
+ const hours = el('flow-hours');
1052
+ hours.replaceChildren();
1053
+ for (let hour = 0; hour <= 24; hour += 1) {
1054
+ const line = document.createElement('div');
1055
+ line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
1056
+ line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
1057
+ const labelEvery = zoom >= 2 ? 1 : 3;
1058
+ if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
1059
+ hours.append(line);
1060
+ }
1061
+ const lanes = el('flow-lanes');
1062
+ lanes.replaceChildren();
1063
+ const fragments = new Map(CAPS.map((cap) => [cap, document.createDocumentFragment()]));
1064
+ for (const bucket of buckets) {
1065
+ const cap = CAPS.includes(bucket.capability) ? bucket.capability : 'read';
1066
+ const key = bucket.minute + '|' + cap;
1067
+ const tools = bucket.tools || [];
1068
+ const denied = bucket.blocked > 0;
1069
+ const allowed = bucket.allowed > 0;
1070
+ const time = String(Math.floor(bucket.minute / 60)).padStart(2, '0') + ':' + String(bucket.minute % 60).padStart(2, '0');
1071
+ const detail = zoom >= 8;
1072
+ const marker = document.createElement('button');
1073
+ marker.type = 'button';
1074
+ marker.className = 'fm ' + (denied && allowed ? 'mixed' : denied ? 'deny' : 'allow') + (detail ? ' detail' : '') + (zoom >= 16 ? ' wide' : '');
1075
+ marker.style.left = ((bucket.minute / 1440) * 100) + '%';
1076
+ marker.style.top = detail ? '4px' : '7px';
1077
+ marker.style.width = (detail
1078
+ ? (zoom >= 16 ? Math.max(92, Math.min(140, 92 + Math.log2(bucket.total) * 10)) : Math.max(44, Math.min(70, 44 + Math.log2(bucket.total) * 6)))
1079
+ : zoom >= 4 ? Math.max(7, Math.min(20, 7 + Math.log2(bucket.total) * 2.5)) : Math.max(4, Math.min(10, 4 + Math.log2(bucket.total) * 1.5))) + 'px';
1080
+ marker.dataset.key = key;
1081
+ marker.dataset.minute = String(bucket.minute);
1082
+ marker.dataset.capability = cap;
1083
+ marker.dataset.count = String(bucket.total);
1084
+ marker.dataset.allow = allowed ? '1' : '0';
1085
+ marker.dataset.deny = denied ? '1' : '0';
1086
+ marker.dataset.search = [time, cap, ...tools, ...(bucket.tasks || []), ...(bucket.codes || [])].join(' ').toLowerCase();
1087
+ marker.title = time + ' / ' + bucket.total + ' calls / ' + tools.join(', ');
1088
+ marker.setAttribute('aria-label', marker.title);
1089
+ if (detail) {
1090
+ const label = tools.length === 1 ? tools[0] : tools[0] + ' +' + Math.max(0, tools.length - 1);
1091
+ marker.innerHTML = '<span class="fm-time">' + esc(time) + '</span><span class="fm-label">' + esc(label) + '</span><span class="fm-count">' + bucket.total + '</span>';
1092
+ }
1093
+ fragments.get(cap).append(marker);
1094
+ }
1095
+ for (const cap of CAPS) {
1096
+ const lane = document.createElement('div');
1097
+ lane.className = 'f-lane';
1098
+ lane.dataset.capability = cap;
1099
+ const label = document.createElement('div');
1100
+ label.className = 'f-label';
1101
+ label.textContent = cap.toUpperCase();
1102
+ const track = document.createElement('div');
1103
+ track.className = 'f-track';
1104
+ track.append(fragments.get(cap));
1105
+ lane.append(label, track);
1106
+ lanes.append(lane);
1107
+ }
1108
+ const scroller = el('flow-scroller');
1109
+ if (preserveCenter == null) {
1110
+ const firstMinute = buckets.length ? Math.min(...buckets.map((bucket) => bucket.minute)) : 0;
1111
+ scroller.scrollLeft = Math.max(0, (GUTTER + (firstMinute / 1440) * inner) - scroller.clientWidth / 2);
1112
+ } else {
1113
+ scroller.scrollLeft = Math.max(0, (preserveCenter / 1440) * width - scroller.clientWidth / 2);
1114
+ }
1115
+ closePopover();
1116
+ applyFlowFilter();
1117
+ renderTokens(day);
1118
+ }
1119
+
1120
+ function applyFlowFilter() {
1121
+ const query = el('flow-search').value.trim().toLowerCase();
1122
+ const capability = el('flow-cap').value;
1123
+ const decision = el('flow-decision').value;
1124
+ let visible = 0;
1125
+ for (const marker of document.querySelectorAll('.fm')) {
1126
+ const text = marker.dataset.search || '';
1127
+ const show = (!capability || marker.dataset.capability === capability) &&
1128
+ (!decision || (decision === 'DENY' ? marker.dataset.deny === '1' : marker.dataset.allow === '1')) &&
1129
+ (!query || text.includes(query));
1130
+ marker.hidden = !show;
1131
+ if (show) visible += Number(marker.dataset.count || 0);
1132
+ }
1133
+ el('flow-count').textContent = fmt(visible) + ' / ' + fmt(v1 ? currentCalls : currentRows.length) + ' calls';
1134
+ if (selectedMarker && selectedMarker.hidden) closePopover();
1135
+ }
1136
+
1137
+ async function loadUsage(day) {
1138
+ if (!controlApi) return;
1139
+ usageData = null;
1140
+ try {
1141
+ const response = await fetch('/api/v1/usage?day=' + encodeURIComponent(day) + '&tz=' + encodeURIComponent(TZ), {
1142
+ cache: 'no-store', headers: AUTH
1143
+ });
1144
+ if (!response.ok) return;
1145
+ const next = await response.json();
1146
+ if (selectedDay !== day) return;
1147
+ if (selectedDay === day) {
1148
+ usageData = next;
1149
+ renderTokens(day);
1150
+ }
1151
+ } catch {
1152
+ // The exact blocked count remains available without usage.
1153
+ }
1154
+ }
1155
+
1156
+ function renderTokens(day) {
1157
+ const impact = tokenImpact(day);
1158
+ const measured = controlApi && usageData && usageData.day === day ? usageData : null;
1159
+ const share = impact.calls ? Math.round((impact.blocked / impact.calls) * 100) : 0;
1160
+ el('tok-blocked').textContent = fmt(impact.blocked);
1161
+ el('tok-saved').textContent = measured && measured.totals.measured_calls ? fmt(measured.totals.tokens) : 'not measured';
1162
+ el('tok-share').textContent = share + '%';
1163
+ el('tok-coverage').textContent = measured ? Math.round(measured.coverage * 100) + '%' : '0%';
1164
+ el('tok-tasks').textContent = fmt(impact.tasks);
1165
+ el('tok-tools').textContent = fmt(impact.tools);
1166
+ el('tok-rule').textContent = impact.topRule ? impact.topRule[0] + ' ' + fmt(impact.topRule[1]) : 'n/a';
1167
+ el('tok-covered').textContent = measured ? fmt(measured.totals.measured_calls) : '0';
1168
+ el('tok-note').textContent = measured && measured.totals.measured_calls
1169
+ ? 'Measured tokens come from the agent usage records for ' + fmt(measured.totals.measured_calls) + ' of ' + fmt(measured.totals.calls) + ' calls on this day. Blocked calls are exact; no per-call saving is inferred.'
1170
+ : 'No measured session usage is available for this day. Blocked call counts are still exact.';
1171
+ el('token-label').textContent = 'Measured task tokens';
1172
+ }
1173
+
1174
+ function closePopover() {
1175
+ if (selectedMarker) selectedMarker.classList.remove('selected');
1176
+ selectedMarker = null;
1177
+ selectedSeq = null;
1178
+ const popover = el('flow-popover');
1179
+ if (popover) {
1180
+ popover.hidden = true;
1181
+ popover.replaceChildren();
1182
+ }
1183
+ }
1184
+
1185
+ function positionPopover(marker, popover) {
1186
+ const wrap = document.querySelector('.flow-wrap');
1187
+ const wrapRect = wrap.getBoundingClientRect();
1188
+ const markerRect = marker.getBoundingClientRect();
1189
+ popover.hidden = false;
1190
+ const width = popover.offsetWidth;
1191
+ const height = popover.offsetHeight;
1192
+ let left = markerRect.left - wrapRect.left - width / 2 + markerRect.width / 2;
1193
+ left = Math.max(8, Math.min(wrapRect.width - width - 8, left));
1194
+ let top = markerRect.top - wrapRect.top - height - 8;
1195
+ if (top < 4) top = 4;
1196
+ popover.style.left = left + 'px';
1197
+ popover.style.top = top + 'px';
1198
+ }
1199
+
1200
+ async function openBucket(marker) {
1201
+ let rows = bucketRows.get(marker.dataset.key) || [];
1202
+ if (v1 && !rows.length) {
1203
+ try {
1204
+ const params = new URLSearchParams({ day: selectedDay, tz: TZ, limit: '200' });
1205
+ if (marker.dataset.minute !== undefined) params.set('minute', marker.dataset.minute);
1206
+ if (marker.dataset.capability) params.set('capability', marker.dataset.capability);
1207
+ const response = await fetch('/api/v1/calls?' + params.toString(), { cache: 'no-store', headers: AUTH });
1208
+ const body = await response.json();
1209
+ if (!response.ok) throw new Error(body.error || 'bucket ' + response.status);
1210
+ rows = (body.rows || []).map((row) => {
1211
+ const copy = row.slice();
1212
+ copy[7] = selectedDay;
1213
+ bySeq.set(String(copy[0]), copy);
1214
+ markerBySeq.set(String(copy[0]), marker);
1215
+ return copy;
1216
+ });
1217
+ bucketRows.set(marker.dataset.key, rows);
1218
+ } catch (error) {
1219
+ el('flow-sub').textContent = 'Could not load block: ' + error.message;
1220
+ return;
1221
+ }
1222
+ }
1223
+ if (!rows.length) return;
1224
+ if (rows.length === 1) {
1225
+ openPopover(rows[0][0], marker);
1226
+ return;
1227
+ }
1228
+ closePopover();
1229
+ selectedMarker = marker;
1230
+ marker.classList.add('selected');
1231
+ const denied = rows.filter((row) => row[4] === 'DENY').length;
1232
+ const allowed = rows.length - denied;
1233
+ const tools = [...new Set(rows.map((row) => row[2]))];
1234
+ const blockStates = new Set(rows.map((row) => drafts.get(String(row[0])) || 'unset'));
1235
+ const blockDraft = blockStates.size === 1 ? [...blockStates][0] : 'mixed';
1236
+ const popover = el('flow-popover');
1237
+ const list = rows.map((row, index) => '<button class="fp-row' + (index >= 6 ? ' fp-extra' : '') + '" type="button" data-seq="' + esc(row[0]) + '"' + (index >= 6 ? ' hidden' : '') + '><span>#' + esc(row[0]) + '</span><span>' + esc(row[2]) + '</span><span class="' + (row[4] === 'ALLOW' ? 'allow' : 'deny') + '">' + esc(row[4]) + '</span></button>').join('');
1238
+ popover.innerHTML = '<div class="fp-head"><span>' + esc(rows[0][1]) + ' / ' + rows.length + ' calls</span><button class="fp-close" type="button" aria-label="Close">&times;</button></div>' +
1239
+ '<div class="fp-title"><strong>' + esc(tools.length === 1 ? tools[0] : tools.slice(0, 3).join(', ')) + '</strong><span class="fp-code">' + denied + ' blocked / ' + allowed + ' allowed</span></div>' +
1240
+ '<div class="fp-grid"><div><span class="fp-k">window</span><span class="fp-v">' + esc(rows[0][1]) + '</span></div><div><span class="fp-k">calls</span><span class="fp-v">' + rows.length + '</span></div><div><span class="fp-k">tools</span><span class="fp-v">' + esc(tools.join(', ')) + '</span></div><div><span class="fp-k">code</span><span class="fp-v">' + esc(rows[0][5] || 'n/a') + '</span></div></div>' +
1241
+ '<div class="fp-actions fp-block-actions"><button type="button" data-block-state="allow">Allow all</button><button type="button" data-block-state="ask">Ask all</button><button type="button" data-block-state="deny">Deny all</button><span class="fp-draft" id="fp-block-draft">draft: ' + esc(blockDraft) + '</span></div>' +
1242
+ '<div class="fp-list">' + list + '</div>' + (rows.length > 6 ? '<button class="fp-more" type="button" id="fp-more">' + (rows.length - 6) + ' more - click for more</button>' : '') +
1243
+ '<p class="fp-note">Apply to the whole block, or pick a call for its details.</p>';
1244
+ popover.querySelector('.fp-close').addEventListener('click', (event) => { event.stopPropagation(); closePopover(); });
1245
+ for (const button of popover.querySelectorAll('.fp-row')) {
1246
+ button.addEventListener('click', (event) => { event.stopPropagation(); openPopover(button.dataset.seq, marker); });
1247
+ }
1248
+ const more = popover.querySelector('#fp-more');
1249
+ if (more) more.addEventListener('click', (event) => {
1250
+ event.stopPropagation();
1251
+ for (const row of popover.querySelectorAll('.fp-extra')) row.hidden = false;
1252
+ popover.querySelector('.fp-list').classList.add('expanded');
1253
+ more.remove();
1254
+ positionPopover(marker, popover);
1255
+ });
1256
+ for (const button of popover.querySelectorAll('[data-block-state]')) {
1257
+ button.classList.toggle('on', button.dataset.blockState === blockDraft);
1258
+ button.addEventListener('click', (event) => { event.stopPropagation(); setBlockDraft(marker, button.dataset.blockState); });
1259
+ }
1260
+ positionPopover(marker, popover);
1261
+ }
1262
+
1263
+ function openPopover(seq, marker = null) {
1264
+ const row = bySeq.get(String(seq));
1265
+ if (!row) return;
1266
+ const groupMarker = marker || markerBySeq.get(String(seq));
1267
+ closePopover();
1268
+ selectedSeq = String(seq);
1269
+ selectedMarker = groupMarker || null;
1270
+ if (selectedMarker) selectedMarker.classList.add('selected');
1271
+ if (selectedMarker) selectedMarker.scrollIntoView({ block: 'nearest', inline: 'center' });
1272
+ const draft = drafts.get(String(seq)) || 'unset';
1273
+ const blockRows = selectedMarker ? (bucketRows.get(selectedMarker.dataset.key) || [row]) : [row];
1274
+ const popover = el('flow-popover');
1275
+ popover.innerHTML = '<div class="fp-head"><span>#' + esc(row[0]) + ' / ' + esc(row[1]) + '</span><button class="fp-close" type="button" aria-label="Close">&times;</button></div>' +
1276
+ '<div class="fp-title"><strong>' + esc(row[2]) + '</strong><span class="fp-code' + (row[4] === 'ALLOW' ? ' allow' : '') + '">' + esc(row[4]) + ' / ' + esc(row[5] || row[4]) + '</span></div>' +
1277
+ '<div class="fp-grid">' +
1278
+ '<div><span class="fp-k">capability</span><span class="fp-v">' + esc(row[3]) + '</span></div>' +
1279
+ '<div><span class="fp-k">draft</span><span class="fp-v" id="fp-draft">' + esc(draft) + '</span></div>' +
1280
+ '<div><span class="fp-k">task</span><span class="fp-v" title="' + esc(row[6]) + '">' + esc(String(row[6]).slice(0, 18)) + '</span></div>' +
1281
+ '<div><span class="fp-k">code</span><span class="fp-v">' + esc(row[5] || 'n/a') + '</span></div>' +
1282
+ '<div><span class="fp-k">block</span><span class="fp-v">' + blockRows.length + ' calls</span></div>' +
1283
+ '<div><span class="fp-k">path</span><span class="fp-v">not recorded</span></div>' +
1284
+ '</div>' +
1285
+ '<div class="fp-actions"><button type="button" data-state="allow">Allow</button><button type="button" data-state="ask">Ask</button><button type="button" data-state="deny">Deny</button><span class="fp-draft">draft</span></div>' +
1286
+ (blockRows.length > 1 ? '<div class="fp-scope"><label><input type="checkbox" id="fp-all"> apply to all ' + blockRows.length + ' calls in this block</label></div>' : '') +
1287
+ '<p class="fp-note">Draft only. The policy file stays local; export produces the diff.</p>';
1288
+ const buttons = popover.querySelectorAll('.fp-actions button');
1289
+ for (const button of buttons) {
1290
+ button.classList.toggle('on', button.dataset.state === draft);
1291
+ button.addEventListener('click', (event) => { event.stopPropagation(); setDraft(seq, button.dataset.state); });
1292
+ }
1293
+ popover.querySelector('.fp-close').addEventListener('click', (event) => { event.stopPropagation(); closePopover(); });
1294
+ if (selectedMarker) positionPopover(selectedMarker, popover);
1295
+ }
1296
+
1297
+ function updateMarkerDraft(marker) {
1298
+ if (!marker) return;
1299
+ marker.classList.remove('draft-allow', 'draft-deny', 'draft-ask');
1300
+ const drafted = (bucketRows.get(marker.dataset.key) || []).map((row) => drafts.get(String(row[0]))).filter(Boolean);
1301
+ if (drafted.length) marker.classList.add('draft-' + drafted[drafted.length - 1]);
1302
+ marker.classList.toggle('has-draft', drafted.length > 0);
1303
+ }
1304
+
1305
+ function setBlockDraft(marker, state) {
1306
+ const rows = bucketRows.get(marker.dataset.key) || [];
1307
+ if (!rows.length) return;
1308
+ const allOn = rows.every((row) => drafts.get(String(row[0])) === state);
1309
+ for (const row of rows) {
1310
+ const key = String(row[0]);
1311
+ if (allOn) drafts.delete(key); else drafts.set(key, state);
1312
+ }
1313
+ updateMarkerDraft(marker);
1314
+ const popover = el('flow-popover');
1315
+ if (popover && !popover.hidden) {
1316
+ const draft = popover.querySelector('#fp-block-draft');
1317
+ if (draft) draft.textContent = 'draft: ' + (allOn ? 'unset' : state);
1318
+ for (const button of popover.querySelectorAll('[data-block-state]')) button.classList.toggle('on', !allOn && button.dataset.blockState === state);
1319
+ }
1320
+ }
1321
+
1322
+ function setDraft(seq, state) {
1323
+ const key = String(seq);
1324
+ const current = drafts.get(key) || 'unset';
1325
+ const next = current === state ? 'unset' : state;
1326
+ const scopeAll = document.getElementById('fp-all') && document.getElementById('fp-all').checked;
1327
+ const rows = selectedMarker ? (bucketRows.get(selectedMarker.dataset.key) || [bySeq.get(key)]) : [bySeq.get(key)];
1328
+ const targets = scopeAll ? rows : [bySeq.get(key)];
1329
+ for (const row of targets) {
1330
+ if (!row) continue;
1331
+ if (next === 'unset') drafts.delete(String(row[0])); else drafts.set(String(row[0]), next);
1332
+ }
1333
+ const marker = selectedMarker || markerBySeq.get(key);
1334
+ if (marker) {
1335
+ marker.classList.remove('draft-allow', 'draft-deny', 'draft-ask');
1336
+ const drafted = (bucketRows.get(marker.dataset.key) || []).map((row) => drafts.get(String(row[0]))).filter(Boolean);
1337
+ if (drafted.length) marker.classList.add('draft-' + drafted[drafted.length - 1]);
1338
+ marker.classList.toggle('has-draft', drafted.length > 0);
1339
+ }
1340
+ const popover = el('flow-popover');
1341
+ if (popover && !popover.hidden) {
1342
+ const buttons = popover.querySelectorAll('.fp-actions button');
1343
+ for (const button of buttons) button.classList.toggle('on', button.dataset.state === next);
1344
+ const draft = popover.querySelector('#fp-draft');
1345
+ if (draft) draft.textContent = next + (scopeAll && rows.length > 1 ? ' (all)' : '');
1346
+ }
1347
+ }
1348
+
1349
+ async function loadDay(day) {
1350
+ if (!v1) return;
1351
+ const [summaryResponse, flowResponse] = await Promise.all([
1352
+ fetch('/api/v1/summary?day=' + encodeURIComponent(day) + '&tz=' + encodeURIComponent(TZ), { cache: 'no-store', headers: AUTH }),
1353
+ fetch('/api/v1/flow?day=' + encodeURIComponent(day) + '&tz=' + encodeURIComponent(TZ), { cache: 'no-store', headers: AUTH })
1354
+ ]);
1355
+ if (!summaryResponse.ok) throw new Error('summary ' + summaryResponse.status);
1356
+ if (!flowResponse.ok) throw new Error('flow ' + flowResponse.status);
1357
+ const summaryValue = await summaryResponse.json();
1358
+ summaryCache.set(day, summaryValue);
1359
+ dayStats.set(day, { day, calls: summaryValue.calls, blocked: summaryValue.blocked });
1360
+ flowCache.set(day, await flowResponse.json());
1361
+ calls[day] = calls[day] || [];
1362
+ initialHead = Math.max(initialHead, Number(summaryValue.head || 0));
1363
+ }
1364
+
1365
+ async function renderDay(day) {
1366
+ if (!calls[day] && !v1) return;
1367
+ const request = ++dayRequest;
1368
+ selectedDay = day;
1369
+ callsDay = day;
1370
+ callsPage = 0;
1371
+ closePopover();
1372
+ if (v1) {
1373
+ try {
1374
+ if (!summaryCache.has(day) || !flowCache.has(day)) await loadDay(day);
1375
+ if (request !== dayRequest) return;
1376
+ } catch (error) {
1377
+ el('flow-sub').textContent = 'Could not load day: ' + error.message;
1378
+ return;
1379
+ }
1380
+ }
1381
+ renderDayStrip();
1382
+ renderDayHeader(day);
1383
+ renderFlow(day);
1384
+ el('calls-day').value = callsDay;
1385
+ if (v1) await loadCalls(true);
1386
+ else renderCalls();
1387
+ loadUsage(day);
1388
+ }
1389
+
1390
+ function setZoom(next) {
1391
+ const scroller = el('flow-scroller');
1392
+ const currentWidth = scroller.scrollWidth || flowWidth();
1393
+ const centerMinute = currentWidth ? ((scroller.scrollLeft + scroller.clientWidth / 2) / currentWidth) * 1440 : 0;
1394
+ zoom = Math.max(ZOOM_LEVELS[0], Math.min(ZOOM_LEVELS[ZOOM_LEVELS.length - 1], next));
1395
+ el('zoom-out').disabled = zoom <= ZOOM_LEVELS[0];
1396
+ el('zoom-in').disabled = zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
1397
+ renderFlow(selectedDay, centerMinute);
1398
+ }
1399
+
1400
+ function exportDraft() {
1401
+ const output = {
1402
+ generated_at: new Date().toISOString(),
1403
+ day: selectedDay,
1404
+ drafts: [...drafts.entries()].map(([seq, decision]) => {
1405
+ const row = bySeq.get(seq);
1406
+ return { seq: Number(seq), time: row ? row[1] : null, tool: row ? row[2] : null, decision };
1407
+ })
1408
+ };
1409
+ const blob = new Blob([JSON.stringify(output, null, 2) + '\n'], { type: 'application/json' });
1410
+ const url = URL.createObjectURL(blob);
1411
+ const link = document.createElement('a');
1412
+ link.href = url;
1413
+ link.download = 'bounds-draft-' + selectedDay + '.json';
1414
+ document.body.append(link);
1415
+ link.click();
1416
+ link.remove();
1417
+ URL.revokeObjectURL(url);
1418
+ }
1419
+
1420
+ function lifetimeSummary() {
1421
+ const counts = new Map();
1422
+ let total = 0;
1423
+ if (v1) {
1424
+ for (const stats of dayStats.values()) total += Number(stats.calls || 0);
1425
+ for (const row of allRows) counts.set(row[2], (counts.get(row[2]) || 0) + 1);
1426
+ return { total, tools: [...counts.entries()].sort((a, b) => b[1] - a[1]) };
1427
+ }
1428
+ for (const day of days) for (const row of calls[day] || []) {
1429
+ total += 1;
1430
+ counts.set(row[2], (counts.get(row[2]) || 0) + 1);
1431
+ }
1432
+ return { total, tools: [...counts.entries()].sort((a, b) => b[1] - a[1]) };
1433
+ }
1434
+
1435
+ function renderLifetime() {
1436
+ const { total, tools } = lifetimeSummary();
1437
+ el('ledger-total').textContent = fmt(total);
1438
+ const target = el('calls-lifetime');
1439
+ target.replaceChildren();
1440
+ const label = document.createElement('span');
1441
+ label.textContent = 'Recent ' + fmt(total) + ' calls';
1442
+ target.append(label);
1443
+ for (const [tool, count] of tools.slice(0, 6)) {
1444
+ const span = document.createElement('span');
1445
+ span.textContent = tool + ' ' + fmt(count);
1446
+ target.append(span);
1447
+ }
1448
+ }
1449
+
1450
+ function filteredCalls() {
1451
+ if (v1) return allRows;
1452
+ const query = el('calls-search').value.trim().toLowerCase();
1453
+ const source = callsDay === '*'
1454
+ ? days.flatMap((day) => (calls[day] || []).map((row) => [...row, day]))
1455
+ : (calls[callsDay] || []).map((row) => [...row, callsDay]);
1456
+ return source.filter((row) => {
1457
+ if (callsHour && row[1].slice(0, 2) !== callsHour) return false;
1458
+ if (el('calls-tool').value && row[2] !== el('calls-tool').value) return false;
1459
+ if (el('calls-capability').value && row[3] !== el('calls-capability').value) return false;
1460
+ if (el('calls-decision').value && row[4] !== el('calls-decision').value) return false;
1461
+ if (query && ![String(row[0]), row[1], row[2], row[3], row[4], row[5], row[6], row[7]].join(' ').toLowerCase().includes(query)) return false;
1462
+ return true;
1463
+ });
1464
+ }
1465
+
1466
+ function callFilterKey() {
1467
+ return [callsDay, callsHour, el('calls-tool').value, el('calls-capability').value, el('calls-decision').value, el('calls-search').value.trim()].join('|');
1468
+ }
1469
+
1470
+ async function loadCalls(reset = true, direction = 'next') {
1471
+ const request = ++callsRequest;
1472
+ const key = callFilterKey();
1473
+ if (reset) {
1474
+ allRows = [];
1475
+ allCursors = [];
1476
+ allCursor = '';
1477
+ allKey = key;
1478
+ }
1479
+ if (!reset && key !== allKey) {
1480
+ return loadCalls(true);
1481
+ }
1482
+ if (!reset && direction === 'prev') {
1483
+ if (!allCursors.length) return;
1484
+ allCursor = allCursors.pop();
1485
+ }
1486
+ const params = new URLSearchParams({ tz: TZ, limit: '100' });
1487
+ if (callsDay !== '*') params.set('day', callsDay);
1488
+ if (callsHour) params.set('hour', callsHour);
1489
+ if (el('calls-tool').value) params.set('tool', el('calls-tool').value);
1490
+ if (el('calls-capability').value) params.set('capability', el('calls-capability').value);
1491
+ if (el('calls-decision').value) params.set('decision', el('calls-decision').value);
1492
+ if (el('calls-search').value.trim()) params.set('search', el('calls-search').value.trim());
1493
+ if (allCursor) params.set('cursor', allCursor);
1494
+ try {
1495
+ const response = await fetch('/api/v1/calls?' + params.toString(), { cache: 'no-store', headers: AUTH });
1496
+ const body = await response.json();
1497
+ if (!response.ok) throw new Error(body.error || 'calls ' + response.status);
1498
+ if (request !== callsRequest) return;
1499
+ const rows = (body.rows || []).map((row) => {
1500
+ const copy = row.slice();
1501
+ copy[7] = row[7] || (callsDay === '*' ? (backfillDay.get(String(row[0])) || '') : callsDay);
1502
+ bySeq.set(String(copy[0]), copy);
1503
+ return copy;
1504
+ });
1505
+ allRows = reset || direction === 'prev' ? rows : allRows.concat(rows);
1506
+ if (!reset && direction !== 'prev') allCursors.push(allCursor);
1507
+ allCursor = body.next_cursor || allCursor;
1508
+ allHasMore = Boolean(body.has_more);
1509
+ for (const row of allRows) {
1510
+ const tool = el('calls-tool');
1511
+ if (![...tool.options].some((option) => option.value === row[2])) {
1512
+ const option = document.createElement('option'); option.value = row[2]; option.textContent = row[2]; tool.append(option);
1513
+ }
1514
+ const cap = el('calls-capability');
1515
+ if (![...cap.options].some((option) => option.value === row[3])) {
1516
+ const option = document.createElement('option'); option.value = row[3]; option.textContent = row[3]; cap.append(option);
1517
+ }
1518
+ }
1519
+ renderCallRows();
1520
+ } catch (error) {
1521
+ el('calls-count').textContent = 'Could not load calls: ' + error.message;
1522
+ }
1523
+ }
1524
+
1525
+ const backfillDay = new Map();
1526
+
1527
+ function renderCallRows() {
1528
+ const body = el('calls-body');
1529
+ body.replaceChildren();
1530
+ for (const row of allRows) {
1531
+ const tr = document.createElement('tr');
1532
+ tr.dataset.seq = String(row[0]);
1533
+ tr.dataset.day = row[7] || '';
1534
+ tr.innerHTML = '<td>' + esc((row[7] || '').slice(5)) + '</td><td>' + esc(row[1]) + '</td><td>' + esc(row[2]) + '</td><td>' + esc(row[3]) + '</td><td class="' + (row[4] === 'ALLOW' ? 'allow' : 'deny') + '">' + esc(row[4]) + '</td><td>' + esc(row[5] || '-') + '</td><td>' + esc(String(row[6]).slice(0, 20)) + '</td>';
1535
+ tr.addEventListener('click', async (event) => {
1536
+ event.stopPropagation();
1537
+ if (row[7] && row[7] !== selectedDay) await renderDay(row[7]);
1538
+ openPopover(row[0], markerBySeq.get(String(row[0])));
1539
+ });
1540
+ body.append(tr);
1541
+ }
1542
+ renderLifetime();
1543
+ el('calls-count').textContent = fmt(allRows.length) + ' loaded';
1544
+ el('calls-page').textContent = allRows.length ? '1-' + allRows.length : '0 calls';
1545
+ el('calls-prev').disabled = allCursors.length === 0;
1546
+ el('calls-next').disabled = !allHasMore;
1547
+ }
1548
+
1549
+ function renderCalls() {
1550
+ if (v1) {
1551
+ loadCalls(true).catch((error) => { el('calls-count').textContent = 'Could not load calls: ' + error.message; });
1552
+ return;
1553
+ }
1554
+ const list = filteredCalls();
1555
+ const pages = Math.max(1, Math.ceil(list.length / PAGE));
1556
+ callsPage = Math.min(callsPage, pages - 1);
1557
+ const start = callsPage * PAGE;
1558
+ const body = el('calls-body');
1559
+ body.replaceChildren();
1560
+ for (const row of list.slice(start, start + PAGE)) {
1561
+ const tr = document.createElement('tr');
1562
+ tr.dataset.seq = String(row[0]);
1563
+ tr.dataset.day = row[7];
1564
+ tr.innerHTML = '<td>' + esc(row[7].slice(5)) + '</td><td>' + esc(row[1]) + '</td><td>' + esc(row[2]) + '</td><td>' + esc(row[3]) + '</td><td class="' + (row[4] === 'ALLOW' ? 'allow' : 'deny') + '">' + esc(row[4]) + '</td><td>' + esc(row[5] || '-') + '</td><td>' + esc(row[6].slice(0, 20)) + '</td>';
1565
+ tr.addEventListener('click', (event) => {
1566
+ event.stopPropagation();
1567
+ if (row[7] !== selectedDay) renderDay(row[7]);
1568
+ openPopover(row[0], markerBySeq.get(String(row[0])));
1569
+ });
1570
+ body.append(tr);
1571
+ }
1572
+ renderLifetime();
1573
+ el('calls-count').textContent = fmt(list.length) + ' calls';
1574
+ el('calls-page').textContent = list.length ? (start + 1) + '-' + Math.min(start + PAGE, list.length) + ' of ' + fmt(list.length) : '0 calls';
1575
+ el('calls-prev').disabled = callsPage === 0;
1576
+ el('calls-next').disabled = callsPage >= pages - 1;
1577
+ }
1578
+
1579
+ function openCalls() {
1580
+ callsDay = selectedDay;
1581
+ callsHour = '';
1582
+ callsPage = 0;
1583
+ el('calls-search').value = '';
1584
+ el('calls-tool').value = '';
1585
+ el('calls-capability').value = '';
1586
+ el('calls-decision').value = '';
1587
+ el('calls-hour').value = '';
1588
+ el('calls-day').value = callsDay;
1589
+ renderCalls();
1590
+ el('calls-drawer').scrollIntoView({ behavior: 'smooth', block: 'start' });
1591
+ }
1592
+
1593
+ function exportCsv() {
1594
+ const rows = v1 ? allRows : filteredCalls();
1595
+ const q = (value) => '"' + String(value ?? '').replaceAll('"', '""') + '"';
1596
+ const lines = ['day,time,tool,capability,decision,rule,task'];
1597
+ for (const row of rows) lines.push([row[7], row[1], row[2], row[3], row[4], row[5], row[6]].map(q).join(','));
1598
+ const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' });
1599
+ const url = URL.createObjectURL(blob);
1600
+ const link = document.createElement('a');
1601
+ link.href = url;
1602
+ link.download = 'bounds-calls-' + (callsDay === '*' ? 'lifetime' : callsDay) + '.csv';
1603
+ document.body.append(link);
1604
+ link.click();
1605
+ link.remove();
1606
+ URL.revokeObjectURL(url);
1607
+ }
1608
+
1609
+ el('prev-day').addEventListener('click', () => {
1610
+ const i = days.indexOf(selectedDay);
1611
+ if (i > 0) renderDay(days[i - 1]);
1612
+ });
1613
+ el('next-day').addEventListener('click', () => {
1614
+ const i = days.indexOf(selectedDay);
1615
+ if (i < days.length - 1) renderDay(days[i + 1]);
1616
+ });
1617
+ el('flow-start').addEventListener('click', () => { el('flow-scroller').scrollTo({ left: 0, behavior: 'auto' }); });
1618
+ el('flow-end').addEventListener('click', () => { el('flow-scroller').scrollTo({ left: el('flow-scroller').scrollWidth, behavior: 'auto' }); });
1619
+ el('flow-prev').addEventListener('click', () => { el('flow-scroller').scrollBy({ left: -720, behavior: 'smooth' }); });
1620
+ el('flow-next').addEventListener('click', () => { el('flow-scroller').scrollBy({ left: 720, behavior: 'smooth' }); });
1621
+ el('zoom-out').addEventListener('click', () => { const i = ZOOM_LEVELS.indexOf(zoom); if (i > 0) setZoom(ZOOM_LEVELS[i - 1]); });
1622
+ el('zoom-in').addEventListener('click', () => { const i = ZOOM_LEVELS.indexOf(zoom); if (i < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[i + 1]); });
1623
+ el('all-calls').addEventListener('click', openCalls);
1624
+ el('flow-calls').addEventListener('click', openCalls);
1625
+ el('export-draft').addEventListener('click', exportDraft);
1626
+ el('agent-action').addEventListener('click', () => { const panel = el('agent-setup'); panel.hidden = !panel.hidden; if (!panel.hidden) { renderAgentPanel(); refreshStatus(); } });
1627
+ el('agent-setup-close').addEventListener('click', () => { el('agent-setup').hidden = true; });
1628
+ el('agent-connect-now').addEventListener('click', connectNow);
1629
+ el('agent-modes').addEventListener('click', (event) => { const button = event.target.closest('[data-agent-mode]'); if (button && !button.disabled) setAgentMode(button.dataset.agentMode); });
1630
+ el('agent-manage-paths').addEventListener('click', () => { el('paths').scrollIntoView({ behavior: 'smooth', block: 'start' }); });
1631
+ el('root-add').addEventListener('click', () => { if (el('root-input').value.trim()) addRoot(); else el('root-input').focus(); });
1632
+ el('root-commit').addEventListener('click', addRoot);
1633
+ el('exclusion-add').addEventListener('click', () => { if (el('exclusion-input').value.trim()) addExclusion(); else el('exclusion-input').focus(); });
1634
+ el('exclusion-commit').addEventListener('click', addExclusion);
1635
+ el('paths-save').addEventListener('click', savePaths);
1636
+ el('flow-search').addEventListener('input', applyFlowFilter);
1637
+ el('flow-cap').addEventListener('change', applyFlowFilter);
1638
+ el('flow-decision').addEventListener('change', applyFlowFilter);
1639
+ el('flow-track').addEventListener('click', (event) => {
1640
+ const marker = event.target.closest('.fm');
1641
+ if (!marker) return;
1642
+ openBucket(marker);
1643
+ });
1644
+ document.addEventListener('click', (event) => {
1645
+ if (!event.target.closest('#flow-popover') && !event.target.closest('.fm')) closePopover();
1646
+ });
1647
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closePopover(); });
1648
+ el('calls-csv').addEventListener('click', exportCsv);
1649
+ el('calls-day').addEventListener('change', () => { callsDay = el('calls-day').value; callsPage = 0; renderCalls(); });
1650
+ el('calls-search').addEventListener('input', () => { callsPage = 0; renderCalls(); });
1651
+ el('calls-tool').addEventListener('change', () => { callsPage = 0; renderCalls(); });
1652
+ el('calls-capability').addEventListener('change', () => { callsPage = 0; renderCalls(); });
1653
+ el('calls-decision').addEventListener('change', () => { callsPage = 0; renderCalls(); });
1654
+ el('calls-hour').addEventListener('change', () => { callsHour = el('calls-hour').value; callsPage = 0; renderCalls(); });
1655
+ el('calls-prev').addEventListener('click', () => { if (v1) loadCalls(false, 'prev'); else if (callsPage > 0) { callsPage -= 1; renderCalls(); } });
1656
+ el('calls-next').addEventListener('click', () => { if (v1) loadCalls(false, 'next'); else { callsPage += 1; renderCalls(); } });
1657
+
1658
+ for (const day of days) {
1659
+ const option = document.createElement('option');
1660
+ option.value = day;
1661
+ option.textContent = dayDate(day).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' });
1662
+ el('calls-day').append(option);
1663
+ }
1664
+ for (let hour = 0; hour < 24; hour += 1) {
1665
+ const option = document.createElement('option');
1666
+ option.value = String(hour).padStart(2, '0');
1667
+ option.textContent = String(hour).padStart(2, '0') + ':00';
1668
+ el('calls-hour').append(option);
1669
+ }
1670
+ const tools = new Set();
1671
+ const caps = new Set();
1672
+ for (const day of days) for (const row of calls[day] || []) { tools.add(row[2]); caps.add(row[3]); }
1673
+ for (const tool of [...tools].sort()) {
1674
+ const option = document.createElement('option');
1675
+ option.value = tool;
1676
+ option.textContent = tool;
1677
+ el('calls-tool').append(option);
1678
+ }
1679
+ for (const cap of [...caps].sort()) {
1680
+ const option = document.createElement('option');
1681
+ option.value = cap;
1682
+ option.textContent = cap;
1683
+ el('calls-capability').append(option);
1684
+ }
1685
+
1686
+ renderAgentStatus();
1687
+ renderPaths();
1688
+ renderDay(selectedDay);
1689
+ if (controlApi) { setInterval(refreshStatus, 2000); startLive(); }
1690
+ el('zoom-out').disabled = zoom <= ZOOM_LEVELS[0];
1691
+ el('zoom-in').disabled = zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
1692
+ })();
1693
+ </script>
1694
+ </body>
1695
+ </html>