@animalabs/connectome-host 0.3.1 → 0.3.7

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 (41) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/publish.yml +9 -4
  3. package/README.md +5 -0
  4. package/bun.lock +8 -4
  5. package/docs/AGENT-ONBOARDING.md +6 -1
  6. package/package.json +5 -5
  7. package/scripts/import-codex-rollout.ts +288 -0
  8. package/src/call-ledger.ts +371 -0
  9. package/src/call-pricing.ts +119 -0
  10. package/src/commands.ts +57 -3
  11. package/src/index.ts +87 -27
  12. package/src/logging-adapter.ts +72 -9
  13. package/src/modules/fleet-module.ts +21 -9
  14. package/src/modules/mcpl-admin-module.ts +6 -0
  15. package/src/modules/observers-module.ts +180 -0
  16. package/src/modules/time-module.ts +15 -9
  17. package/src/modules/web-ui-module.ts +415 -16
  18. package/src/modules/web-ui-observers.ts +322 -0
  19. package/src/recipe.ts +48 -3
  20. package/src/strategies/frontdesk-strategy.ts +10 -4
  21. package/src/web/protocol.ts +141 -0
  22. package/test/call-ledger.test.ts +91 -0
  23. package/test/call-pricing.test.ts +69 -0
  24. package/test/fleet-subscribe-union-e2e.test.ts +5 -0
  25. package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
  26. package/test/frontdesk-strategy.test.ts +10 -0
  27. package/test/import-codex-rollout.test.ts +36 -0
  28. package/test/logging-adapter.test.ts +58 -1
  29. package/test/recipe-cache-ttl.test.ts +7 -3
  30. package/test/recipe-provider.test.ts +36 -0
  31. package/test/recipe-timezone.test.ts +20 -0
  32. package/test/time-module.test.ts +13 -0
  33. package/test/web-ui-context-coverage.test.ts +61 -0
  34. package/test/web-ui-observers.test.ts +344 -0
  35. package/web/package-lock.json +2446 -0
  36. package/web/src/App.tsx +24 -0
  37. package/web/src/Context.tsx +207 -7
  38. package/web/src/ObserverGate.tsx +78 -0
  39. package/web/src/Usage.tsx +116 -2
  40. package/web/src/observer-identity.ts +110 -0
  41. package/web/src/wire.ts +60 -1
package/web/src/App.tsx CHANGED
@@ -12,6 +12,7 @@ import { McplPanel, type McplServerRow } from './Mcpl';
12
12
  import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
13
13
  import { ContextPanel } from './Context';
14
14
  import { ContextDocument } from './ContextDocument';
