@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
@@ -41,6 +41,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
41
41
  import type { Recipe } from '../recipe.js';
42
42
  import type { SessionManager } from '../session-manager.js';
43
43
  import type { BranchState } from '../commands.js';
44
+ import type { CallLedger } from '../call-ledger.js';
44
45
  import { handleCommand } from '../commands.js';
45
46
  import { AgentTreeReducer, type AgentTreeSnapshot } from '../state/agent-tree-reducer.js';
46
47
  import { FleetTreeAggregator } from '../state/fleet-tree-aggregator.js';
@@ -54,6 +55,7 @@ import {
54
55
  type WelcomeMessageEntry,
55
56
  type RequestHistoryMessage,
56
57
  type TokenUsage,
58
+ type CallLedgerSnapshot,
57
59
  type McplListMessage,
58
60
  type LessonsListMessage,
59
61
  } from '../web/protocol.js';
@@ -63,6 +65,16 @@ import {
63
65
  DEFAULT_CONFIG_PATH,
64
66
  } from '../mcpl-config.js';
65
67
  import { loadRecipe } from '../recipe.js';
68
+ import {
69
+ ObserverRegistry,
70
+ ObserverSessions,
71
+ sessionTokenFromRequest,
72
+ traceRequiredScope,
73
+ filterEntryForScopes,
74
+ scopeWelcome,
75
+ type ObserverScope,
76
+ type ObserverHelloIdentity,
77
+ } from './web-ui-observers.js';
66
78
 
67
79
  /**
68
80
  * Minimal slice of AppContext the module needs. Defined locally to avoid
@@ -108,15 +120,46 @@ export interface WebUiModuleConfig {
108
120
  * the entire host is firewalled off from browsers.
109
121
  */
110
122
  allowedOrigins?: string[];
123
+ /**
124
+ * Path to the observer grant file (data/observers.json — see connectome
125
+ * docs/observability.md). When set AND the file holds at least one grant,
126
+ * key-authenticated observers may connect: static assets are served
127
+ * without basic auth (the app shell carries no data), /ws upgrades are
128
+ * accepted unauthenticated and must present a signed observer-hello
129
+ * before anything is sent, and every frame is filtered by the grant's
130
+ * scope mask. With no grants the webui behaves exactly as before.
131
+ */
132
+ observersPath?: string;
133
+ /** Content-free recent provider-call ledger for spend/cache diagnostics. */
134
+ callLedger?: CallLedger;
135
+ }
136
+
137
+ /** Data stashed on the Bun WS upgrade. */
138
+ interface WsData {
139
+ id: number;
140
+ /** True when the upgrade carried valid basic auth (full access). */
141
+ authed: boolean;
142
+ /** Host header of the upgrade request — the observer statement binds it. */
143
+ host: string;
111
144
  }
112
145
 
113
146
  /** Per-connection state. */
