@guenbankhai/tools 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +53 -2
  2. package/README.vi.md +54 -0
  3. package/agentmoni/lib/agents/claude/billing.js +13 -0
  4. package/agentmoni/lib/agents/claude/home.js +4 -0
  5. package/agentmoni/lib/agents/claude/index.js +181 -0
  6. package/agentmoni/lib/agents/claude/sources/oauth-usage.js +74 -0
  7. package/agentmoni/lib/agents/claude/timeline.js +26 -0
  8. package/agentmoni/lib/agents/codex/billing.js +9 -0
  9. package/agentmoni/lib/agents/codex/index.js +280 -0
  10. package/agentmoni/lib/agents/codex/sources/rollout.js +26 -0
  11. package/agentmoni/lib/agents/codex/timeline.js +22 -0
  12. package/agentmoni/lib/agents/index.js +13 -0
  13. package/agentmoni/lib/catalog.js +96 -0
  14. package/agentmoni/lib/catalog.json +493 -0
  15. package/agentmoni/lib/collect.js +11 -0
  16. package/agentmoni/lib/colors.js +35 -0
  17. package/agentmoni/lib/cost.js +87 -0
  18. package/agentmoni/lib/format.js +18 -0
  19. package/agentmoni/lib/graph.js +106 -0
  20. package/agentmoni/lib/history.js +50 -0
  21. package/agentmoni/lib/json-fields.js +58 -0
  22. package/agentmoni/lib/jsonl.js +43 -0
  23. package/agentmoni/lib/launch.js +118 -0
  24. package/agentmoni/lib/limits.js +11 -0
  25. package/agentmoni/lib/links.js +139 -0
  26. package/agentmoni/lib/list.js +71 -0
  27. package/agentmoni/lib/log.js +226 -0
  28. package/agentmoni/lib/monitor.js +58 -0
  29. package/agentmoni/lib/page.html +591 -0
  30. package/agentmoni/lib/present.js +184 -0
  31. package/agentmoni/lib/procs.js +22 -0
  32. package/agentmoni/lib/quota.js +34 -0
  33. package/agentmoni/lib/repokey.js +44 -0
  34. package/agentmoni/lib/share.js +106 -0
  35. package/agentmoni/lib/state.js +62 -0
  36. package/agentmoni/lib/terminal.js +143 -0
  37. package/agentmoni/lib/timeline.js +40 -0
  38. package/agentmoni/lib/web.js +75 -0
  39. package/agentmoni/monitor.js +98 -0
  40. package/package.json +22 -4
