@animalabs/connectome-host 0.3.1

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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,283 @@
1
+ import { For, Show } from 'solid-js';
2
+ import type { AgentNode } from '@conhost/state/agent-tree-reducer';
3
+ import type { UiNode } from './tree';
4
+ import { aggregateTokens, flattenUiTree } from './tree';
5
+
6
+ export interface TreeSidebarProps {
7
+ roots: UiNode[];
8
+ /** Currently-selected node id (UiNode.id), if any. */
9
+ selectedId: string | null;
10
+ /** Set of node ids that are expanded; render contains caret state. */
11
+ expanded: Set<string>;
12
+ onToggleExpand(id: string): void;
13
+ /** Open / focus the stream view for this node. */
14
+ onSelectStream(node: UiNode): void;
15
+ /** Open / focus the usage view for this node. */
16
+ onSelectUsage(node: UiNode): void;
17
+ /** Cancel an in-process subagent by display name. If the subagent lives
18
+ * inside a fleet child, pass its name as `childName` so the server can
19
+ * forward the cancel to that child's runtime. */
20
+ onCancelSubagent(name: string, childName?: string): void;
21
+ /** Stop a fleet child gracefully. */
22
+ onFleetStop(name: string): void;
23
+ /** Restart a fleet child. */
24
+ onFleetRestart(name: string): void;
25
+ }
26
+
27
+ export function TreeSidebar(props: TreeSidebarProps) {
28
+ return (
29
+ <div class="h-full overflow-y-auto px-2 py-2 text-xs">
30
+ <Show
31
+ when={props.roots.length > 0}
32
+ fallback={<div class="text-neutral-600 italic px-2">No agents registered yet.</div>}
33
+ >
34
+ <div class="space-y-0.5">
35
+ <For each={flattenUiTree(props.roots, props.expanded)}>
36
+ {(item) => (
37
+ <NodeRow
38
+ node={item.node}
39
+ depth={item.depth}
40
+ expanded={props.expanded.has(item.node.id)}
41
+ selected={props.selectedId === item.node.id}
42
+ onToggleExpand={() => props.onToggleExpand(item.node.id)}
43
+ onSelectStream={() => props.onSelectStream(item.node)}
44
+ onSelectUsage={() => props.onSelectUsage(item.node)}
45
+ onCancelSubagent={props.onCancelSubagent}
46
+ onFleetStop={props.onFleetStop}
47
+ onFleetRestart={props.onFleetRestart}
48
+ />
49
+ )}
50
+ </For>
51
+ </div>
52
+ </Show>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ function NodeRow(props: {
58
+ node: UiNode;
59
+ depth: number;
60
+ expanded: boolean;
61
+ selected: boolean;
62
+ onToggleExpand(): void;
63
+ onSelectStream(): void;
64
+ onSelectUsage(): void;
65
+ onCancelSubagent(name: string, childName?: string): void;
66
+ onFleetStop(name: string): void;
67
+ onFleetRestart(name: string): void;
68
+ }) {
69
+ const hasChildren = (): boolean => props.node.children.length > 0;
70
+ const canStream = (): boolean => props.node.streamSource.kind !== 'none';
71
+
72
+ const isLiveSubagent = (): boolean =>
73
+ props.node.kind === 'subagent' && props.node.agent?.status === 'running';
74
+
75
+ // Subagents inside a fleet child carry `child-event-agent` as their stream
76
+ // source; that's where the owning fleet child's name lives.
77
+ const owningFleetChild = (): string | undefined =>
78
+ props.node.streamSource.kind === 'child-event-agent'
79
+ ? props.node.streamSource.childName
80
+ : undefined;
81
+
82
+ const stopHandler = (): (() => void) | null => {
83
+ if (isLiveSubagent()) return () => props.onCancelSubagent(props.node.label, owningFleetChild());
84
+ if (props.node.kind === 'fleet-child' && props.node.fleetChildName)
85
+ return () => props.onFleetStop(props.node.fleetChildName!);
86
+ return null;
87
+ };
88
+
89
+ const restartHandler = (): (() => void) | null => {
90
+ if (props.node.kind === 'fleet-child' && props.node.fleetChildName)
91
+ return () => props.onFleetRestart(props.node.fleetChildName!);
92
+ return null;
93
+ };
94
+
95
+ const selectedClass = (): string => props.selected ? 'bg-cyan-900/30' : 'hover:bg-neutral-900/40';
96
+
97
+ const onLabelClick = (): void => {
98
+ if (canStream()) props.onSelectStream();
99
+ else if (hasChildren()) props.onToggleExpand();
100
+ };
101
+
102
+ return (
103
+ <div
104
+ class={`flex items-center gap-1 py-0.5 pr-1 rounded ${selectedClass()}`}
105
+ style={{ 'padding-left': `${0.25 + props.depth * 0.75}rem` }}
106
+ >
107
+ {/* Expand caret. Always present so column widths line up; invisible for leaves. */}
108
+ <button
109
+ type="button"
110
+ class={`w-4 text-center text-neutral-500 hover:text-neutral-200 ${hasChildren() ? '' : 'opacity-0 pointer-events-none'}`}
111
+ onClick={props.onToggleExpand}
112
+ title={props.expanded ? 'Collapse' : 'Expand'}
113
+ >
114
+ {props.expanded ? '▾' : '▸'}
115
+ </button>
116
+
117
+ {/* Label region — clicking opens the stream view. The token badge inside
118
+ * has its own onClick that stops propagation so it can target usage. */}
119
+ <div
120
+ role="button"
121
+ tabIndex={0}
122
+ class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer"
123
+ onClick={onLabelClick}
124
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onLabelClick(); }}
125
+ >
126
+ <NodeLabel node={props.node} onUsageClick={() => props.onSelectUsage()} />
127
+ </div>
128
+
129
+ <NodeActions
130
+ node={props.node}
131
+ canStream={canStream()}
132
+ onSelectStream={props.onSelectStream}
133
+ onStop={stopHandler()}
134
+ onRestart={restartHandler()}
135
+ />
136
+ </div>
137
+ );
138
+ }
139
+
140
+ function NodeLabel(props: { node: UiNode; onUsageClick: () => void }) {
141
+ const n = props.node;
142
+ if (n.kind === 'process') {
143
+ const agg = aggregateTokens(n);
144
+ return (
145
+ <>
146
+ <span class="text-neutral-600">▣</span>
147
+ <span class="font-mono text-neutral-200 truncate">{n.label}</span>
148
+ <span class="text-[10px] text-neutral-600 uppercase tracking-wider">parent</span>
149
+ <span class="ml-auto text-neutral-500 text-[10px] font-mono whitespace-nowrap">
150
+ <UsageBadge label={`${fmtTokens(agg.output)}out`} hidden={agg.output === 0} onClick={props.onUsageClick} title="Show session usage" />
151
+ </span>
152
+ </>
153
+ );
154
+ }
155
+ if (n.kind === 'fleet-child') {
156
+ const agg = aggregateTokens(n);
157
+ return (
158
+ <>
159
+ <span class="text-cyan-400">▢</span>
160
+ <span class="font-mono text-neutral-200 truncate">{n.label}</span>
161
+ <span class="text-[10px] text-cyan-500 uppercase tracking-wider">child</span>
162
+ <span class="ml-auto text-neutral-500 text-[10px] font-mono whitespace-nowrap">
163
+ <UsageBadge label={`${fmtTokens(agg.output)}out`} hidden={agg.output === 0} onClick={props.onUsageClick} title="Show child usage" />
164
+ </span>
165
+ </>
166
+ );
167
+ }
168
+ // framework / subagent
169
+ const a = n.agent!;
170
+ return (
171
+ <>
172
+ <span class="font-mono text-neutral-300 truncate">{n.label}</span>
173
+ <span class={`px-1 rounded text-[10px] ${phaseColor(a.phase)}`}>
174
+ {a.phase}
175
+ </span>
176
+ <Show when={n.kind === 'subagent'}>
177
+ <span class="text-neutral-600 text-[10px]">sub</span>
178
+ </Show>
179
+ <span class="ml-auto flex items-center gap-1 text-neutral-500 text-[10px] font-mono whitespace-nowrap">
180
+ <UsageBadge
181
+ label={`${fmtTokens(a.tokens.input)}cx`}
182
+ hidden={a.tokens.input === 0}
183
+ onClick={props.onUsageClick}
184
+ title="Show agent usage"
185
+ />
186
+ <Show when={a.toolCallsCount > 0}>
187
+ {/* Use × instead of · to keep the count from reading as a negative
188
+ ("·11" at this font size is easy to misparse as "-11"). */}
189
+ <span title="tool calls">×{a.toolCallsCount}</span>
190
+ </Show>
191
+ </span>
192
+ </>
193
+ );
194
+ }
195
+
196
+ function UsageBadge(props: { label: string; hidden: boolean; onClick: () => void; title: string }) {
197
+ return (
198
+ <Show when={!props.hidden}>
199
+ <span
200
+ role="button"
201
+ tabIndex={0}
202
+ class="px-1 rounded cursor-pointer hover:text-cyan-300 hover:bg-cyan-900/20"
203
+ title={props.title}
204
+ onClick={(e) => { e.stopPropagation(); props.onClick(); }}
205
+ onKeyDown={(e) => {
206
+ if (e.key === 'Enter' || e.key === ' ') {
207
+ e.stopPropagation();
208
+ e.preventDefault();
209
+ props.onClick();
210
+ }
211
+ }}
212
+ >
213
+ {props.label}
214
+ </span>
215
+ </Show>
216
+ );
217
+ }
218
+
219
+ function NodeActions(props: {
220
+ node: UiNode;
221
+ canStream: boolean;
222
+ onSelectStream: () => void;
223
+ onStop: (() => void) | null;
224
+ onRestart: (() => void) | null;
225
+ }) {
226
+ return (
227
+ <>
228
+ <Show when={props.canStream}>
229
+ <button
230
+ type="button"
231
+ class="text-[10px] px-1 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
232
+ onClick={(e) => { e.stopPropagation(); props.onSelectStream(); }}
233
+ title="Show this node's live stream"
234
+ >
235
+ stream
236
+ </button>
237
+ </Show>
238
+ <Show when={props.onStop}>
239
+ <button
240
+ type="button"
241
+ class="text-[10px] px-1 py-0.5 bg-rose-900/40 hover:bg-rose-900/60 text-rose-200 rounded font-mono"
242
+ onClick={(e) => { e.stopPropagation(); props.onStop?.(); }}
243
+ title="Stop"
244
+ >
245
+ stop
246
+ </button>
247
+ </Show>
248
+ <Show when={props.onRestart}>
249
+ <button
250
+ type="button"
251
+ class="text-[10px] px-1 py-0.5 bg-cyan-900/40 hover:bg-cyan-900/60 text-cyan-200 rounded font-mono"
252
+ onClick={(e) => { e.stopPropagation(); props.onRestart?.(); }}
253
+ title="Restart"
254
+ >
255
+
256
+ </button>
257
+ </Show>
258
+ </>
259
+ );
260
+ }
261
+
262
+ function phaseColor(phase: AgentNode['phase']): string {
263
+ switch (phase) {
264
+ case 'streaming': return 'bg-cyan-500/30 text-cyan-200';
265
+ case 'sending': return 'bg-amber-500/30 text-amber-200';
266
+ case 'invoking': return 'bg-fuchsia-500/30 text-fuchsia-200';
267
+ case 'executing': return 'bg-amber-500/30 text-amber-200';
268
+ case 'done': return 'bg-neutral-700 text-neutral-400';
269
+ case 'failed': return 'bg-rose-500/40 text-rose-200';
270
+ // Postmortem 2026-05-28 P1 #3: 'cancelled' is terminal-but-benign.
271
+ // A slightly warmer dim than 'done' so the operator can spot which
272
+ // children ended on a cancel vs. a successful turn, without the
273
+ // false-alarm RED of 'failed'.
274
+ case 'cancelled': return 'bg-neutral-700 text-amber-300/70';
275
+ default: return 'bg-neutral-800 text-neutral-400';
276
+ }
277
+ }
278
+
279
+ function fmtTokens(n: number): string {
280
+ if (n < 1000) return String(n);
281
+ if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
282
+ return (n / 1_000_000).toFixed(1) + 'M';
283
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Usage panel — token-spend breakdown for the focused tree node.
3
+ *
4
+ * Strategy by node kind:
5
+ * - 'process' (parent root): show session-wide UsageMessage as the
6
+ * authoritative total, plus a breakdown of direct children.
7
+ * - 'fleet-child' folder: aggregate over its agents, plus per-agent rows.
8
+ * - 'framework' / 'subagent' (leaf-ish): show that one agent's numbers.
9
+ *
10
+ * The panel is intentionally read-only — there's no per-node billing knob to
11
+ * twiddle here yet. Most operators want the same answer: "where's the burn?"
12
+ */
13
+
14
+ import { For, Show } from 'solid-js';
15
+ import type { UiNode } from './tree';
16
+ import { aggregateTokens } from './tree';
17
+ import type { TokenUsage, PerAgentCost } from '@conhost/web/protocol';
18
+
19
+ export function UsagePanel(props: {
20
+ node: UiNode;
21
+ /** Session-wide cumulative usage from welcome/usage messages. Only
22
+ * consulted when the focused node is the parent process. */
23
+ sessionUsage: TokenUsage;
24
+ /** Per-agent cost slice (parent-process agents only). Used to label
25
+ * per-agent cost in process / per-agent breakdown views. */
26
+ perAgentCost: PerAgentCost[];
27
+ onClose(): void;
28
+ }) {
29
+ const costFor = (agentName: string): { total: number; currency: string } | undefined => {
30
+ return props.perAgentCost.find(c => c.name === agentName)?.cost;
31
+ };
32
+ const totals = (): { input: number; output: number; cacheRead: number; cacheWrite: number; cost?: { total: number; currency: string } } => {
33
+ if (props.node.kind === 'process') {
34
+ return {
35
+ input: props.sessionUsage.input,
36
+ output: props.sessionUsage.output,
37
+ cacheRead: props.sessionUsage.cacheRead,
38
+ cacheWrite: props.sessionUsage.cacheWrite,
39
+ cost: props.sessionUsage.cost,
40
+ };
41
+ }
42
+ const t = aggregateTokens(props.node);
43
+ // For an agent-leaf, attach the matching per-agent cost so the panel
44
+ // shows $X.XX next to its tokens. Aggregates (fleet-child folders) do
45
+ // not show cost — the framework's UsageTracker is per-process and we
46
+ // don't aggregate cross-process costs here.
47
+ if (props.node.agent) {
48
+ const c = costFor(props.node.label);
49
+ return c ? { ...t, cost: c } : t;
50
+ }
51
+ return t;
52
+ };
53
+
54
+ const breakdown = (): UiNode[] => {
55
+ // Parent process and fleet-child folders break down by direct children;
56
+ // leaf agent nodes don't (they ARE the breakdown).
57
+ if (props.node.kind === 'process' || props.node.kind === 'fleet-child') {
58
+ return props.node.children;
59
+ }
60
+ return [];
61
+ };
62
+
63
+ const headline = (): string => {
64
+ switch (props.node.kind) {
65
+ case 'process': return 'session total';
66
+ case 'fleet-child': return `child: ${props.node.label}`;
67
+ case 'framework': return `agent: ${props.node.label}`;
68
+ case 'subagent': return `subagent: ${props.node.label}`;
69
+ }
70
+ };
71
+
72
+ return (
73
+ <div class="border-l border-neutral-800 w-96 shrink-0 bg-neutral-950 flex flex-col h-full">
74
+ <div class="border-b border-neutral-800 px-3 py-2 flex items-center gap-2">
75
+ <span class="text-[10px] uppercase tracking-wider text-neutral-500 font-semibold">usage</span>
76
+ <span class="font-mono text-sm text-neutral-200 truncate">{props.node.label}</span>
77
+ <button
78
+ type="button"
79
+ class="ml-auto px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded text-xs"
80
+ onClick={() => props.onClose()}
81
+ >
82
+ close
83
+ </button>
84
+ </div>
85
+
86
+ <div class="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] space-y-3">
87
+ <section>
88
+ <div class="text-neutral-500 uppercase tracking-wider text-[10px] mb-1">{headline()}</div>
89
+ <TotalsBlock totals={totals()} />
90
+ </section>
91
+
92
+ <Show when={breakdown().length > 0}>
93
+ <section>
94
+ <div class="text-neutral-500 uppercase tracking-wider text-[10px] mb-1">by node</div>
95
+ <div class="space-y-1">
96
+ <For each={breakdown()}>{(child) => <BreakdownRow node={child} perAgentCost={props.perAgentCost} />}</For>
97
+ </div>
98
+ </section>
99
+ </Show>
100
+
101
+ <Show when={props.node.kind === 'process'}>
102
+ <section class="text-[10px] text-neutral-600 italic leading-snug">
103
+ Session total comes from the membrane's per-call usage stream.
104
+ "By node" is the per-agent ledger from the tree reducer; the two
105
+ should converge but small drift is normal during streaming.
106
+ </section>
107
+ </Show>
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function TotalsBlock(props: { totals: { input: number; output: number; cacheRead: number; cacheWrite: number; cost?: { total: number; currency: string } } }) {
114
+ const t = props.totals;
115
+ return (
116
+ <div class="grid grid-cols-2 gap-x-3 gap-y-0.5">
117
+ <div class="text-neutral-500">input</div>
118
+ <div class="text-neutral-200 text-right">{fmt(t.input)}</div>
119
+ <div class="text-neutral-500">output</div>
120
+ <div class="text-neutral-200 text-right">{fmt(t.output)}</div>
121
+ <Show when={t.cacheRead > 0 || t.cacheWrite > 0}>
122
+ <div class="text-neutral-500">cache read</div>
123
+ <div class="text-neutral-200 text-right">{fmt(t.cacheRead)}</div>
124
+ <div class="text-neutral-500">cache write</div>
125
+ <div class="text-neutral-200 text-right">{fmt(t.cacheWrite)}</div>
126
+ </Show>
127
+ <Show when={t.cost && t.cost.total > 0}>
128
+ <div class="text-neutral-500 mt-1">cost</div>
129
+ <div class="text-emerald-300 text-right mt-1" title={t.cost!.currency}>
130
+ ${fmtCost(t.cost!.total)}
131
+ </div>
132
+ </Show>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ function BreakdownRow(props: { node: UiNode; perAgentCost: PerAgentCost[] }) {
138
+ const agg = aggregateTokens(props.node);
139
+ const cost = (): { total: number; currency: string } | undefined => {
140
+ if (!props.node.agent) return undefined;
141
+ return props.perAgentCost.find(c => c.name === props.node.label)?.cost;
142
+ };
143
+ const kindHint = (): string => {
144
+ switch (props.node.kind) {
145
+ case 'fleet-child': return 'child';
146
+ case 'subagent': return 'sub';
147
+ case 'framework': return 'agent';
148
+ default: return '';
149
+ }
150
+ };
151
+ return (
152
+ <div class="border-l-2 border-neutral-800 pl-2 py-0.5">
153
+ <div class="flex items-baseline gap-2">
154
+ <span class="font-mono text-neutral-300 truncate">{props.node.label}</span>
155
+ <span class="text-[10px] text-neutral-600 uppercase tracking-wider">{kindHint()}</span>
156
+ <Show when={cost() && cost()!.total > 0}>
157
+ <span class="ml-auto text-emerald-300 text-[10px]" title={cost()!.currency}>
158
+ ${fmtCost(cost()!.total)}
159
+ </span>
160
+ </Show>
161
+ </div>
162
+ <div class="flex gap-3 text-neutral-500 text-[10px]">
163
+ <span>in {fmt(agg.input)}</span>
164
+ <span>out {fmt(agg.output)}</span>
165
+ <Show when={agg.cacheRead > 0}><span>cR {fmt(agg.cacheRead)}</span></Show>
166
+ <Show when={agg.cacheWrite > 0}><span>cW {fmt(agg.cacheWrite)}</span></Show>
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function fmt(n: number): string {
173
+ if (n < 1000) return String(n);
174
+ if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
175
+ return (n / 1_000_000).toFixed(2) + 'M';
176
+ }
177
+
178
+ /** Format a cost figure: 4 decimals when sub-dollar (so cents are visible),
179
+ * 2 otherwise — matches the TUI's /usage formatter. */
180
+ function fmtCost(total: number): string {
181
+ return total.toFixed(total < 1 ? 4 : 2);
182
+ }
@@ -0,0 +1,7 @@
1
+ import { render } from 'solid-js/web';
2
+ import { App } from './App';
3
+ import './styles.css';
4
+
5
+ const root = document.getElementById('root');
6
+ if (!root) throw new Error('#root not found');
7
+ render(() => <App />, root);
@@ -0,0 +1,26 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Sane base for monospace blocks. */
4
+ pre, code, kbd, samp { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
5
+
6
+ /* Fade in messages as they arrive — keeps streaming feeling alive. */
7
+ .msg-enter { animation: msg-enter 120ms ease-out; }
8
+ @keyframes msg-enter {
9
+ from { opacity: 0; transform: translateY(2px); }
10
+ to { opacity: 1; transform: translateY(0); }
11
+ }
12
+
13
+ /* Markdown defaults — enough for assistant prose without a plugin. */
14
+ .prose-mini { line-height: 1.55; }
15
+ .prose-mini p { margin: 0.4em 0; }
16
+ .prose-mini h1, .prose-mini h2, .prose-mini h3 { font-weight: 600; margin: 0.6em 0 0.3em; }
17
+ .prose-mini h1 { font-size: 1.25em; }
18
+ .prose-mini h2 { font-size: 1.15em; }
19
+ .prose-mini h3 { font-size: 1.05em; }
20
+ .prose-mini ul, .prose-mini ol { margin: 0.4em 0; padding-left: 1.5em; }
21
+ .prose-mini li { margin: 0.15em 0; }
22
+ .prose-mini code { background: rgba(255,255,255,0.08); padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.9em; }
23
+ .prose-mini pre { background: rgba(255,255,255,0.04); padding: 0.6em 0.8em; border-radius: 5px; overflow-x: auto; margin: 0.5em 0; }
24
+ .prose-mini pre code { background: transparent; padding: 0; }
25
+ .prose-mini a { color: rgb(96 165 250); text-decoration: underline; }
26
+ .prose-mini blockquote { border-left: 2px solid rgba(255,255,255,0.2); padding-left: 0.8em; margin: 0.4em 0; color: rgba(255,255,255,0.7); }