114
147
  interface ClientState {
115
148
  /** Stable id matching ws.data.id; used for routing fleet IPC responses. */
116
149
  id: number;
117
- ws: ServerWebSocket<{ id: number }>;
150
+ ws: ServerWebSocket<WsData>;
118
151
  /** True after we've sent the welcome message. */
119
152
  welcomed: boolean;
153
+ /**
154
+ * Authorization state. 'full' = basic-auth (or open loopback) — behavior
155
+ * identical to pre-observer builds, scopes null. 'pending' = upgraded
156
+ * without auth, must observer-hello before any data flows. 'observer' =
157
+ * key-authenticated; `scopes` is the grant's mask.
158
+ */
159
+ auth: 'full' | 'pending' | 'observer';
160
+ scopes: Set<ObserverScope> | null;
161
+ /** Kills pending connections that never complete the hello. */
162
+ authTimer?: ReturnType<typeof setTimeout>;
120
163
  /** Open peek subscriptions for this client, keyed by scope. Each entry
121
164
  * carries its detacher so unsubscribe and disconnect both clean up
122
165
  * without the framework leaking listeners. */
@@ -132,6 +175,161 @@ const WELCOME_HISTORY_LIMIT = 200;
132
175
  const HISTORY_PAGE_DEFAULT = 200;
133
176
  const HISTORY_PAGE_MAX = 500;
134
177
 
178
+ interface CoverageSummary {
179
+ id: string;
180
+ level: number;
181
+ tokens?: number;
182
+ sourceIds?: string[];
183
+ mergedInto?: string;
184
+ }
185
+
186
+ interface CoverageChunk {
187
+ index: number;
188
+ tokens?: number;
189
+ compressed?: boolean;
190
+ summaryId?: string;
191
+ messages?: Array<{ id?: string }>;
192
+ }
193
+
194
+ interface CoverageStrategy {
195
+ summaries?: CoverageSummary[];
196
+ chunks?: CoverageChunk[];
197
+ compressionQueue?: number[];
198
+ mergeQueue?: Array<{ level: number; sourceIds: string[] }>;
199
+ resolutions?: Map<string, number>;
200
+ pendingCompression?: Promise<void> | null;
201
+ }
202
+
203
+ export interface ContextCoverageSnapshot {
204
+ agent: string;
205
+ branch: string;
206
+ generatedAt: string;
207
+ supported: boolean;
208
+ totals: {
209
+ chunks: number;
210
+ compressedChunks: number;
211
+ coveredMessages: number;
212
+ coveredTokens: number;
213
+ summaries: number;
214
+ };
215
+ levels: Array<{
216
+ level: number;
217
+ summaries: number;
218
+ frontier: number;
219
+ tokens: number;
220
+ coveredChunks: number;
221
+ coveredMessages: number;
222
+ coveredTokens: number;
223
+ }>;
224
+ chunks: Array<{
225
+ index: number;
226
+ messages: number;
227
+ tokens: number;
228
+ compressed: boolean;
229
+ summaryId: string | null;
230
+ maxLevel: number;
231
+ selectedMin: number;
232
+ selectedMax: number;
233
+ queued: boolean;
234
+ }>;
235
+ queue: {
236
+ inFlight: boolean;
237
+ pending: string | null;
238
+ l1: number[];
239
+ merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
240
+ };
241
+ }
242
+
243
+ /** Build a text-free projection of the autobiographical summary pyramid. */
244
+ export function buildContextCoverageSnapshot(
245
+ agentName: string,
246
+ cm: {
247
+ currentBranch: () => { name: string };
248
+ getStrategy: () => unknown;
249
+ getPendingWork?: () => { description?: string } | null;
250
+ },
251
+ ): ContextCoverageSnapshot {
252
+ const strategy = cm.getStrategy() as CoverageStrategy;
253
+ const summaries = Array.isArray(strategy.summaries) ? strategy.summaries : [];
254
+ const chunks = Array.isArray(strategy.chunks) ? strategy.chunks : [];
255
+ const compressionQueue = Array.isArray(strategy.compressionQueue) ? strategy.compressionQueue : [];
256
+ const mergeQueue = Array.isArray(strategy.mergeQueue) ? strategy.mergeQueue : [];
257
+ const resolutions = strategy.resolutions instanceof Map ? strategy.resolutions : new Map<string, number>();
258
+ const summaryById = new Map(summaries.map(summary => [summary.id, summary]));
259
+ const queuedChunks = new Set(compressionQueue);
260
+
261
+ const projectedChunks = chunks.map((chunk) => {
262
+ let maxLevel = 0;
263
+ let current = chunk.summaryId ? summaryById.get(chunk.summaryId) : undefined;
264
+ const seen = new Set<string>();
265
+ while (current && !seen.has(current.id)) {
266
+ seen.add(current.id);
267
+ maxLevel = Math.max(maxLevel, current.level);
268
+ current = current.mergedInto ? summaryById.get(current.mergedInto) : undefined;
269
+ }
270
+
271
+ const selected = (chunk.messages ?? [])
272
+ .map(message => typeof message.id === 'string' ? (resolutions.get(message.id) ?? 0) : 0);
273
+ return {
274
+ index: chunk.index,
275
+ messages: chunk.messages?.length ?? 0,
276
+ tokens: Math.max(0, chunk.tokens ?? 0),
277
+ compressed: chunk.compressed === true,
278
+ summaryId: chunk.summaryId ?? null,
279
+ maxLevel,
280
+ selectedMin: selected.length > 0 ? Math.min(...selected) : 0,
281
+ selectedMax: selected.length > 0 ? Math.max(...selected) : 0,
282
+ queued: queuedChunks.has(chunk.index),
283
+ };
284
+ });
285
+
286
+ const levelNumbers = [...new Set(summaries.map(summary => summary.level))]
287
+ .filter(level => Number.isFinite(level) && level > 0)
288
+ .sort((a, b) => a - b);
289
+ const levels = levelNumbers.map((level) => {
290
+ const atLevel = summaries.filter(summary => summary.level === level);
291
+ const covered = projectedChunks.filter(chunk => chunk.maxLevel >= level);
292
+ return {
293
+ level,
294
+ summaries: atLevel.length,
295
+ frontier: atLevel.filter(summary => !summary.mergedInto).length,
296
+ tokens: atLevel.reduce((total, summary) => total + Math.max(0, summary.tokens ?? 0), 0),
297
+ coveredChunks: covered.length,
298
+ coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
299
+ coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
300
+ };
301
+ });
302
+ const covered = projectedChunks.filter(chunk => chunk.maxLevel > 0);
303
+ const pending = cm.getPendingWork?.()?.description ?? null;
304
+
305
+ return {
306
+ agent: agentName,
307
+ branch: cm.currentBranch().name,
308
+ generatedAt: new Date().toISOString(),
309
+ supported: Array.isArray(strategy.summaries) && Array.isArray(strategy.chunks),
310
+ totals: {
311
+ chunks: projectedChunks.length,
312
+ compressedChunks: projectedChunks.filter(chunk => chunk.compressed).length,
313
+ coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
314
+ coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
315
+ summaries: summaries.length,
316
+ },
317
+ levels,
318
+ chunks: projectedChunks,
319
+ queue: {
320
+ inFlight: strategy.pendingCompression != null,
321
+ pending,
322
+ l1: [...compressionQueue],
323
+ merges: mergeQueue.map(merge => ({
324
+ targetLevel: merge.level,
325
+ sourceCount: merge.sourceIds.length,
326
+ firstSource: merge.sourceIds[0] ?? null,
327
+ lastSource: merge.sourceIds[merge.sourceIds.length - 1] ?? null,
328
+ })),
329
+ },
330
+ };
331
+ }
332
+
135
333
  /**
136
334
  * Structural view of the windowed-read facade added to
137
335
  * @animalabs/context-manager alongside this protocol version. Typed
@@ -174,6 +372,10 @@ interface SharedServerState {
174
372
  allowedOrigins: string[];
175
373
  clients: Map<number, ClientState>;
176
374
  nextClientId: number;
375
+ /** Observer grant registry (hot-reloaded) — null when not configured. */
376
+ observers: ObserverRegistry | null;
377
+ /** Short-lived HTTP session tokens minted after observer WS auth. */
378
+ observerSessions: ObserverSessions;
177
379
  /** Aggregate session usage published to clients: parent's own totals plus
178
380
  * the most-recent `usage:updated` totals reported by each fleet child.
179
381
  * Recomputed from `parentUsage` + `childUsage` on every relevant event. */
@@ -191,6 +393,9 @@ interface SharedServerState {
191
393
  * every usage:updated event so the welcome and live UsageMessage frames
192
394
  * carry consistent values. */
193
395
  latestPerAgentCost: import('../web/protocol.js').PerAgentCost[];
396
+ /** Recent per-call cache diagnostics, updated directly by the adapter. */
397
+ latestCallLedger?: CallLedgerSnapshot;
398
+ callLedgerDetacher: (() => void) | null;
194
399
  /** Currently-bound app, refreshed on every setApp() call. WS handlers read
195
400
  * from here so the singleton always points at the live framework regardless
196
401
  * of which WebUiModule instance is "active". */
@@ -260,10 +465,14 @@ export class WebUiModule implements Module {
260
465
  allowedOrigins: this.config.allowedOrigins ?? defaultAllowedOrigins(port),
261
466
  clients: new Map(),
262
467
  nextClientId: 1,
468
+ observers: this.config.observersPath ? new ObserverRegistry(this.config.observersPath) : null,
469
+ observerSessions: new ObserverSessions(),
263
470
  latestUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
264
471
  parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
265
472
  childUsage: new Map(),
266
473
  latestPerAgentCost: [],
474
+ latestCallLedger: this.config.callLedger?.snapshot(),
475
+ callLedgerDetacher: null,
267
476
  pendingFleetRequests: new Map(),
268
477
  childRecipeCache: new Map(),
269
478
  app: null,
@@ -271,14 +480,15 @@ export class WebUiModule implements Module {
271
480
  fleetEventDetacher: null,
272
481
  messageListenerDetacher: null,
273
482
  };
483
+ state.observers?.start();
274
484
  state.server = Bun.serve({
275
485
  port,
276
486
  hostname: host,
277
487
  fetch: (req, server) => this.handleHttp(req, server),
278
488
  websocket: {
279
- open: (ws) => this.onWsOpen(ws as ServerWebSocket<{ id: number }>),
280
- message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<{ id: number }>, msg),
281
- close: (ws) => this.onWsClose(ws as ServerWebSocket<{ id: number }>),
489
+ open: (ws) => this.onWsOpen(ws as ServerWebSocket<WsData>),
490
+ message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<WsData>, msg),
491
+ close: (ws) => this.onWsClose(ws as ServerWebSocket<WsData>),
282
492
  },
283
493
  });
284
494
  // When port=0 is passed (test setups, ephemeral binds), the OS picks a
@@ -294,6 +504,19 @@ export class WebUiModule implements Module {
294
504
  }
295
505
  sharedServer = state;
296
506
 
507
+ // Provider calls include auxiliary compression requests that never emit a
508
+ // framework usage trace, so subscribe at the adapter ledger itself. This
509
+ // keeps the panel live for both conversational and memory-maintenance
510
+ // calls without polling the JSONL log.
511
+ state.callLedgerDetacher = this.config.callLedger?.onUpdate((ledger) => {
512
+ state.latestCallLedger = ledger;
513
+ const msg: WebUiServerMessage = { type: 'call-ledger', ledger };
514
+ for (const client of state.clients.values()) {
515
+ if (!client.welcomed) continue;
516
+ if (client.scopes === null || client.scopes.has('health')) this.send(client, msg);
517
+ }
518
+ }) ?? null;
519
+
297
520
  console.log(`[webui] listening on http://${host}:${boundPort}`);
298
521
  }