@@ -0,0 +1,591 @@
1
+ <!doctype html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Agent monitor</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --bg: #f5f6f8; --panel: #ffffff; --panel2: #eef0f4; --line: #d3d8e0; --text: #1b1f27; --dim: #667085;
11
+ --run: #1e9e4f; --wait: #c98a00; --end: #8a93a3; --bad: #d03a3a; --accent: #3b78c4;
12
+ }
13
+ @media (prefers-color-scheme: dark) {
14
+ :root {
15
+ --bg: #101216; --panel: #191d25; --panel2: #232833; --line: #353c4a; --text: #e2e8f0; --dim: #98a2b3;
16
+ --run: #3ad077; --wait: #f2d54b; --end: #7b8496; --bad: #f26b6b; --accent: #5b9bea;
17
+ }
18
+ }
19
+ * { box-sizing: border-box; }
20
+ body { margin: 0; background: var(--bg); color: var(--text); font: 13px/1.4 system-ui, "Segoe UI", sans-serif; padding: 0 16px; }
21
+ header { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; padding: 12px 0; }
22
+ h1 { font-size: 16px; margin: 0; }
23
+ h2 { font-size: 13px; margin: 0; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); }
24
+ section { margin-bottom: 18px; }
25
+ .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
26
+ .dim { color: var(--dim); }
27
+ .mono { font-family: ui-monospace, Consolas, monospace; }
28
+ button, select { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 5px; padding: 3px 9px; font: inherit; cursor: pointer; }
29
+ button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
30
+ .seg { display: inline-flex; } .seg button { border-radius: 0; } .seg button:first-child { border-radius: 5px 0 0 5px; } .seg button:last-child { border-radius: 0 5px 5px 0; }
31
+ #conn.off { color: var(--bad); }
32
+ .accts { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 10px; }
33
+ .acct { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; }
34
+ .win { display: grid; grid-template-columns: 26px minmax(80px, 1fr) auto; gap: 8px; align-items: center; margin-top: 4px; }
35
+ .win.stale { opacity: .6; }
36
+ .track { height: 9px; border-radius: 5px; background: var(--panel2); overflow: hidden; display: flex; }
37
+ .track.unk { background: repeating-linear-gradient(45deg, var(--panel2) 0 4px, var(--line) 4px 8px); }
38
+ .fill { height: 100%; background: var(--run); } .fill.mid { background: var(--wait); } .fill.low { background: var(--bad); }
39
+ .legend span { display: inline-flex; align-items: center; gap: 4px; margin-right: 10px; }
40
+ .sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
41
+ .c-i { background: var(--i); } .c-cr { background: var(--cr); } .c-cw { background: var(--cw); } .c-o { background: var(--o); }
42
+ #attention .chip { border-color: var(--bad); }
43
+ .chip { display: inline-block; border: 1px solid var(--line); border-radius: 12px; padding: 1px 9px; background: var(--panel); cursor: pointer; font-size: 12px; margin: 2px 4px 2px 0; }
44
+ .scroll { overflow: auto; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; }
45
+ #graphSec { display: flex; flex-direction: column; flex: 1; min-height: 0; margin-bottom: 0; }
46
+ #graph { display: flex; flex: 1 1 0; min-height: 240px; }
47
+ /* Auto margins centre a small tree without hiding the start of an overflowing tree. */
48
+ #canvas { position: relative; flex: none; margin: auto; }
49
+ #canvas svg { position: absolute; left: 0; top: 0; overflow: visible; }
50
+ .edge { fill: none; stroke: var(--end); stroke-width: 2; }
51
+ .edge.inferred { stroke-dasharray: 2 6; stroke-linecap: round; }
52
+ .edge.running { stroke: var(--run); } .edge.waiting { stroke: var(--wait); }
53
+ .hit { fill: none; stroke: transparent; stroke-width: 16; cursor: help; }
54
+ .dot { fill: var(--run); }
55
+ .elabel { position: absolute; transform: translate(-50%, -100%); max-width: 110px; overflow: hidden; text-overflow: ellipsis; line-height: 16px; height: 18px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 0 7px; font-size: 11px; color: var(--dim); cursor: help; white-space: nowrap; }
56
+ .card { position: absolute; background: var(--panel); border: 2px solid var(--line); border-radius: 8px; padding: 6px 9px; overflow: hidden; cursor: pointer; font-size: 12px; line-height: 1.35; }
57
+ .elabel.v { transform: translate(0, -50%); writing-mode: vertical-rl; width: 18px; height: auto; max-width: none; max-height: 80px; padding: 7px 0; overflow: hidden; text-overflow: ellipsis; }
58
+ #canvas.all .card.ended, #canvas.all .edge.ended { opacity: .55; }
59
+ .card.sel { outline: 2px solid var(--accent); } .card.flash { outline: 3px solid var(--bad); }
60
+ .fold { position: absolute; white-space: nowrap; padding: 2px 6px; line-height: 18px; border-color: var(--accent); }
61
+ .card:focus-visible, .fold:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
62
+ .card > div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
63
+ .card .prompt { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; height: 32px; font-weight: 600; }
64
+ .st-running { color: var(--run); } .st-waiting { color: var(--wait); } .st-ended { color: var(--end); }
65
+ .t-warn { color: var(--bad); }
66
+ .t-i { color: var(--i); } .t-cr { color: var(--cr); } .t-cw { color: var(--cw); } .t-o { color: var(--o); }
67
+ .tag { font-size: 10px; border: 1px solid var(--line); border-radius: 4px; padding: 0 4px; margin-right: 4px; color: var(--dim); }
68
+ .tag.warn { border-color: var(--bad); color: var(--bad); }
69
+ .nums { display: grid; grid-template-columns: 22px repeat(5, 1fr); gap: 0 4px; font-size: 11px; }
70
+ .nums b { font-weight: 600; } .nums .h { color: var(--dim); }
71
+ .stack { grid-column: 2 / -1; height: 5px; border-radius: 3px; display: flex; overflow: hidden; background: var(--panel2); margin-bottom: 2px; }
72
+ .stack i { display: block; height: 100%; }
73
+ .ctx { position: relative; height: 7px; background: var(--panel2); border-radius: 4px; margin: 3px 0; }
74
+ .ctx .fill { border-radius: 4px; background: var(--accent); } .ctx .fill.low { background: var(--bad); }
75
+ .ctx .mark { position: absolute; left: 80%; top: -2px; width: 2px; height: 11px; background: var(--wait); }
76
+ .ctx .mark.warn { background: var(--bad); }
77
+ table { border-collapse: collapse; width: 100%; background: var(--panel); border: 1px solid var(--line); }
78
+ th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
79
+ th { color: var(--dim); font-weight: 600; position: sticky; top: 0; background: var(--panel); z-index: 1; box-shadow: 0 1px 0 var(--line); }
80
+ tbody tr { cursor: pointer; } tbody tr:hover { background: var(--panel2); }
81
+ tbody tr.sub { cursor: default; background: var(--panel2); font-weight: 600; } tbody tr.sub:hover { background: var(--panel2); }
82
+ .tbl { overflow: auto; flex: 1; min-height: 0; border: 1px solid var(--line); }
83
+ .tbl table { border: 0; }
84
+ #hist, #histFoot { scrollbar-gutter: stable; } /* equal clientWidth, so the synced scrollLeft reaches the end */
85
+ #hist tbody tr:last-child td { border-bottom: 0; }
86
+ #hist { overflow-x: hidden; } #histFoot { flex: none; overflow-x: auto; overflow-y: hidden; border-top: 0; } /* only the footer shows the horizontal scrollbar */
87
+ #backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 5; }
88
+ #top { min-height: 100vh; padding-bottom: 44px; display: flex; flex-direction: column; overflow: hidden; }
89
+ #top > :not(#graphSec) { flex-shrink: 0; }
90
+ #histSec[hidden] { display: none; }
91
+ #histSec { height: 100vh; padding-bottom: 44px; margin: 0; display: flex; flex-direction: column; }
92
+ #histBar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 4; display: flex; align-items: center; gap: 12px; padding: 6px 16px; background: var(--panel); border-top: 1px solid var(--line); }
93
+ #drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(500px, 100vw); background: var(--panel); border-left: 1px solid var(--line); padding: 12px 16px 16px; overflow: auto; box-shadow: -4px 0 16px rgba(0,0,0,.2); z-index: 6; }
94
+ #drawer dl { display: grid; grid-template-columns: 96px 1fr; gap: 3px 10px; margin: 6px 0; }
95
+ #drawer dt { color: var(--dim); } #drawer dd { margin: 0; word-break: break-all; }
96
+ #drawer h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); margin: 0 0 6px; padding-bottom: 3px; border-bottom: 1px solid var(--line); }
97
+ .dsec { margin: 14px 0; }
98
+ .dtitle { font-size: 15px; font-weight: 600; word-break: break-word; flex: 1; }
99
+ .badge { display: inline-block; border: 1px solid currentColor; border-radius: 10px; padding: 0 8px; font-size: 11px; margin-right: 6px; }
100
+ .tok { display: grid; grid-template-columns: 14px 1fr auto; gap: 3px 8px; align-items: center; }
101
+ #tl { max-height: 46vh; overflow: auto; border: 1px solid var(--line); border-radius: 6px; }
102
+ .tr { display: grid; grid-template-columns: 52px 74px 1fr; gap: 0 8px; padding: 3px 8px; border-bottom: 1px solid var(--line); word-break: break-word; }
103
+ .tr:last-child { border-bottom: 0; }
104
+ .tr .t { color: var(--dim); font-family: ui-monospace, Consolas, monospace; font-size: 11px; }
105
+ .tr .kd { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
106
+ .tr .tk { grid-column: 3; font-size: 11px; font-family: ui-monospace, Consolas, monospace; display: flex; flex-wrap: wrap; gap: 0 10px; }
107
+ .k-user .kd { color: var(--accent); } .k-assistant .kd { color: var(--run); } .k-tool .kd { color: var(--cw); } .k-turn { background: var(--panel2); } .k-turn .kd { color: var(--dim); }
108
+ #tip { position: fixed; z-index: 9; max-width: 420px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px; box-shadow: 0 4px 14px rgba(0,0,0,.25); pointer-events: none; white-space: pre-wrap; word-break: break-all; font-size: 12px; }
109
+ .warnings { color: var(--wait); font-size: 12px; }
110
+ #money { margin-bottom: 8px; } #money summary { cursor: pointer; }
111
+ .money-days { display: flex; flex-wrap: wrap; gap: 2px 14px; margin-top: 4px; }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <div id="top">
116
+ <header>
117
+ <h1>Agent monitor</h1>
118
+ <span id="conn" class="dim">connecting…</span>
119
+ <span class="seg" id="modeSeg"></span>
120
+ <label class="row dim">repo <select id="repoSel"></select></label>
121
+ <span class="legend dim" id="legend"></span>
122
+ </header>
123
+ <section><details id="money"></details><div class="accts" id="quota"></div><div class="dim" id="outside" style="margin-top:6px"></div></section>
124
+ <section id="attSec"><h2>Needs attention</h2><div id="attention"></div></section>
125
+ <section id="graphSec">
126
+ <div class="row" style="margin-bottom:6px"><h2>Active sessions</h2><span class="seg" id="orientSeg"></span><span class="seg" id="scopeSeg"></span><button id="fitBtn" title="Zoom out so every node is visible">Fit</button><button id="zoomOut" title="Zoom out 10%">−</button><button id="zoomBtn" title="Reset to 100%"></button><button id="zoomIn" title="Zoom in 10%">+</button><span id="replayBar" hidden></span></div>
127
+ <div class="scroll" id="graph"><div id="canvas"></div></div>
128
+ </section>
129
+ <div class="warnings" id="warnings"></div>
130
+ </div>
131
+ <section id="histSec" hidden>
132
+ <div class="row" style="margin-bottom:6px"><h2>History</h2>
133
+ <select id="fProv"></select>
134
+ <select id="fKind"><option value="">all kinds</option><option>main</option><option>sub</option><option>standalone</option></select>
135
+ <span id="fParent"></span></div>
136
+ <div class="tbl" id="hist"></div>
137
+ <div class="tbl" id="histFoot"></div>
138
+ </section>
139
+ <div id="histBar"><button id="histBtn"></button><span class="dim" id="histCount"></span></div>
140
+ <div id="backdrop" hidden></div>
141
+ <aside id="drawer" hidden></aside>
142
+ <div id="tip" hidden></div>
143
+ <script>
144
+ /*GRAPH*/
145
+ /*HISTORY*/
146
+ /*COLORS*/
147
+ const F = (() => { const module = {}; /*FORMAT*/ return module.exports; })();
148
+ (() => {
149
+ const NAMES = Colors.NAMES;
150
+ // [label, token field, colour key]. The fields come from the data (each session's share lists the catalog token types);
151
+ // colours are taken in order from the legend palette in colors.js, so a field beyond the palette (e.g. the cacheWrite1h subset) is not drawn.
152
+ let PARTS = [];
153
+ const partLabel = k => k[0].toUpperCase() + (k.match(/[A-Z]/g) ?? []).join('');
154
+ function setPartsOf(v) {
155
+ const sessions = [...v.history], walk = n => { sessions.push(n); n.children.forEach(walk); };
156
+ v.repos.forEach(r => r.roots.forEach(walk));
157
+ const fields = [];
158
+ for (const pick of [s => Object.values(s.share ?? {}).flatMap(x => Object.keys(x ?? {})), s => Object.keys(s.tokens ?? {})]) {
159
+ for (const s of sessions) for (const k of pick(s)) if (k !== 'total' && !fields.includes(k)) fields.push(k);
160
+ if (fields.length) break;
161
+ }
162
+ const keys = Object.keys(Colors.PART);
163
+ PARTS = fields.slice(0, keys.length).map((k, i) => [partLabel(k), k, keys[i]]);
164
+ }
165
+ // All transcript-derived text goes through h(): append(string) makes a text node. Text is never parsed as markup.
166
+ const h = (tag, props, ...kids) => {
167
+ const e = document.createElement(tag);
168
+ for (const [k, v] of Object.entries(props ?? {})) {
169
+ if (k === 'class') e.className = v;
170
+ else if (k === 'on') for (const [ev, fn] of Object.entries(v)) e.addEventListener(ev, fn);
171
+ else if (k === 'style') Object.assign(e.style, v);
172
+ else if (v != null) e.setAttribute(k, String(v));
173
+ }
174
+ for (const c of kids.flat(Infinity)) if (c != null && c !== false) e.append(typeof c === 'object' ? c : String(c));
175
+ return e;
176
+ };
177
+ const svg = (tag, attrs) => { const e = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [k, v] of Object.entries(attrs ?? {})) e.setAttribute(k, String(v)); return e; };
178
+ const $ = id => document.getElementById(id);
179
+ // Rebuilding a scroller's content empties it, which resets its scroll position: put it back afterwards.
180
+ const keepScroll = (el, build) => { const { scrollTop, scrollLeft } = el; build(); el.scrollTop = scrollTop; el.scrollLeft = scrollLeft; };
181
+ const clear = e => { while (e.firstChild) e.removeChild(e.firstChild); return e; };
182
+ const store = { get(k) { try { return localStorage.getItem(k); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } } };
183
+ const short = m => m ? Colors.short(m) : '?';
184
+ const repoName = k => String(k ?? '').replace(/-[0-9a-f]{4}$/, '');
185
+ const base = p => String(p ?? '').split(/[\\/]/).filter(Boolean).pop() ?? '—';
186
+ const when = ms => ms == null ? '—' : new Date(ms).toLocaleString();
187
+ const label = s => `${s.provider} ${short(s.model)}${s.effort ? ' ' + s.effort : ''}`;
188
+ // Provider, model (by strength tier) and effort colours come from lib/colors.js, shared with the terminal; the theme picks the lightness.
189
+ const theme = () => typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
190
+ const paint = (kind, key, provider) => Colors.css(kind, key, theme(), provider);
191
+ // The I/CR/CW/O legend colours live in lib/colors.js only; the CSS variables are set from there (and follow the OS theme).
192
+ const setParts = () => { for (const k of Object.keys(Colors.PART)) document.documentElement.style.setProperty('--' + k, Colors.cssPart(k, theme())); };
193
+ setParts();
194
+ if (typeof matchMedia === 'function') matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change', () => { setParts(); render(); });
195
+ const provEl = p => h('span', { style: { color: paint('provider', p) } }, p);
196
+ const mcolor = m => paint('model', m);
197
+ const modelEl = s => h('b', null, h('span', { style: { color: paint('provider', s.provider) } }, s.provider), ' ', h('span', { style: { color: mcolor(s.model) } }, short(s.model)), s.effort ? [' ', h('span', { style: { color: paint('effort', s.effort, s.provider) } }, s.effort)] : null);
198
+ const title = s => s.name ?? s.agentType ?? '—';
199
+ // Money: the view's knownUSD is the priced part; records without a price are shown next to the total, never hidden.
200
+ const sumCost = items => items.reduce((t, s) => { const c = s.cost; if (c) { t.usd += c.knownUSD; t.known += c.knownRecords; t.unknown += c.unknownRecords; t.uncertain += c.uncertainRecords; } return t; }, { usd: 0, known: 0, unknown: 0, uncertain: 0 });
201
+ const costCell = t => (t.unknown && !t.known ? '?' : '$' + t.usd.toFixed(4)) + (t.unknown ? ` +${t.unknown} unpriced` : '') + (t.uncertain ? ' (uncertain)' : '');
202
+ const kindText = kind => kind === 'api' ? 'API' : 'API equivalent';
203
+ // Model names come from transcripts: only the drawer (text nodes) prints them, never a title attribute.
204
+ const costDetail = (c, names = false) => c ? `sources: ${c.sources.join('+')}${c.unknownModels.length ? '\nno price: ' + (names ? c.unknownModels.join(', ') : c.unknownModels.length + ' models') : ''}${c.uncertainRecords ? `\n${c.uncertainRecords} records priced at normal speed (fast multiplier unknown)` : ''}` : '';
205
+ const moneyLine = n => n.cost ? h('div', { class: n.cost.unknownRecords || n.cost.uncertainRecords ? 't-warn' : 'dim', title: costDetail(n.cost) }, `${kindText(n.costKind)} ${costCell(sumCost([n]))}`) : null;
206
+
207
+ let replay = null, branch = null, grouping = 'none', treeRequest = 0;
208
+ let view = null, recvAt = 0, mode = null, orient = store.get('orient') === 'Vertical' ? 'Vertical' : 'Horizontal';
209
+ let scope = store.get('scope') === 'All' ? 'All' : 'Live';
210
+ let fit = store.get('fit') !== 'off', lastLay = null, zoomNow = 1;
211
+ const savedZoom = Number(store.get('zoom'));
212
+ if (savedZoom >= 0.3 && savedZoom <= 2) zoomNow = savedZoom;
213
+ let repo = null, sel = null, shown = null, fProv = '', fKind = '', pinned = false, histOpen = store.get('hist') === 'open';
214
+ const collapsed = new Set(), tips = new Map();
215
+ const now = () => view.now + (Date.now() - recvAt);
216
+
217
+ function seg(host, items, current, pick) {
218
+ clear(host).append(...items.map(([v, text]) => h('button', { class: v === current ? 'on' : '', on: { click: () => pick(v) } }, text)));
219
+ }
220
+
221
+ // ---- cost summary + legend ---------------------------------------------
222
+ function moneyPanel() {
223
+ const rows = [...view.history], walk = n => { rows.push(n); n.children.forEach(walk); };
224
+ view.repos.forEach(r => r.roots.forEach(walk));
225
+ const days = Object.entries(view.costByDay ?? {}), api = rows.length && rows.every(s => s.billing === 'api');
226
+ const host = clear($('money')); host.hidden = !view.cost;
227
+ if (!view.cost) return;
228
+ host.append(h('summary', { title: costDetail(view.cost) }, `Cost ${api ? kindText('api') : kindText('api-equivalent')}: ${costCell(sumCost([view]))}`),
229
+ h('div', { class: 'money-days mono' }, days.map(([d, c]) => h('span', null, h('span', { class: 'dim' }, d + ' '), costCell(sumCost([{ cost: c }]))))));
230
+ }
231
+ const legend = () => clear($('legend')).append(...PARTS.map(([l, k, c]) => h('span', null, h('i', { class: 'sw c-' + c }), `${l} ${k}`)));
232
+
233
+ // ---- quota strip -------------------------------------------------------
234
+ let provOpts = null;
235
+ function quotaStrip() {
236
+ const host = clear($('quota'));
237
+ for (const p of view.providers) {
238
+ if (!Object.keys(view.quota[p].windows).length) continue;
239
+ const q = view.quota[p], card = h('div', { class: 'acct' }, h('b', { style: { color: paint('provider', p) } }, NAMES[p]));
240
+ for (const w of Object.keys(q.windows)) {
241
+ const x = q.windows[w], pc = x.percent;
242
+ const reset = x.resetInMs != null ? Math.max(0, x.resetInMs - (Date.now() - recvAt)) : null;
243
+ card.append(h('div', { class: 'win' + (x.stale ? ' stale' : '') },
244
+ h('span', { class: 'dim' }, x.label ?? w),
245
+ h('div', { class: 'track' + (pc == null ? ' unk' : '') }, pc == null ? null : h('div', { class: 'fill ' + (x.low ? 'low' : pc >= 60 ? 'mid' : ''), style: { width: Math.min(100, pc) + '%' } })),
246
+ h('span', { class: 'mono' }, pc == null ? (x.reset ? 'reset, not read again' : '— unknown') : F.pct(pc) + '%', x.low ? ' ⚠' : '', reset != null ? ' reset ' + F.dur(reset) : '',
247
+ x.readAt != null ? h('span', { class: 'dim' }, ` · ${x.source ?? '?'}, read ${F.dur(now() - x.readAt)} ago`) : null)));
248
+ }
249
+ if (q.error) card.append(h('div', { class: 'warnings' }, '⚠ ' + q.error));
250
+ host.append(card);
251
+ }
252
+ const sel = $('fProv'), opts = ['', ...view.providers];
253
+ if (opts.join() !== provOpts) { provOpts = opts.join(); clear(sel).append(...opts.map(p => h('option', { value: p }, p || 'all providers'))); if (!opts.includes(fProv)) fProv = ''; sel.value = fProv; }
254
+ const o = view.outside ?? {};
255
+ clear($('outside')).append('outside monitor: ', ...view.providers.filter(p => Object.keys(view.quota[p].windows).length).map((p, i) => [i ? ' ' : '', h('span', { style: { color: paint('provider', p) } }, NAMES[p]), ...Object.keys(view.quota[p].windows).map(w => ` ${view.quota[p].windows[w].label ?? w} ${F.rise(o[p]?.[w])}%`)]).flat());
256
+ }
257
+
258
+ // ---- helpers over the view ---------------------------------------------
259
+ // All adds the finished sessions of live trees, dimmed.
260
+ const graphRepos = () => replay ? [{ key: replay.root.repo, roots: [replay.root] }] : scope === 'All' ? view.repos.map(r => ({ ...r, roots: AgentHistory.withFinished(r.roots, view.history) })) : view.repos;
261
+ const inRepo = r => repo === 'all' || r === repo;
262
+ function allNodes() { const out = new Map(); const walk = (n, parent) => { out.set(n.key, { n, parent }); n.children.forEach(c => walk(c, n)); }; graphRepos().forEach(r => r.roots.forEach(n => walk(n, null))); return out; }
263
+ const lookup = (key, nodes) => nodes.get(key)?.n ?? view.history.find(s => s.key === key) ?? null;
264
+ const descendants = (n, running = false) => n.children.reduce((s, c) => s + (running ? Number(c.state === 'running') : 1) + descendants(c, running), 0);
265
+
266
+ // ---- attention ---------------------------------------------------------
267
+ function attention() {
268
+ const items = view.attention.filter(a => a.repo == null || inRepo(a.repo));
269
+ $('attSec').hidden = !items.length;
270
+ const host = clear($('attention'));
271
+ for (const a of items) {
272
+ if (a.type === 'links') {
273
+ tips.set('amb', view.ambiguous.map(x => `${x.sessions.map(s => s.label).join(' + ')}\n may belong to: ${x.parents.map(p => p.label).join(' | ')}`).join('\n\n'));
274
+ host.append(h('span', { class: 'chip', 'data-tip': 'amb' }, `⚠ ${a.count} unclear links`));
275
+ continue;
276
+ }
277
+ const who = a.provider ? [provEl(a.provider), ' ' + short(a.model)] : [];
278
+ const text = a.type === 'orphan' ? ['⚠ ', ...who, ' orphan']
279
+ : a.type === 'context' ? ['⚠ ', ...who, ` ctx ${F.ctx(a.tokens)}/${F.ctx(a.limit)}`]
280
+ : ['⚠ ', h('span', { style: { color: paint('provider', a.provider) } }, NAMES[a.provider]), ` ${view.quota[a.provider]?.windows[a.window]?.label ?? a.window} quota left ${F.pct(100 - a.percent)}%`];
281
+ host.append(h('span', { class: 'chip', on: { click: () => a.key && focusNode(a.key) } }, text));
282
+ }
283
+ }
284
+
285
+ // ---- node card ---------------------------------------------------------
286
+ const stackBar = s => {
287
+ const total = PARTS.reduce((t, [, k]) => t + (s[k] ?? 0), 0);
288
+ return h('div', { class: 'stack' }, PARTS.map(([, k, c]) => h('i', { class: 'c-' + c, style: { width: total > 0 ? (s[k] ?? 0) / total * 100 + '%' : '0' } })));
289
+ };
290
+ const numCols = () => ({ gridTemplateColumns: `22px repeat(${PARTS.length + 1}, 1fr)` });
291
+ function nums(n) {
292
+ if (!n.tokens && !Object.keys(n.share ?? {}).length) return null;
293
+ if (n.showQuota === false && mode !== 'token') return null; // API billing: no quota to show
294
+ const head = [h('span'), h('span', { class: 'h' }, mode === 'token' ? '' : 'total'), ...PARTS.map(([l]) => h('span', { class: 'h' }, l))];
295
+ if (mode === 'token') return n.tokens ? h('div', { class: 'nums', style: numCols() }, head, h('span'), h('span'), PARTS.map(([, k]) => h('b', null, F.tok(n.tokens[k])))) : null;
296
+ const out = [head];
297
+ for (const w of Object.keys(n.share ?? {})) {
298
+ const s = n.share[w];
299
+ out.push(h('span', { class: 'dim' }, w), h('b', null, s ? '≈' + F.pct(s.total) + '%' : '—'), PARTS.map(([, k]) => h('span', null, s ? F.pct(s[k]) : '—')));
300
+ out.push(h('span'), stackBar(s ?? {}));
301
+ }
302
+ return h('div', { class: 'nums', style: numCols() }, out);
303
+ }
304
+ function card(n, box, parent) {
305
+ const c = n.context, fill = c.tokens != null && c.limit ? Math.min(100, c.tokens / c.limit * 100) : 0;
306
+ const age = n.state === 'waiting' ? `waiting ${F.dur(now() - n.lastActivityAt)}` : `${n.state} ${F.dur(now() - n.startedAt)}`;
307
+ return h('div', { class: `card ${n.state}${sel === n.key ? ' sel' : ''}`, 'data-key': n.key, role: 'button', tabindex: 0,
308
+ on: { click: () => select(n.key), keydown: ev => { if (ev.target === ev.currentTarget && (ev.key === 'Enter' || ev.key === ' ')) { ev.preventDefault(); select(n.key); } } },
309
+ style: { left: box.x + 'px', top: box.y + 'px', width: box.w + 'px', height: box.h + 'px', borderColor: mcolor(n.model) } },
310
+ h('div', null, h('span', { class: 'tag' }, n.kind), h('span', { class: 'st-' + n.state }, '● '), age, n.orphan ? h('span', { class: 'tag warn' }, 'orphan') : null, n.resume ? h('span', { class: 'tag' }, `resume #${n.resume}`) : null),
311
+ h('div', null, modelEl(n)),
312
+ h('div', { class: 'dim' }, repoName(n.repo), ' ', parent && parent.cwd !== n.cwd ? h('span', { class: 'tag', title: n.cwd ?? '' }, 'cwd ' + base(n.cwd)) : null),
313
+ h('div', { class: 'prompt' }, title(n)),
314
+ moneyLine(n),
315
+ nums(n),
316
+ n.tree ? h('div', { class: 'dim', title: costDetail(n.tree.cost) }, 'group: ', mode === 'token' ? PARTS.map(([l, k]) => `${l} ${F.tok(n.tree.tokens?.[k])}`).join(' ') : n.showQuota === false ? '' : Object.keys(n.tree.share).map(w => `${w}≈${F.pct(n.tree.share[w])}%`).join(' ')) : null,
317
+ n.tree && n.tree.cost ? h('div', { class: n.tree.cost.unknownRecords || n.tree.cost.uncertainRecords ? 't-warn' : 'dim', title: costDetail(n.tree.cost) }, `group ${kindText(n.costKind)} ${costCell(sumCost([{ cost: n.tree.cost }]))}`) : null,
318
+ h('div', { class: 'ctx' }, h('div', { class: 'fill' + (c.warn ? ' low' : ''), style: { width: fill + '%' } }), h('div', { class: 'mark' + (c.warn ? ' warn' : '') })),
319
+ h('div', null, h('span', { class: c.warn ? 't-warn' : 'dim' }, `ctx ${F.ctx(c.tokens)}/${c.limit ? F.ctx(c.limit) : '—'} `),
320
+ n.done ? h('span', { class: 'chip', on: { click: ev => { ev.stopPropagation(); branch = n.key; setHist(true); } } }, `✓ ${n.done} finished ▸`) : null));
321
+ }
322
+
323
+ // CSS zoom keeps scroll size, hit-testing and the absolutely placed children consistent. Fit never goes above 1; manual zoom runs 0.3-2.
324
+ function applyFit() {
325
+ const g = $('graph'), l = lastLay;
326
+ // Fit off keeps the current (manual, stored) zoom.
327
+ if (fit && l && g.clientWidth && g.clientHeight) zoomNow = Math.max(0.3, Math.min(1, g.clientWidth / l.width, g.clientHeight / l.height));
328
+ $('canvas').style.zoom = zoomNow; $('zoomBtn').textContent = Math.round(zoomNow * 100) + '%';
329
+ $('fitBtn').className = fit ? 'on' : ''; $('fitBtn').setAttribute('aria-pressed', fit);
330
+ }
331
+ function setZoom(z) {
332
+ zoomNow = Math.round(Math.max(0.3, Math.min(2, z)) * 100) / 100; fit = false;
333
+ store.set('fit', 'off'); store.set('zoom', zoomNow); applyFit();
334
+ }
335
+ function graph() {
336
+ const focused = ['data-fold', 'data-key'].find(a => document.activeElement?.getAttribute(a)), focusKey = focused && document.activeElement.getAttribute(focused);
337
+ const nodes = allNodes();
338
+ const roots = graphRepos().filter(r => replay || inRepo(r.key)).flatMap(r => r.roots).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
339
+ const wrap = n => ({ id: n.key, children: n.children.map(wrap) });
340
+ const lay = AgentGraph.layoutGraph(roots.map(wrap), { vertical: orient === 'Vertical', collapsed });
341
+ const canvas = clear($('canvas'));
342
+ canvas.className = !replay && scope === 'All' ? 'all' : '';
343
+ clear($('replayBar')).append(...replay ? [`Viewing: ${title(replay.root)} · ended ${replay.root.endedAt == null ? 'still live' : when(replay.root.endedAt)} · tree ${kindText(replay.root.costKind)} ${costCell(sumCost([replay]))} `, h('button', { on: { click: () => { treeRequest++; replay = null; branch = null; sel = null; render(); } } }, '✕ back to live')] : []); $('replayBar').hidden = !replay;
344
+ canvas.style.width = lay.width + 'px'; canvas.style.height = lay.height + 'px';
345
+ lastLay = lay; applyFit();
346
+ tips.clear();
347
+ if (!roots.length) { canvas.append(h('div', { class: 'dim', style: { padding: '16px' } }, 'no active sessions' + (repo !== 'all' ? ' in this repo' : ''))); return; }
348
+ const root = svg('svg', { width: lay.width, height: lay.height }), labels = [];
349
+ lay.wires.forEach((w, i) => {
350
+ // No recorded link is never drawn as proven: dotted, labelled unknown, no evidence.
351
+ const child = nodes.get(w.to).n, l = child.link ?? { kind: 'inferred', label: 'unknown link', evidence: null }, kind = l.kind;
352
+ tips.set('e' + i, `${l?.label ?? 'link'} · ${kind}\n` + Object.entries(l?.evidence ?? {}).map(([k, v]) => `${k}: ${k === 'at' ? when(v) : v}`).join('\n'));
353
+ root.append(svg('path', { id: 'w' + i, d: w.path, class: `edge ${kind} ${child.state}` }), svg('path', { d: w.path, class: 'hit', 'data-tip': 'e' + i }));
354
+ if (child.state === 'running') {
355
+ const dot = svg('circle', { r: 4, class: 'dot' }), m = svg('animateMotion', { dur: '2.2s', begin: `-${(i * 0.37 % 2.2).toFixed(2)}s`, repeatCount: 'indefinite' });
356
+ m.append(svg('mpath', { href: '#w' + i }));
357
+ dot.append(m); root.append(dot);
358
+ }
359
+ labels.push(h('div', { class: 'elabel' + (orient === 'Vertical' ? ' v' : ''), 'data-tip': 'e' + i, style: { left: w.label[0] + 'px', top: w.label[1] + 'px' } }, l.label));
360
+ });
361
+ canvas.append(root, ...labels);
362
+ for (const b of lay.nodes) {
363
+ const { n, parent } = nodes.get(b.id); canvas.append(card(n, b, parent));
364
+ if (!n.children.length) continue;
365
+ const shut = collapsed.has(n.key), vertical = orient === 'Vertical', { w, h: height } = AgentGraph.FOLD;
366
+ canvas.append(h('button', { class: 'fold', 'data-fold': n.key, 'aria-expanded': !shut, 'aria-label': shut ? `Expand ${descendants(n)} hidden children (${descendants(n, true)} running)` : 'Collapse subtree',
367
+ style: { left: (vertical ? b.x + b.w / 2 : b.x + b.w - height / 2) + 'px', top: (vertical ? b.y + b.h - height / 2 : b.y + b.h / 2 - height / 2) + 'px',
368
+ width: (shut ? w : height) + 'px', height: height + 'px', transform: vertical ? 'translateX(-50%)' : '' },
369
+ on: { click: () => { if (shut) collapsed.delete(n.key); else collapsed.add(n.key); render(); canvas.querySelector(`[data-fold="${CSS.escape(n.key)}"]`)?.focus(); } }
370
+ }, shut ? `+${descendants(n)} (${descendants(n, true)} running)` : '−'));
371
+ }
372
+ if (focused) canvas.querySelector(`[${focused}="${CSS.escape(focusKey)}"]`)?.focus({ preventScroll: true });
373
+ }
374
+
375
+ function focusNode(key) {
376
+ const nodes = allNodes();
377
+ for (let p = nodes.get(key)?.parent; p; p = nodes.get(p.key).parent) collapsed.delete(p.key);
378
+ render();
379
+ const el = $('canvas').querySelector(`[data-key="${CSS.escape(key)}"]`);
380
+ if (!el) return;
381
+ $('graph').scrollTo({ left: Math.max(0, parseFloat(el.style.left) * zoomNow - 40), top: Math.max(0, parseFloat(el.style.top) * zoomNow - 40), behavior: 'smooth' });
382
+ el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 1500);
383
+ }
384
+
385
+ // ---- history -----------------------------------------------------------
386
+ // The history is a footer panel: a bar at the bottom toggles one viewport-high section below the canvas.
387
+ function setHist(on, scroll = on) { histOpen = on; store.set('hist', on ? 'open' : 'closed'); render(); if (scroll) $('histSec').scrollIntoView?.({ behavior: 'smooth', block: 'start' }); }
388
+ function histPanel() {
389
+ $('histSec').hidden = !histOpen;
390
+ $('histBtn').textContent = histOpen ? 'History ▾' : 'History ▴';
391
+ }
392
+ function parentLabel(key, nodes) { const p = lookup(key, nodes); return p ? label(p) + (p.name ? ' · ' + p.name.slice(0, 40) : '') : (key.split(':')[1] ?? key).slice(0, 8); }
393
+ function renderHistory() {
394
+ const nodes = allNodes();
395
+ // Live mode: `branch` keeps the finished sessions whose parent chain reaches it.
396
+ const under = s => { const seen = new Set(); for (let k = s.parent; k && !seen.has(k); k = lookup(k, nodes)?.parent) { if (k === branch) return true; seen.add(k); } return false; };
397
+ const source = replay ? AgentHistory.subtree(branch ? lookup(branch, nodes) : replay.root) : branch ? view.history.filter(under) : view.history;
398
+ const rows = source.filter(s => (replay || inRepo(s.rootRepo)) && (!fProv || s.provider === fProv) && (!fKind || s.kind === fKind));
399
+ $('histCount').textContent = `sessions: ${rows.length}`;
400
+ const crumb = clear($('fParent'));
401
+ crumb.append(h('button', { on: { click: () => { replay = null; branch = null; render(); } } }, 'All'));
402
+ if (replay || branch) {
403
+ const path = []; let n = lookup(branch, nodes) ?? replay?.root;
404
+ const seen = new Set();
405
+ while (n && !seen.has(n.key)) { seen.add(n.key); path.unshift(n); n = nodes.get(n.key)?.parent; }
406
+ for (const n of path) crumb.append(' › ', h('button', { on: { click: () => { branch = n.key; renderHistory(); } } }, title(n)));
407
+ }
408
+ crumb.append(' Group: ', ...['none', 'agent', 'agent › model'].map(g => h('button', { class: grouping === g ? 'on' : '', on: { click: () => { grouping = g; renderHistory(); } } }, g)));
409
+ const windows = [...new Set(rows.flatMap(s => Object.keys(s.share)))];
410
+ const cols = [['ended'], ['kind'], ['agent'], ['name'], ['parent'], ['duration'], ['cost'], ...(mode === 'token' ? [] : windows.map(w => [w])), ...PARTS.map(([l, , c]) => [l, 't-' + c])];
411
+ const body = rows.map(s => h('tr', { on: { click: () => select(s.key) } },
412
+ h('td', null, s.endedAt != null ? when(s.endedAt) : s.state, s.turn === 'aborted' ? ' · ⊘ interrupted' : ''), h('td', null, s.kind), h('td', null, modelEl(s), s.agentType ? ` (${s.agentType})` : ''),
413
+ h('td', { style: { paddingLeft: (8 + (s.depth ?? 0) * 18) + 'px' } }, title(s), ' ', h('button', { on: { click: ev => { ev.stopPropagation(); openTree(s.key); } } }, '⧉ tree')),
414
+ h('td', null, s.parent ? parentLabel(s.parent, nodes) : '—'), h('td', null, F.dur((s.endedAt ?? now()) - s.startedAt)), h('td', { class: 'mono', title: costDetail(s.cost) }, costCell(sumCost([s]))),
415
+ mode === 'token' ? null : windows.map(w => h('td', { class: 'mono' }, s.share[w] ? '≈' + F.pct(s.share[w].total) + '%' : '—')),
416
+ PARTS.map(([, k, c]) => h('td', { class: 'mono t-' + c }, F.tok(s.tokens?.[k])))));
417
+ // One row per provider, aligned to cols; tokens and share are never summed across providers.
418
+ const summary = (label, items, cls, total) => {
419
+ const t = AgentHistory.totals(items, now()), c = cls ? { class: cls } : null;
420
+ const rowOf = (first, kind, dur, share, tok, cost) => h('tr', c, cols.map(([name]) => h('td', { class: windows.includes(name) || name === 'kind' ? null : 'mono' }, ...(
421
+ name === 'ended' ? [h('b', null, first)] : name === 'kind' ? [kind] : name === 'duration' ? [dur] : name === 'cost' ? [cost] : share && (windows.includes(name)) ? [share(name)] : tok && PARTS.some(p => p[0] === name) ? [tok(PARTS.find(p => p[0] === name))] : []))));
422
+ return [...Object.entries(t.providers).map(([p, v]) => {
423
+ return rowOf([label, ' · ', provEl(p)], Object.entries(v.kinds).map(([k, n]) => k + ' ' + n).join(', '), F.dur(v.active),
424
+ w => v.share[w] == null ? '—' : '≈' + F.pct(v.share[w]) + '%', ([, k, col]) => h('span', { class: 't-' + col }, F.tok(v.tokens[k])), costCell(sumCost(items.filter(s => s.provider === p))));
425
+ }), ...(total ? [rowOf('All', 'sessions ' + items.length, 'active ' + F.dur(t.active) + ' · span ' + F.dur(t.span), null, null, costCell(sumCost(items)))] : [])];
426
+ };
427
+ let display = body;
428
+ if (grouping !== 'none') {
429
+ const groups = new Map();
430
+ rows.forEach((s, i) => { const k = s.provider + (grouping === 'agent › model' ? ' › ' + (s.model ?? 'unknown') : ''); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(i); });
431
+ display = [...groups].flatMap(([k, indexes]) => [...indexes.map(i => body[i]), ...summary([provEl(rows[indexes[0]].provider), k.slice(rows[indexes[0]].provider.length) + ' subtotal'], indexes.map(i => rows[i]), 'sub')]);
432
+ }
433
+ const head = h('tr', null, cols.map(([c, cls]) => h('th', { class: cls }, c))), foot = summary('Total', rows, null, true);
434
+ const mc = cols.map(() => h('col')), fc = cols.map(() => h('col'));
435
+ const main = h('table', null, h('colgroup', null, mc), h('thead', null, head), h('tbody', null, display)), tail = h('table', null, h('colgroup', null, fc), h('tfoot', null, foot));
436
+ histCols = { main, tail, head, foot, mc, fc };
437
+ keepScroll($('hist'), () => keepScroll($('histFoot'), () => { clear($('hist')).append(main); clear($('histFoot')).append(tail); syncCols(); }));
438
+ }
439
+ // The footer is a second table: give both tables the same fixed column widths (the widest cell of either) so the columns line up.
440
+ let histCols = null;
441
+ function syncCols() {
442
+ if (!histCols) return;
443
+ const { main, tail, head, foot, mc, fc } = histCols;
444
+ keepScroll($('hist'), () => keepScroll($('histFoot'), () => {
445
+ for (const t of [main, tail]) { t.style.tableLayout = ''; t.style.width = ''; }
446
+ [...mc, ...fc].forEach(c => { c.style.width = ''; });
447
+ const w = mc.map((_, i) => Math.ceil(Math.max(head.children[i].offsetWidth ?? 0, ...foot.map(r => r.children[i].offsetWidth ?? 0))));
448
+ const sum = w.reduce((a, b) => a + b, 0);
449
+ if (!sum) return; // hidden section: nothing to measure yet
450
+ w.forEach((x, i) => { mc[i].style.width = fc[i].style.width = x + 'px'; });
451
+ for (const t of [main, tail]) { t.style.tableLayout = 'fixed'; t.style.width = Math.max(sum, $('hist').clientWidth ?? 0) + 'px'; }
452
+ }));
453
+ }
454
+
455
+ // ---- detail drawer -----------------------------------------------------
456
+ // The timeline is read once per opened session (from the transcript, on the server); the info part follows every frame.
457
+ async function openTree(key) {
458
+ const request = ++treeRequest;
459
+ try {
460
+ const r = await fetch('/tree?key=' + encodeURIComponent(key));
461
+ if (!r.ok) throw new Error('tree unavailable');
462
+ const data = await r.json();
463
+ if (request !== treeRequest) return;
464
+ replay = data; branch = key; collapsed.clear(); setHist(true, false); focusNode(key); globalThis.scrollTo?.({ top: 0, behavior: 'smooth' });
465
+ } catch (e) { $('conn').textContent = e.message; }
466
+ }
467
+ function select(key) { sel = key; render(); }
468
+ function tlRow(e) {
469
+ const [name, ...rest] = e.kind === 'tool' ? e.text.split(' ') : [e.kind]; // a tool row is named after the tool
470
+ const text = e.kind === 'tool' ? rest.join(' ') : e.text;
471
+ const t = e.tokens ? PARTS.filter(([, k]) => e.tokens[k]).map(([l, k, c]) => h('span', { class: 't-' + c }, `${l} ${F.tok(e.tokens[k])}`)) : [];
472
+ return h('div', { class: 'tr k-' + e.kind }, h('span', { class: 't' }, e.at ? F.hhmm(e.at) : ''), h('span', { class: 'kd' }, name), h('span', null, text), t.length ? h('div', { class: 'tk' }, t) : null);
473
+ }
474
+ async function loadTimeline(key) {
475
+ const into = fn => { if (shown === key && $('tl')) fn(clear($('tl'))); };
476
+ try {
477
+ const r = await fetch('/timeline?key=' + encodeURIComponent(key));
478
+ if (!r.ok) throw new Error('no transcript for this session');
479
+ const d = await r.json();
480
+ into(host => {
481
+ if (d.total > d.entries.length) host.append(h('div', { class: 'dim', style: { padding: '3px 8px' } }, `… ${d.total - d.entries.length} older entries hidden`));
482
+ host.append(...d.entries.map(tlRow)); // spread: append(array) would print "[object HTMLDivElement],..."
483
+ if (!d.entries.length) host.append(h('div', { class: 'dim', style: { padding: '3px 8px' } }, 'empty'));
484
+ host.scrollTop = host.scrollHeight; // newest last
485
+ });
486
+ } catch (e) { into(host => host.append(h('div', { class: 'dim', style: { padding: '3px 8px' } }, String(e.message)))); }
487
+ }
488
+ function drawer() {
489
+ const d = $('drawer');
490
+ $('backdrop').hidden = !sel;
491
+ if (!sel) { d.hidden = true; shown = null; return; }
492
+ d.hidden = false;
493
+ if (shown !== sel) {
494
+ shown = sel;
495
+ clear(d).append(h('div', { id: 'dinfo' }), h('div', { class: 'dsec' }, h('h3', null, 'Timeline'), h('div', { id: 'tl' }, h('div', { class: 'dim', style: { padding: '3px 8px' } }, 'loading…'))));
496
+ loadTimeline(sel);
497
+ }
498
+ keepScroll(d, () => drawerInfo());
499
+ }
500
+ function drawerInfo() {
501
+ const s = lookup(sel, allNodes()), info = clear($('dinfo'));
502
+ if (!s) { info.append(h('div', { class: 'row' }, h('b', null, sel), h('button', { on: { click: () => { sel = null; render(); } } }, '✕')), h('p', { class: 'dim' }, 'session is not in the current filter')); return; }
503
+ const dl = pairs => h('dl', null, pairs.map(([k, v]) => [h('dt', null, k), h('dd', null, v)]));
504
+ const sec = (name, ...kids) => h('div', { class: 'dsec' }, h('h3', null, name), kids);
505
+ const l = s.link, state = s.state ?? 'ended', tot = w => s.share[w] ? '≈' + F.pct(s.share[w].total) + '%' : '—';
506
+ info.append(
507
+ h('div', { class: 'row' }, h('div', { class: 'dtitle' }, title(s)), h('button', { on: { click: () => { sel = null; render(); } } }, '✕')),
508
+ h('div', { style: { marginTop: '6px' } }, h('span', { class: 'badge', style: { color: mcolor(s.model) } }, modelEl(s)), h('span', { class: 'badge st-' + state }, s.turn === 'aborted' ? '⊘ interrupted' : state), h('span', { class: 'badge dim' }, s.kind + (s.agentType ? ` (${s.agentType})` : ''))),
509
+ sec('Info', dl([['provider', h('span', { style: { color: paint('provider', s.provider) } }, s.provider)], ['id', s.id], ['model', h('span', { style: { color: mcolor(s.model) } }, s.model ?? '—')], ['effort', h('span', { style: { color: paint('effort', s.effort, s.provider) } }, s.effort ?? '—')], ['cwd', s.cwd ?? '—'], ['repo', s.repo],
510
+ ['started', when(s.startedAt)], ['last activity', when(s.lastActivityAt ?? s.endedAt)], ['resume', String(s.resume ?? 0)], ['orphan', s.orphan ? 'yes' : 'no']])),
511
+ s.cost ? sec('Cost', dl([['billing', s.billing + (s.showQuota === false ? ' (quota hidden)' : '')], [kindText(s.costKind), h('span', { class: s.cost.unknownRecords || s.cost.uncertainRecords ? 't-warn' : null }, costCell(sumCost([s])))], ['detail', costDetail(s.cost, true).replace(/\n/g, ' · ')], ...Object.entries(s.costByDay ?? {}).map(([d, c]) => [d, costCell(sumCost([{ cost: c }]))])])) : null,
512
+ s.tokens ? sec('Token', h('div', { class: 'tok' }, PARTS.map(([lab, k, c]) => [h('i', { class: 'sw c-' + c }), h('span', { class: 't-' + c }, lab + ' ' + k), h('b', { class: 'mono t-' + c }, s.tokens?.[k] == null ? '—' : String(s.tokens?.[k]))])),
513
+ dl(Object.keys(s.share).map(w => ['quota ' + w, tot(w)]))) : null,
514
+ sec('Runs', s.runs?.length ? h('div', { class: 'mono' }, s.runs.map(r => h('div', null, `pid ${r.pid ?? 'hosted'} · ${when(r.start)} → ${r.end ? when(r.end) : 'running'}`))) : h('div', { class: 'dim' }, '—')),
515
+ sec('Link to parent', l ? dl([['kind', `${l.kind} (${l.label})`], ...Object.entries(l.evidence ?? {}).map(([k, v]) => [k, k === 'at' ? when(v) : String(v)])]) : h('div', { class: 'dim' }, 'none')));
516
+ }
517
+
518
+ // ---- frame -------------------------------------------------------------
519
+ function filters() {
520
+ const opts = new Map(); // repo key -> { name, root, at }
521
+ const add = (k, name, root, at) => { const o = opts.get(k) ?? opts.set(k, { name, root, at: 0 }).get(k); o.root ??= root; o.at = Math.max(o.at, at); };
522
+ view.repos.forEach(r => add(r.key, r.name, r.root, r.lastAt));
523
+ view.history.forEach(s => add(s.rootRepo, repoName(s.rootRepo), s.rootPath, s.endedAt));
524
+ if (repo !== 'all') add(repo, repoName(repo), null, 0);
525
+ // Two repos with one display name get their parent folder appended.
526
+ const list = [...opts].sort((a, b) => b[1].at - a[1].at);
527
+ const text = o => list.filter(([, x]) => x.name === o.name).length > 1 && o.root ? `${o.name} (${base(o.root.replace(/[\\/]+[^\\/]+[\\/]*$/, ''))})` : o.name;
528
+ const el = clear($('repoSel'));
529
+ el.append(h('option', { value: 'all' }, 'all repos'), ...list.map(([k, o]) => h('option', { value: k, title: o.root ?? '' }, text(o))));
530
+ el.value = repo;
531
+ }
532
+ function render() {
533
+ if (!view) return;
534
+ seg($('modeSeg'), [['quota', 'Quota'], ['token', 'Token']], mode, v => { mode = v; store.set('mode', v); render(); });
535
+ seg($('orientSeg'), [['Horizontal', 'Horizontal'], ['Vertical', 'Vertical']], orient, v => { orient = v; store.set('orient', v); render(); });
536
+ seg($('scopeSeg'), [['Live', 'Live'], ['All', 'All']], scope, v => { scope = v; store.set('scope', v); render(); });
537
+ legend(); moneyPanel(); quotaStrip(); graph(); attention(); histPanel(); renderHistory(); drawer();
538
+ $('warnings').textContent = view.warnings.length ? '⚠ ' + view.warnings.join(' · ') : '';
539
+ }
540
+ function accept(v) {
541
+ if (!view) { const saved = store.get('mode'); mode = v.modeFlag ?? (saved === 'token' || saved === 'quota' ? saved : 'quota'); repo = v.defaultRepo ?? 'all'; }
542
+ view = v; recvAt = Date.now();
543
+ setPartsOf(v);
544
+ filters();
545
+ render();
546
+ }
547
+
548
+ // Hover/click on an edge or its label shows the link evidence; delegated, so it survives the per-frame redraw.
549
+ document.addEventListener('mouseover', e => {
550
+ const t = e.target?.closest?.('[data-tip]'), tip = $('tip');
551
+ if (!t) { if (!pinned) tip.hidden = true; return; }
552
+ tip.textContent = tips.get(t.getAttribute('data-tip')) ?? '';
553
+ tip.hidden = false;
554
+ tip.style.left = Math.max(0, Math.min(e.clientX + 12, innerWidth - 440)) + 'px'; tip.style.top = e.clientY + 12 + 'px';
555
+ });
556
+ document.addEventListener('click', e => { const t = e.target?.closest?.('[data-tip]'); pinned = !!t && !pinned; if (!t) $('tip').hidden = true; });
557
+ $('repoSel').addEventListener('change', e => { repo = e.target.value; render(); });
558
+ $('fitBtn').addEventListener('click', () => { fit = !fit; store.set('fit', fit ? 'on' : 'off'); if (!fit) store.set('zoom', zoomNow); applyFit(); });
559
+ $('zoomOut').addEventListener('click', () => setZoom(zoomNow - 0.1));
560
+ $('zoomIn').addEventListener('click', () => setZoom(zoomNow + 0.1));
561
+ $('zoomBtn').addEventListener('click', () => setZoom(1));
562
+ // Pinch sends many small deltas: one 10% step per 100 px (a line counts as 16 px, like the history scroll).
563
+ let wheelSum = 0;
564
+ $('graph').addEventListener('wheel', e => {
565
+ if (!e.ctrlKey || !e.deltaY) return;
566
+ e.preventDefault();
567
+ wheelSum += e.deltaY * (e.deltaMode === 1 ? 16 : 1);
568
+ if (Math.abs(wheelSum) < 100) return;
569
+ const steps = Math.trunc(wheelSum / 100); wheelSum -= steps * 100;
570
+ setZoom(zoomNow - steps * 0.1);
571
+ }, { passive: false });
572
+ $('histFoot').addEventListener('scroll', () => { $('hist').scrollLeft = $('histFoot').scrollLeft; });
573
+ $('hist').addEventListener('wheel', e => { $('histFoot').scrollLeft += e.deltaX * (e.deltaMode === 1 ? 16 : 1); });
574
+ $('backdrop').addEventListener('click', () => { sel = null; render(); });
575
+ document.addEventListener('keydown', e => { if (e.key === 'Escape' && sel) { sel = null; render(); } });
576
+ globalThis.addEventListener?.('resize', () => { applyFit(); syncCols(); });
577
+ $('histBtn').addEventListener('click', () => setHist(!histOpen));
578
+ $('fProv').addEventListener('change', e => { fProv = e.target.value; renderHistory(); });
579
+ $('fKind').addEventListener('change', e => { fKind = e.target.value; renderHistory(); });
580
+
581
+ const es = new EventSource('/events');
582
+ es.onmessage = e => {
583
+ $('conn').textContent = 'live'; $('conn').className = 'dim';
584
+ try { accept(JSON.parse(e.data)); } catch (err) { $('conn').textContent = 'data error: ' + err.message; }
585
+ };
586
+ // EventSource reconnects by itself; the last frame stays on screen.
587
+ es.onerror = () => { $('conn').textContent = 'connection lost — keeping the last data, retrying'; $('conn').className = 'off'; };
588
+ })();
589
+ </script>
590
+ </body>
591
+ </html>