@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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- 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
|
+
}
|
package/web/src/main.tsx
ADDED
|
@@ -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); }
|