299
522
 
@@ -352,7 +575,10 @@ export class WebUiModule implements Module {
352
575
  const entry = toWireEntry(ev.message as MessageLike, cm0.getMessageCount() - 1);
353
576
  const push: WebUiServerMessage = { type: 'message-appended', entry };
354
577
  for (const c of ss.clients.values()) {
355
- if (c.welcomed) this.send(c, push);
578
+ if (!c.welcomed) continue;
579
+ if (c.scopes === null) { this.send(c, push); continue; }
580
+ const filtered = filterEntryForScopes(entry, c.scopes);
581
+ if (filtered) this.send(c, { type: 'message-appended', entry: filtered });
356
582
  }
357
583
  } catch (err) {
358
584
  // Never let a viewer-side projection error break the store's
@@ -577,6 +803,17 @@ export class WebUiModule implements Module {
577
803
  }
578
804
  }
579
805
 
806
+ // -------------------------------------------------------------------------
807
+ // Observer scope filtering — event-family masks (docs/observability.md §4).
808
+ // Full clients (scopes === null) bypass everything: historical behavior.
809
+ // Pure projections live in web-ui-observers.ts (unit-tested there).
810
+ // -------------------------------------------------------------------------
811
+
812
+ private clientAllowsTrace(client: ClientState, event: { type: string }): boolean {
813
+ if (client.scopes === null) return true;
814
+ return client.scopes.has(traceRequiredScope(event));
815
+ }
816
+
580
817
  private fanOutTrace(event: TraceEvent): void {
581
818
  // Update cached usage snapshot first so welcomes for late-connecting
582
819
  // clients get a current value.
@@ -615,8 +852,10 @@ export class WebUiModule implements Module {
615
852
  : null;
616
853
  for (const client of sharedServer!.clients.values()) {
617
854
  if (!client.welcomed) continue;
618
- this.send(client, traceMsg);
619
- if (usageMsg) this.send(client, usageMsg);
855
+ if (this.clientAllowsTrace(client, event)) this.send(client, traceMsg);
856
+ if (usageMsg && (client.scopes === null || client.scopes.has('health'))) {
857
+ this.send(client, usageMsg);
858
+ }
620
859
  }
621
860
  }
622
861
 
@@ -733,6 +972,10 @@ export class WebUiModule implements Module {
733
972
  private async handleHttp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
734
973
  const url = new URL(req.url);
735
974
 
975
+ // Observer feature gate: live only when grants exist. With no grants
976
+ // every path below reduces to the historical basic-auth-only behavior.
977
+ const observersActive = sharedServer!.observers?.active() ?? false;
978
+
736
979
  // WebSocket upgrade
737
980
  if (url.pathname === '/ws') {
738
981
  // Origin check FIRST — drive-by CSRF on a localhost-bound WS is the
@@ -740,15 +983,61 @@ export class WebUiModule implements Module {
740
983
  // `new WebSocket(...)` the way they do on fetch, so without an
741
984
  // explicit check, any tab the operator opens could connect here.
742
985
  if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
743
- if (!this.checkAuth(req)) return this.unauthorized();
986
+ const basicOk = this.checkAuth(req);
987
+ // Without basic auth the upgrade is allowed only when observer grants
988
+ // exist — and then NOTHING is sent until a signed observer-hello
989
+ // verifies (in-band auth; see onWsOpen/onWsMessage).
990
+ if (!basicOk && !observersActive) return this.unauthorized();
744
991
  const id = sharedServer!.nextClientId++;
745
- const ok = server.upgrade(req, { data: { id } });
992
+ const ok = server.upgrade(req, {
993
+ data: { id, authed: basicOk, host: req.headers.get('host') ?? '' } satisfies WsData,
994
+ });
746
995
  if (!ok) return new Response('Upgrade failed', { status: 400 });
747
996
  // Bun returns undefined on success; the response is taken over by the upgrade.
748
997
  return new Response(null, { status: 101 });
749
998
  }
750
999
 
751
- if (!this.checkAuth(req)) return this.unauthorized();
1000
+ // HTTP auth: basic auth grants everything (historical behavior); an
1001
+ // observer session cookie (minted after WS key auth) grants by scope;
1002
+ // when observers are active the static app shell is public — it carries
1003
+ // no data, and key-only devices must be able to load the SPA to
1004
+ // authenticate at all.
1005
+ const basicOk = this.checkAuth(req);
1006
+ const sessionScopes = basicOk
1007
+ ? null
1008
+ : sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
1009
+ const httpAllowed = (scope: ObserverScope): boolean =>
1010
+ basicOk || (sessionScopes?.has(scope) ?? false);
1011
+ // /auth/basic: deliberate basic-auth challenge point. fetch() never
1012
+ // triggers the browser's native credential prompt, but a top-level
1013
+ // navigation here does — the SPA's "sign in with password" fallback for
1014
+ // devices without an observer grant. Once credentials are cached for
1015
+ // the realm, the WS upgrade carries them and the client is 'full'.
1016
+ if (url.pathname === '/auth/basic') {
1017
+ if (basicOk) return new Response(null, { status: 302, headers: { location: '/' } });
1018
+ return this.unauthorized();
1019
+ }
1020
+ const isStatic = !url.pathname.startsWith('/debug/')
1021
+ && url.pathname !== '/curve'
1022
+ && url.pathname !== '/healthz'
1023
+ && !url.pathname.startsWith('/files/');
1024
+ if (!basicOk && !(observersActive && isStatic) && !sessionScopes) {
1025
+ return this.unauthorized();
1026
+ }
1027
+
1028
+ // Per-route scope gates for observer sessions (basic auth passes all).
1029
+ if ((url.pathname.startsWith('/debug/') || url.pathname === '/curve') && !httpAllowed('debug')) {
1030
+ return this.unauthorized();
1031
+ }
1032
+ if (url.pathname === '/healthz' && !httpAllowed('health')) {
1033
+ return this.unauthorized();
1034
+ }
1035
+ if (url.pathname.startsWith('/files/') && !basicOk) {
1036
+ // Workspace files stay basic-auth-only: mount contents are outside the
1037
+ // observer scope model (they are the agent's working files, not wire
1038
+ // events). Revisit if a 'files' scope is ever warranted.
1039
+ return this.unauthorized();
1040
+ }
752
1041
 
753
1042
  // Debug: the membrane-normalized request that WOULD be emitted if the
754
1043
  // agent were activated right now — no inference, no state mutation.
@@ -763,6 +1052,12 @@ export class WebUiModule implements Module {
763
1052
  if (url.pathname === '/debug/context/curve') {
764
1053
  return this.handleContextCurve(url);
765
1054
  }
1055
+ if (url.pathname === '/debug/context/coverage') {
1056
+ return this.handleContextCoverage(url);
1057
+ }
1058
+ if (url.pathname === '/debug/context/maintenance') {
1059
+ return this.handleContextMaintenance();
1060
+ }
766
1061
  if (url.pathname === '/curve') {
767
1062
  return new Response(CURVE_PAGE_HTML, {
768
1063
  headers: { 'content-type': 'text/html; charset=utf-8' },
@@ -798,6 +1093,39 @@ export class WebUiModule implements Module {
798
1093
  return this.serveStatic(requested);
799
1094
  }
800
1095
 
1096
+ /**
1097
+ * Counts-only state and bounded history for periodic context maintenance.
1098
+ * Authentication is enforced by handleHttp before this method is reached.
1099
+ * The framework snapshot deliberately contains no message or summary text.
1100
+ */
1101
+ private handleContextMaintenance(): Response {
1102
+ const app = sharedServer?.app;
1103
+ if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1104
+ const framework = app.framework as unknown as {
1105
+ getContextMaintenanceSnapshot?: () => Record<string, unknown>;
1106
+ };
1107
+ if (typeof framework.getContextMaintenanceSnapshot !== 'function') {
1108
+ return Response.json(
1109
+ { error: 'framework lacks context-maintenance diagnostics' },
1110
+ { status: 501 },
1111
+ );
1112
+ }
1113
+ return Response.json(framework.getContextMaintenanceSnapshot());
1114
+ }
1115
+
1116
+ /** Summary-tree coverage and queued work, with no message or summary text. */
1117
+ private handleContextCoverage(url: URL): Response {
1118
+ const app = sharedServer?.app;
1119
+ if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1120
+ const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1121
+ const agent = app.framework.getAgent(agentName);
1122
+ if (!agent) {
1123
+ return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
1124
+ }
1125
+ const cm = agent.getContextManager();
1126
+ return Response.json(buildContextCoverageSnapshot(agentName, cm));
1127
+ }
1128
+
801
1129
  /**
802
1130
  * Debug endpoint: return the membrane-normalized request the framework would
803
1131
  * hand to the model if the agent were activated right now. Delegates to
@@ -1116,16 +1444,60 @@ export class WebUiModule implements Module {
1116
1444
  // WebSocket lifecycle
1117
1445
  // -------------------------------------------------------------------------
1118
1446
 
1119
- private onWsOpen(ws: ServerWebSocket<{ id: number }>): void {
1447
+ private onWsOpen(ws: ServerWebSocket<WsData>): void {
1120
1448
  const id = ws.data.id;
1121
- const client: ClientState = { id, ws, welcomed: false, peeks: new Map() };
1449
+ const client: ClientState = {
1450
+ id, ws, welcomed: false, peeks: new Map(),
1451
+ auth: ws.data.authed ? 'full' : 'pending',
1452
+ scopes: null,
1453
+ };
1122
1454
  sharedServer!.clients.set(id, client);
1123
1455
 
1456
+ if (client.auth === 'pending') {
1457
+ // In-band observer auth: tell the client what host its statement must
1458
+ // bind, and give it a bounded window to present a verifiable hello.
1459
+ // No data flows on this connection until then.
1460
+ this.send(client, { type: 'observer-auth-required', host: ws.data.host });
1461
+ client.authTimer = setTimeout(() => {
1462
+ if (client.auth === 'pending') ws.close(4401, 'observer auth timeout');
1463
+ }, 15_000);
1464
+ return;
1465
+ }
1466
+
1124
1467
  if (sharedServer?.app) void this.sendWelcome(client);
1125
1468
  // Else: park until setApp() flushes welcomes.
1126
1469
  }
1127
1470
 
1128
- private onWsMessage(ws: ServerWebSocket<{ id: number }>, raw: string | Buffer): void {
1471
+ private handleObserverHello(client: ClientState, identity: ObserverHelloIdentity): void {
1472
+ const registry = sharedServer!.observers;
1473
+ const result = registry?.verifyHello(identity, client.ws.data.host) ?? null;
1474
+ if (!result) {
1475
+ this.send(client, { type: 'error', message: 'observer auth failed' });
1476
+ client.ws.close(4401, 'observer auth failed');
1477
+ return;
1478
+ }
1479
+ if (client.authTimer) clearTimeout(client.authTimer);
1480
+ client.auth = 'observer';
1481
+ client.scopes = result.scopes;
1482
+ this.send(client, {
1483
+ type: 'observer-ack',
1484
+ scopes: [...result.scopes],
1485
+ sessionToken: sharedServer!.observerSessions.mint(result.scopes),
1486
+ label: result.grant.label,
1487
+ });
1488
+ console.error(`[webui-observers] observer connected: ${result.grant.label} scopes=[${[...result.scopes].join(',')}]`);
1489
+ if (sharedServer?.app) void this.sendWelcome(client);
1490
+ }
1491
+
1492
+ /** Client message types a scoped (non-full) observer may send. */
1493
+ private observerMaySend(client: ClientState, type: string): boolean {
1494
+ if (client.auth !== 'observer') return false;
1495
+ if (type === 'ping') return true;
1496
+ if (type === 'request-history') return client.scopes?.has('messages') ?? false;
1497
+ return false; // observers are read-only: no user-message/command/mcpl/fleet
1498
+ }
1499
+
1500
+ private onWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
1129
1501
  const id = ws.data.id;
1130
1502
  const client = sharedServer!.clients.get(id);
1131
1503
  if (!client) return;
@@ -1142,6 +1514,20 @@ export class WebUiModule implements Module {
1142
1514
  return;
1143
1515
  }
1144
1516
 
1517
+ // Observer auth interlock: a pending connection may ONLY hello; a
1518
+ // key-authenticated observer is read-only within its scopes. Full
1519
+ // (basic-auth) clients skip both gates — historical behavior.
1520
+ if (parsed.type === 'observer-hello') {
1521
+ if (client.auth === 'pending') this.handleObserverHello(client, parsed.identity);
1522
+ // hello on an already-authenticated connection is a no-op
1523
+ return;
1524
+ }
1525
+ if (client.auth === 'pending') return; // nothing else before auth
1526
+ if (client.auth === 'observer' && !this.observerMaySend(client, parsed.type)) {
1527
+ this.send(client, { type: 'error', message: `forbidden for observer scope (${parsed.type})` });
1528
+ return;
1529
+ }
1530
+
1145
1531
  if (!sharedServer?.app) {
1146
1532
  this.send(client, { type: 'error', message: 'host not ready' });
1147
1533
  return;
@@ -1825,10 +2211,11 @@ export class WebUiModule implements Module {
1825
2211
  }
1826
2212
  }
1827
2213
 
1828
- private onWsClose(ws: ServerWebSocket<{ id: number }>): void {
2214
+ private onWsClose(ws: ServerWebSocket<WsData>): void {
1829
2215
  const id = ws.data.id;
1830
2216
  const client = sharedServer?.clients.get(id);
1831
2217
  if (client) {
2218
+ if (client.authTimer) clearTimeout(client.authTimer);
1832
2219
  for (const detach of client.peeks.values()) {
1833
2220
  try { detach(); } catch { /* ignore */ }
1834
2221
  }
@@ -1969,9 +2356,12 @@ export class WebUiModule implements Module {
1969
2356
 
1970
2357
  private async sendWelcome(client: ClientState): Promise<void> {
1971
2358
  if (!sharedServer?.app) return;
2359
+ // Never send data to a connection that hasn't authenticated. Parked
2360
+ // pending connections get their welcome from handleObserverHello.
2361
+ if (client.auth === 'pending') return;
1972
2362
 
1973
2363
  const welcome = await this.buildWelcome();
1974
- this.send(client, welcome);
2364
+ this.send(client, client.scopes === null ? welcome : scopeWelcome(welcome, client.scopes));
1975
2365
  client.welcomed = true;
1976
2366
  // Live trace forwarding is driven by the single fan-out listener
1977
2367
  // installed in setApp(); membership is implicit in `sharedServer!.clients`.
@@ -2003,10 +2393,16 @@ export class WebUiModule implements Module {
2003
2393
  resolveBlobs: false,
2004
2394
  alignToBodyGroups: true,
2005
2395
  });
2396
+ let entries = coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex);
2397
+ if (client.scopes !== null) {
2398
+ entries = entries
2399
+ .map((e) => filterEntryForScopes(e, client.scopes!))
2400
+ .filter((e): e is WelcomeMessageEntry => e !== null);
2401
+ }
2006
2402
  this.send(client, {
2007
2403
  type: 'history-page',
2008
2404
  corrId: req.corrId,
2009
- entries: coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex),
2405
+ entries,
2010
2406
  startIndex: win.startIndex,
2011
2407
  totalCount: win.totalCount,
2012
2408
  });
@@ -2112,6 +2508,9 @@ export class WebUiModule implements Module {
2112
2508
  ...(sharedServer!.latestPerAgentCost.length > 0
2113
2509
  ? { perAgentCost: sharedServer!.latestPerAgentCost }
2114
2510
  : {}),
2511
+ ...(sharedServer!.latestCallLedger
2512
+ ? { callLedger: sharedServer!.latestCallLedger }
2513
+ : {}),
2115
2514
  };
2116
2515
  }
2117
2516