15
+ import { ObserverGateScreen } from './ObserverGate';
15
16
  import {
16
17
  WEB_PROTOCOL_VERSION,
17
18
  type WebUiServerMessage,
@@ -21,6 +22,7 @@ import {
21
22
  type MessageBlock,
22
23
  type TokenUsage,
23
24
  type PerAgentCost,
25
+ type CallLedgerSnapshot,
24
26
  } from '@conhost/web/protocol';
25
27
 
26
28
  /** Client-side block: the wire MessageBlock plus live-stream bookkeeping. */
@@ -115,6 +117,7 @@ export function App() {
115
117
  let pendingHistoryTimer: number | undefined;
116
118
  const [usage, setUsage] = createSignal<TokenUsage>({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
117
119
  const [perAgentCost, setPerAgentCost] = createSignal<PerAgentCost[]>([]);
120
+ const [callLedger, setCallLedger] = createSignal<CallLedgerSnapshot | null>(null);
118
121
  const [draft, setDraft] = createSignal('');
119
122
  /** Currently-focused tree node + the panel mode rendered on its behalf.
120
123
  * `mode` decides whether the side panel shows live stream events or a
@@ -704,6 +707,7 @@ export function App() {
704
707
  appendMessage: (m) => setMessages(produce(arr => arr.push(m))),
705
708
  setUsage,
706
709
  setPerAgentCost,
710
+ setCallLedger,
707
711
  appendStreamToken,
708
712
  appendToolUseBlocks,
709
713
  updateToolStatus,
@@ -836,6 +840,20 @@ export function App() {
836
840
  <div class="flex flex-col h-screen">
837
841
  <Header welcome={welcome()} usage={usage()} status={wire.status()} />
838
842
  <ReconnectBanner status={wire.status()} />
843
+ <Show when={wire.observerState() === 'observer' && wire.observer()}>
844
+ {(info) => (
845
+ <div class="bg-violet-950/60 border-b border-violet-900 px-4 py-1.5 text-xs text-violet-200 flex items-center gap-2">
846
+ <span class="w-2 h-2 rounded-full bg-violet-500" />
847
+ <span>
848
+ Observing as <span class="font-semibold">{info().label}</span> — read-only, scopes:{' '}
849
+ <code class="font-mono">{info().scopes.join(', ')}</code>. Content outside your scopes is elided.
850
+ </span>
851
+ </div>
852
+ )}
853
+ </Show>
854
+ <Show when={wire.observerState() === 'denied' || wire.observerState() === 'unavailable'}>
855
+ <ObserverGateScreen state={wire.observerState() as 'denied' | 'unavailable'} />
856
+ </Show>
839
857
  <Show when={protoMismatch() !== null}>
840
858
  <div class="bg-amber-950/60 border-b border-amber-900 px-4 py-1.5 text-xs text-amber-200 flex items-center gap-2">
841
859
  <span class="w-2 h-2 rounded-full bg-amber-500" />
@@ -952,6 +970,7 @@ export function App() {
952
970
  node={focusedNode()!}
953
971
  sessionUsage={usage()}
954
972
  perAgentCost={perAgentCost()}
973
+ callLedger={callLedger()}
955
974
  onClose={closePanel}
956
975
  />
957
976
  )}
@@ -1051,6 +1070,7 @@ interface HandlerHooks {
1051
1070
  appendMessage: (msg: Message) => void;
1052
1071
  setUsage: (u: TokenUsage) => void;
1053
1072
  setPerAgentCost: (c: PerAgentCost[]) => void;
1073
+ setCallLedger: (ledger: CallLedgerSnapshot | null) => void;
1054
1074
  appendStreamToken: (token: string, blockType?: string) => void;
1055
1075
  /** Attach yielded tool calls to the streaming assistant message. */
1056
1076
  appendToolUseBlocks: (calls: Array<{ id: string; name: string; input?: unknown }>) => void;
@@ -1084,6 +1104,7 @@ function handleServerMessage(
1084
1104
  hooks.applyWelcome(msg);
1085
1105
  hooks.setUsage(msg.usage);
1086
1106
  hooks.setPerAgentCost(msg.perAgentCost ?? []);
1107
+ hooks.setCallLedger(msg.callLedger ?? null);
1087
1108
  return;
1088
1109
  }
1089
1110
  case 'message-appended':
@@ -1096,6 +1117,9 @@ function handleServerMessage(
1096
1117
  hooks.setUsage(msg.usage);
1097
1118
  if (msg.perAgentCost) hooks.setPerAgentCost(msg.perAgentCost);
1098
1119
  return;
1120
+ case 'call-ledger':
1121
+ hooks.setCallLedger(msg.ledger);
1122
+ return;
1099
1123
  case 'trace': {
1100
1124
  const e = msg.event;
1101
1125
  switch (e.type) {
@@ -8,7 +8,7 @@
8
8
  * a per-segment table, with an exact total token count.
9
9
  */
10
10
 
11
- import { createSignal, onMount, For, Show } from 'solid-js';
11
+ import { createSignal, onCleanup, onMount, For, Show } from 'solid-js';
12
12
 
13
13
  interface Seg { messages: number; tokens: number }
14
14
  interface Stats {
@@ -28,9 +28,55 @@ interface Makeup {
28
28
  error?: string;
29
29
  }
30
30
 
31
+ interface CoverageLevel {
32
+ level: number;
33
+ summaries: number;
34
+ frontier: number;
35
+ tokens: number;
36
+ coveredChunks: number;
37
+ coveredMessages: number;
38
+ coveredTokens: number;
39
+ }
40
+
41
+ interface CoverageChunk {
42
+ index: number;
43
+ messages: number;
44
+ tokens: number;
45
+ compressed: boolean;
46
+ summaryId: string | null;
47
+ maxLevel: number;
48
+ selectedMin: number;
49
+ selectedMax: number;
50
+ queued: boolean;
51
+ }
52
+
53
+ interface Coverage {
54
+ agent: string;
55
+ branch: string;
56
+ generatedAt: string;
57
+ supported: boolean;
58
+ totals: {
59
+ chunks: number;
60
+ compressedChunks: number;
61
+ coveredMessages: number;
62
+ coveredTokens: number;
63
+ summaries: number;
64
+ };
65
+ levels: CoverageLevel[];
66
+ chunks: CoverageChunk[];
67
+ queue: {
68
+ inFlight: boolean;
69
+ pending: string | null;
70
+ l1: number[];
71
+ merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
72
+ };
73
+ }
74
+
31
75
  type Row = { key: string; label: string; messages: number; tokens: number; color: string };
32
76
 
33
77
  const fmt = (n: number) => n.toLocaleString();
78
+ const levelColors = ['#52525b', '#06b6d4', '#10b981', '#f59e0b', '#f43f5e', '#a78bfa', '#60a5fa'];
79
+ const levelColor = (level: number) => levelColors[Math.min(level, levelColors.length - 1)];
34
80
 
35
81
  function rowsOf(s: Stats): Row[] {
36
82
  return [
@@ -45,15 +91,19 @@ function rowsOf(s: Stats): Row[] {
45
91
 
46
92
  export function ContextPanel(props: { agent?: string }) {
47
93
  const [data, setData] = createSignal<Makeup | null>(null);
94
+ const [coverage, setCoverage] = createSignal<Coverage | null>(null);
48
95
  const [loading, setLoading] = createSignal(false);
96
+ const [coverageLoading, setCoverageLoading] = createSignal(false);
49
97
  const [err, setErr] = createSignal<string | null>(null);
98
+ const [coverageErr, setCoverageErr] = createSignal<string | null>(null);
99
+
100
+ const query = () => props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
50
101
 
51
102
  const load = async () => {
52
103
  setLoading(true);
53
104
  setErr(null);
54
105
  try {
55
- const q = props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
56
- const res = await fetch(`/debug/context/makeup${q}`, { credentials: 'same-origin' });
106
+ const res = await fetch(`/debug/context/makeup${query()}`, { credentials: 'same-origin' });
57
107
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
58
108
  const j = (await res.json()) as Makeup;
59
109
  if (j.error) throw new Error(j.error);
@@ -65,7 +115,30 @@ export function ContextPanel(props: { agent?: string }) {
65
115
  }
66
116
  };
67
117
 
68
- onMount(load);
118
+ const loadCoverage = async () => {
119
+ setCoverageLoading(true);
120
+ setCoverageErr(null);
121
+ try {
122
+ const res = await fetch(`/debug/context/coverage${query()}`, { credentials: 'same-origin' });
123
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
124
+ setCoverage((await res.json()) as Coverage);
125
+ } catch (e) {
126
+ setCoverageErr(e instanceof Error ? e.message : String(e));
127
+ } finally {
128
+ setCoverageLoading(false);
129
+ }
130
+ };
131
+
132
+ const refreshAll = () => {
133
+ void load();
134
+ void loadCoverage();
135
+ };
136
+
137
+ onMount(() => {
138
+ refreshAll();
139
+ const timer = window.setInterval(() => void loadCoverage(), 5_000);
140
+ onCleanup(() => window.clearInterval(timer));
141
+ });
69
142
 
70
143
  const estTotal = () => data()?.stats?.total.tokens ?? 0;
71
144
  const rows = () => {
@@ -80,10 +153,11 @@ export function ContextPanel(props: { agent?: string }) {
80
153
  <button
81
154
  type="button"
82
155
  class="px-2 py-0.5 text-neutral-400 hover:text-neutral-100 border border-neutral-700 rounded"
83
- onClick={load}
84
- disabled={loading()}
156
+ onClick={refreshAll}
157
+ disabled={loading() || coverageLoading()}
158
+ title="Refresh context diagnostics"
85
159
  >
86
- {loading() ? '…' : 'refresh'}
160
+ {loading() || coverageLoading() ? '…' : 'refresh'}
87
161
  </button>
88
162
  </div>
89
163
 
@@ -91,6 +165,106 @@ export function ContextPanel(props: { agent?: string }) {
91
165
  <div class="text-rose-400">error: {err()}</div>
92
166
  </Show>
93
167
 
168
+ <Show when={coverageErr()}>
169
+ <div class="text-rose-400">coverage: {coverageErr()}</div>
170
+ </Show>
171
+
172
+ <Show when={coverage()?.supported}>
173
+ <section class="border-y border-neutral-800 py-3 space-y-2.5">
174
+ <div class="flex items-center justify-between">
175
+ <span class="text-neutral-300">Summary coverage</span>
176
+ <span class="text-neutral-600">
177
+ {coverage()!.queue.inFlight || coverage()!.queue.l1.length > 0 || coverage()!.queue.merges.length > 0
178
+ ? 'active'
179
+ : 'idle'}
180
+ </span>
181
+ </div>
182
+
183
+ <div class="grid grid-cols-3 gap-2 text-neutral-500">
184
+ <div>
185
+ <div class="text-base text-neutral-200">{coverage()!.totals.compressedChunks}/{coverage()!.totals.chunks}</div>
186
+ <div>chunks</div>
187
+ </div>
188
+ <div>
189
+ <div class="text-base text-neutral-200">{fmt(coverage()!.totals.coveredMessages)}</div>
190
+ <div>msgs covered</div>
191
+ </div>
192
+ <div>
193
+ <div class="text-base text-neutral-200">{fmt(coverage()!.totals.summaries)}</div>
194
+ <div>summaries</div>
195
+ </div>
196
+ </div>
197
+
198
+ <div class="space-y-1.5">
199
+ <div class="flex justify-between text-neutral-500"><span>available depth</span><span>oldest → newest</span></div>
200
+ <CoverageStrip chunks={coverage()!.chunks} field="available" />
201
+ <div class="flex justify-between text-neutral-500"><span>selected depth</span><span>{fmt(coverage()!.totals.coveredTokens)} tok covered</span></div>
202
+ <CoverageStrip chunks={coverage()!.chunks} field="selected" />
203
+ </div>
204
+
205
+ <div class="flex flex-wrap gap-x-2.5 gap-y-1 text-neutral-500">
206
+ <For each={[0, ...coverage()!.levels.map(level => level.level)]}>
207
+ {(level) => (
208
+ <span><span class="inline-block w-2 h-2 rounded-sm mr-1" style={{ background: levelColor(level) }} />L{level}</span>
209
+ )}
210
+ </For>
211
+ </div>
212
+
213
+ <table class="w-full">
214
+ <thead>
215
+ <tr class="text-neutral-500 text-left">
216
+ <th class="font-normal py-1">level</th>
217
+ <th class="font-normal text-right">frontier</th>
218
+ <th class="font-normal text-right">total</th>
219
+ <th class="font-normal text-right">coverage</th>
220
+ </tr>
221
+ </thead>
222
+ <tbody>
223
+ <For each={coverage()!.levels}>
224
+ {(level) => (
225
+ <tr class="border-t border-neutral-900">
226
+ <td class="py-1 text-neutral-200">L{level.level}</td>
227
+ <td class="text-right text-neutral-300">{level.frontier}</td>
228
+ <td class="text-right text-neutral-500">{level.summaries}</td>
229
+ <td class="text-right text-neutral-400">{fmt(level.coveredMessages)} msg</td>
230
+ </tr>
231
+ )}
232
+ </For>
233
+ </tbody>
234
+ </table>
235
+
236
+ <div class="border-t border-neutral-900 pt-2 space-y-1">
237
+ <div class="flex items-center justify-between">
238
+ <span class="text-neutral-400">Queue</span>
239
+ <span class={coverage()!.queue.inFlight ? 'text-amber-300' : 'text-neutral-600'}>
240
+ {coverage()!.queue.inFlight ? 'in flight' : 'waiting'}
241
+ </span>
242
+ </div>
243
+ <Show when={coverage()!.queue.pending}>
244
+ <div class="text-amber-200 truncate" title={coverage()!.queue.pending ?? ''}>{coverage()!.queue.pending}</div>
245
+ </Show>
246
+ <For each={coverage()!.queue.l1}>
247
+ {(index) => <div class="text-neutral-400">L1 · chunk {index}</div>}
248
+ </For>
249
+ <For each={coverage()!.queue.merges}>
250
+ {(merge) => (
251
+ <div class="text-neutral-400" title={`${merge.firstSource ?? ''} → ${merge.lastSource ?? ''}`}>
252
+ L{merge.targetLevel} · {merge.sourceCount} source summaries
253
+ </div>
254
+ )}
255
+ </For>
256
+ <Show when={!coverage()!.queue.inFlight && coverage()!.queue.l1.length === 0 && coverage()!.queue.merges.length === 0}>
257
+ <div class="text-neutral-600">No queued work</div>
258
+ </Show>
259
+ </div>
260
+
261
+ <div class="flex items-center justify-between gap-2 text-neutral-600">
262
+ <span class="min-w-0 truncate" title={coverage()!.branch}>{coverage()!.branch}</span>
263
+ <a class="shrink-0 text-cyan-500 hover:text-cyan-300" href="/curve" target="_blank" rel="noreferrer">curve ↗</a>
264
+ </div>
265
+ </section>
266
+ </Show>
267
+
94
268
  <Show when={data()?.stats} fallback={<Show when={!err()}><div class="text-neutral-500">loading…</div></Show>}>
95
269
  {/* headline totals */}
96
270
  <div class="flex gap-4">
@@ -156,3 +330,29 @@ export function ContextPanel(props: { agent?: string }) {
156
330
  </div>
157
331
  );
158
332
  }
333
+
334
+ function CoverageStrip(props: { chunks: CoverageChunk[]; field: 'available' | 'selected' }) {
335
+ const total = () => props.chunks.reduce((sum, chunk) => sum + Math.max(1, chunk.tokens), 0);
336
+ return (
337
+ <div class="flex h-4 w-full overflow-hidden rounded border border-neutral-800 bg-neutral-900">
338
+ <For each={props.chunks}>
339
+ {(chunk) => {
340
+ const level = () => props.field === 'available' ? chunk.maxLevel : chunk.selectedMax;
341
+ const title = () => {
342
+ const selected = chunk.selectedMin === chunk.selectedMax
343
+ ? `L${chunk.selectedMax}`
344
+ : `L${chunk.selectedMin}–L${chunk.selectedMax}`;
345
+ return `chunk ${chunk.index} · ${chunk.messages} msg · ${fmt(chunk.tokens)} tok · available L${chunk.maxLevel} · selected ${selected}${chunk.queued ? ' · queued' : ''}`;
346
+ };
347
+ return (
348
+ <div
349
+ class={`h-full min-w-px ${chunk.queued ? 'ring-1 ring-inset ring-amber-200' : ''}`}
350
+ style={{ width: `${(Math.max(1, chunk.tokens) / Math.max(1, total())) * 100}%`, background: levelColor(level()) }}
351
+ title={title()}
352
+ />
353
+ );
354
+ }}
355
+ </For>
356
+ </div>
357
+ );
358
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Full-screen gate shown when this device's observer key is not granted on
3
+ * this agent ('denied') or WebCrypto is unavailable ('unavailable' —
4
+ * insecure context). Shows the device fingerprint to hand to the agent /
5
+ * operator, and offers password sign-in as the fallback (navigating to
6
+ * /auth/basic triggers the browser's basic-auth prompt; with cached
7
+ * credentials the subsequent WS upgrade authenticates as a full client).
8
+ */
9
+ import { createResource, Show } from 'solid-js';
10
+ import { deviceFingerprint } from './observer-identity';
11
+
12
+ export function ObserverGateScreen(props: { state: 'denied' | 'unavailable' }) {
13
+ const [fingerprint] = createResource(deviceFingerprint);
14
+
15
+ const copy = (): void => {
16
+ const fp = fingerprint();
17
+ if (fp) void navigator.clipboard?.writeText(fp);
18
+ };
19
+
20
+ return (
21
+ <div class="fixed inset-0 z-50 bg-zinc-950/95 flex items-center justify-center p-6">
22
+ <div class="max-w-xl w-full border border-zinc-800 rounded-lg bg-zinc-900 p-6 space-y-4 text-sm text-zinc-300">
23
+ <Show
24
+ when={props.state === 'denied'}
25
+ fallback={
26
+ <>
27
+ <h2 class="text-base font-semibold text-zinc-100">Observer keys unavailable here</h2>
28
+ <p>
29
+ Device-key authentication needs a secure context (https or localhost) for WebCrypto.
30
+ This page was loaded over plain http, so only password sign-in is available.
31
+ </p>
32
+ </>
33
+ }
34
+ >
35
+ <h2 class="text-base font-semibold text-zinc-100">This device isn't authorized yet</h2>
36
+ <p>
37
+ This viewer shows an agent's interior — access is granted per device, by the agent or
38
+ its operator. Share this device's key fingerprint with them:
39
+ </p>
40
+ <Show
41
+ when={!fingerprint.loading && fingerprint() === null}
42
+ fallback={
43
+ <div class="flex items-center gap-2">
44
+ <code class="font-mono text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 break-all flex-1">
45
+ {fingerprint.loading ? 'generating…' : fingerprint()}
46
+ </code>
47
+ <button
48
+ class="px-2 py-1.5 text-xs rounded border border-zinc-700 hover:bg-zinc-800"
49
+ onClick={copy}
50
+ >
51
+ copy
52
+ </button>
53
+ </div>
54
+ }
55
+ >
56
+ <p class="text-amber-300/90">
57
+ This origin can't generate a device key (WebCrypto needs https or localhost) —
58
+ use password sign-in below, or open the agent's https URL.
59
+ </p>
60
+ </Show>
61
+ <p class="text-zinc-400">
62
+ Grant (agent tool or operator edit of <code class="font-mono">data/observers.json</code>):{' '}
63
+ <code class="font-mono text-xs">
64
+ observers--grant key=&lt;fingerprint&gt; label=&lt;who-you-are&gt; scopes=[health,…]
65
+ </code>
66
+ . Takes effect within ~3s — then{' '}
67
+ <button class="underline" onClick={() => location.reload()}>reload</button>.
68
+ </p>
69
+ </Show>
70
+ <div class="pt-2 border-t border-zinc-800">
71
+ <a class="underline text-zinc-400 hover:text-zinc-200" href="/auth/basic">
72
+ Sign in with password instead
73
+ </a>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ );
78
+ }
package/web/src/Usage.tsx CHANGED
@@ -14,7 +14,13 @@
14
14
  import { For, Show } from 'solid-js';
15
15
  import type { UiNode } from './tree';
16
16
  import { aggregateTokens } from './tree';
17
- import type { TokenUsage, PerAgentCost } from '@conhost/web/protocol';
17
+ import type {
18
+ TokenUsage,
19
+ PerAgentCost,
20
+ CallLedgerSnapshot,
21
+ CallLedgerRow,
22
+ CallLedgerVerdict,
23
+ } from '@conhost/web/protocol';
18
24
 
19
25
  export function UsagePanel(props: {
20
26
  node: UiNode;
@@ -24,6 +30,10 @@ export function UsagePanel(props: {
24
30
  /** Per-agent cost slice (parent-process agents only). Used to label
25
31
  * per-agent cost in process / per-agent breakdown views. */
26
32
  perAgentCost: PerAgentCost[];
33
+ /** Recent provider calls. The ledger is process-local, so it is shown on
34
+ * the process usage view rather than pretending it can be split across
35
+ * fleet children. */
36
+ callLedger: CallLedgerSnapshot | null;
27
37
  onClose(): void;
28
38
  }) {
29
39
  const costFor = (agentName: string): { total: number; currency: string } | undefined => {
@@ -70,7 +80,7 @@ export function UsagePanel(props: {
70
80
  };
71
81
 
72
82
  return (
73
- <div class="border-l border-neutral-800 w-96 shrink-0 bg-neutral-950 flex flex-col h-full">
83
+ <div class="border-l border-neutral-800 w-[52rem] max-w-[68vw] shrink-0 bg-neutral-950 flex flex-col h-full">
74
84
  <div class="border-b border-neutral-800 px-3 py-2 flex items-center gap-2">
75
85
  <span class="text-[10px] uppercase tracking-wider text-neutral-500 font-semibold">usage</span>
76
86
  <span class="font-mono text-sm text-neutral-200 truncate">{props.node.label}</span>
@@ -99,6 +109,8 @@ export function UsagePanel(props: {
99
109
  </Show>
100
110
 
101
111
  <Show when={props.node.kind === 'process'}>
112
+ <CallLedgerSection ledger={props.callLedger} />
113
+
102
114
  <section class="text-[10px] text-neutral-600 italic leading-snug">
103
115
  Session total comes from the membrane's per-call usage stream.
104
116
  "By node" is the per-agent ledger from the tree reducer; the two
@@ -110,6 +122,108 @@ export function UsagePanel(props: {
110
122
  );
111
123
  }
112
124
 
125
+ function CallLedgerSection(props: { ledger: CallLedgerSnapshot | null }) {
126
+ const rows = (): CallLedgerRow[] => [...(props.ledger?.rows ?? [])].slice(-30).reverse();
127
+ return (
128
+ <section>
129
+ <div class="flex items-baseline gap-2 mb-1">
130
+ <div class="text-neutral-500 uppercase tracking-wider text-[10px]">recent calls · cache ledger</div>
131
+ <Show when={props.ledger}>
132
+ {(ledger) => (
133
+ <span class="ml-auto text-[10px] text-neutral-500">
134
+ ${fmtCost(ledger().summary.cost?.total ?? 0)} retained · {Math.round(ledger().summary.cacheHitRatio * 100)}% cached
135
+ <Show when={(ledger().summary.cost?.unpricedCalls ?? 0) > 0}>
136
+ {' '}· <span class="text-amber-400">{ledger().summary.cost!.unpricedCalls} unpriced</span>
137
+ </Show>
138
+ </span>
139
+ )}
140
+ </Show>
141
+ </div>
142
+
143
+ <Show
144
+ when={rows().length > 0}
145
+ fallback={<div class="border border-neutral-800 rounded px-2 py-2 text-neutral-600">No provider calls recorded yet.</div>}
146
+ >
147
+ <div class="border border-neutral-800 rounded overflow-hidden">
148
+ <div class="grid grid-cols-[4.7rem_3.5rem_3rem_4rem_4rem_4rem_4rem_4.7rem_minmax(8rem,1fr)] gap-x-2 px-2 py-1 bg-neutral-900 text-[9px] uppercase tracking-wider text-neutral-600">
149
+ <span>time</span><span>origin</span><span>msgs</span><span class="text-right">in</span>
150
+ <span class="text-right">read</span><span class="text-right">write</span><span class="text-right">out</span>
151
+ <span class="text-right">cost</span><span>verdict / cause</span>
152
+ </div>
153
+ <div class="max-h-80 overflow-y-auto divide-y divide-neutral-900">
154
+ <For each={rows()}>{(row) => <CallLedgerRowView row={row} />}</For>
155
+ </div>
156
+ </div>
157
+ <div class="mt-1 text-[9px] text-neutral-600">
158
+ Costs marked in USD use provider-reported token buckets and the versioned public rate card. Calls missing an exact cache-write split or known rate stay explicitly unpriced. origin~ remains an estimated call class.
159
+ </div>
160
+ </Show>
161
+ </section>
162
+ );
163
+ }
164
+
165
+ function CallLedgerRowView(props: { row: CallLedgerRow }) {
166
+ const time = (): string => {
167
+ const d = new Date(props.row.timestamp);
168
+ return Number.isNaN(d.getTime()) ? '?' : d.toLocaleTimeString([], { hour12: false });
169
+ };
170
+ const flags = (): string => {
171
+ const bp = props.row.cache.breakpoints;
172
+ if (bp === undefined) return 'flags unknown';
173
+ if (bp === 0) return 'no cache flags';
174
+ return `${bp}bp:${props.row.cache.effectiveTtl}`;
175
+ };
176
+ const costTitle = (): string => {
177
+ const c = props.row.cost;
178
+ if (!c) return 'Unpriced: an authoritative usage bucket or public rate was unavailable';
179
+ return [
180
+ `billing-grade ${c.currency} · ${c.pricingVersion}`,
181
+ `input $${c.input.toFixed(6)}`,
182
+ `read $${c.cacheRead.toFixed(6)}`,
183
+ `write 5m $${c.cacheWrite5m.toFixed(6)}`,
184
+ `write 1h $${c.cacheWrite1h.toFixed(6)}`,
185
+ `output $${c.output.toFixed(6)}`,
186
+ ].join(' · ');
187
+ };
188
+ return (
189
+ <div class="grid grid-cols-[4.7rem_3.5rem_3rem_4rem_4rem_4rem_4rem_4.7rem_minmax(8rem,1fr)] gap-x-2 px-2 py-1.5 items-start hover:bg-neutral-900/60">
190
+ <span class="text-neutral-400" title={`${props.row.timestamp} · ${Math.round(props.row.durationMs / 1000)}s`}>{time()}</span>
191
+ <span class="text-neutral-500">{props.row.originEstimate}</span>
192
+ <span class="text-neutral-400">{props.row.messages}</span>
193
+ <span class="text-right text-neutral-400">{fmt(props.row.tokens.input)}</span>
194
+ <span class="text-right text-sky-300">{fmt(props.row.tokens.cacheRead)}</span>
195
+ <span class="text-right text-amber-300">{fmt(props.row.tokens.cacheWrite)}</span>
196
+ <span class="text-right text-neutral-400">{fmt(props.row.tokens.output)}</span>
197
+ <span
198
+ class={`text-right ${props.row.cost ? 'text-emerald-300' : 'text-amber-500'}`}
199
+ title={costTitle()}
200
+ >
201
+ {props.row.cost ? `$${fmtCost(props.row.cost.total)}` : 'unpriced'}
202
+ </span>
203
+ <span class="min-w-0">
204
+ <span class={`font-semibold ${verdictColor(props.row.verdict)}`}>{props.row.verdict}</span>
205
+ <span class="ml-1 text-[9px] text-neutral-600">{flags()}</span>
206
+ <span class="block text-[9px] leading-snug text-neutral-500 break-words">{props.row.cause}</span>
207
+ </span>
208
+ </div>
209
+ );
210
+ }
211
+
212
+ function verdictColor(verdict: CallLedgerVerdict): string {
213
+ switch (verdict) {
214
+ case 'HIT': return 'text-emerald-300';
215
+ case 'hit+extend': return 'text-lime-300';
216
+ case 'rewrite:expired': return 'text-orange-300';
217
+ case 'rewrite:prefix-mutated':
218
+ case 'rewrite:prefix-truncated': return 'text-red-300';
219
+ case 'rewrite:unexplained': return 'text-violet-300';
220
+ case 'ERROR': return 'text-red-400';
221
+ case 'first-write': return 'text-slate-300';
222
+ case 'uncached': return 'text-stone-400';
223
+ default: return 'text-neutral-400';
224
+ }
225
+ }
226
+
113
227
  function TotalsBlock(props: { totals: { input: number; output: number; cacheRead: number; cacheWrite: number; cost?: { total: number; currency: string } } }) {
114
228
  const t = props.totals;
115
229
  return (