@animalabs/connectome-host 0.7.2 → 0.7.4

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 (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. package/web/vite.config.ts +8 -2
@@ -21,6 +21,8 @@
21
21
  */
22
22
 
23
23
  import { CURVE_PAGE_HTML } from './web-ui-curve-page.js';
24
+ import { RETRIEVAL_TRACE_PAGE_HTML } from './retrieval-trace-page.js';
25
+ import type { RetrievalTraceSource } from './retrieval-trace.js';
24
26
  import type {
25
27
  AgentFramework,
26
28
  Module,
@@ -68,6 +70,28 @@ import {
68
70
  saveMcplServers,
69
71
  DEFAULT_CONFIG_PATH,
70
72
  } from '../mcpl-config.js';
73
+ import {
74
+ resolveAgent,
75
+ buildMcplSnapshot,
76
+ buildSettingsState,
77
+ buildPinsSnapshot,
78
+ buildHealthSnapshot,
79
+ buildContextCoverage,
80
+ buildContextMakeup,
81
+ buildContextCurve,
82
+ buildContextMaintenance,
83
+ runContextPreview,
84
+ buildDebugContext,
85
+ notifyAgentOfSettingsChange,
86
+ applySettingsUpdate,
87
+ applySettingsReset,
88
+ applySettingsCancelTransition,
89
+ applyPinAdd,
90
+ applyPinRemove,
91
+ PanelError,
92
+ type PanelAppRef,
93
+ type McplLiveServer,
94
+ } from '../web/panel-data.js';
71
95
  import { loadRecipe } from '../recipe.js';
72
96
  import {
73
97
  ObserverRegistry,
@@ -173,166 +197,59 @@ interface ClientState {
173
197
  /** Default port — picked to be memorable and unlikely to collide. */
174
198
  const DEFAULT_PORT = 7340;
175
199
 
176
- /** Messages shipped in the welcome frame (the tail window). */
177
- const WELCOME_HISTORY_LIMIT = 200;
178
- /** Default / max page size for request-history. */
179
- const HISTORY_PAGE_DEFAULT = 200;
180
- const HISTORY_PAGE_MAX = 500;
181
-
182
- interface CoverageSummary {
183
- id: string;
184
- level: number;
185
- tokens?: number;
186
- sourceIds?: string[];
187
- mergedInto?: string;
200
+ /** HTTP route panel op, for ?scope=<child> proxying. /curve (the HTML
201
+ * page) is deliberately absent: it is served locally and its own fetch of
202
+ * /debug/context/curve carries the scope param through. */
203
+ const HTTP_PANEL_OPS: Record<string, string> = {
204
+ '/debug/context/makeup': 'context-makeup',
205
+ '/debug/context/coverage': 'context-coverage',
206
+ '/debug/context/curve': 'context-curve',
207
+ '/debug/context/preview': 'context-preview',
208
+ '/debug/context/maintenance': 'context-maintenance',
209
+ '/debug/context': 'debug-context',
210
+ '/healthz': 'health',
211
+ };
212
+
213
+ /** True when a wire `scope` field names a fleet child (vs the local process). */
214
+ function isChildScope(scope: string | undefined): scope is string {
215
+ return scope !== undefined && scope !== '' && scope !== 'local';
188
216
  }
189
217
 
190
- interface CoverageChunk {
191
- index: number;
192
- tokens?: number;
193
- compressed?: boolean;
194
- summaryId?: string;
195
- messages?: Array<{ id?: string }>;
218
+ /** Map a panel-layer failure onto the HTTP response it deserves. */
219
+ function panelErrorResponse(err: unknown): Response {
220
+ const status = err instanceof PanelError ? err.status : 500;
221
+ return Response.json(
222
+ { error: err instanceof Error ? err.message : String(err) },
223
+ { status },
224
+ );
196
225
  }
197
226
 
198
- interface CoverageStrategy {
199
- summaries?: CoverageSummary[];
200
- chunks?: CoverageChunk[];
201
- compressionQueue?: number[];
202
- mergeQueue?: Array<{ level: number; sourceIds: string[] }>;
203
- resolutions?: Map<string, number>;
204
- pendingCompression?: Promise<void> | null;
227
+ /** Project the debug-route query string into panel-op params. Number/boolean
228
+ * coercion happens here so the child-side handlers see typed values. */
229
+ function panelParamsFromUrl(url: URL): Record<string, unknown> {
230
+ const params: Record<string, unknown> = {};
231
+ const agent = url.searchParams.get('agent');
232
+ if (agent) params.agent = agent;
233
+ const budget = url.searchParams.get('budget');
234
+ if (budget !== null) params.budget = Number(budget);
235
+ const tail = url.searchParams.get('tail');
236
+ if (tail !== null) params.tail = Number(tail);
237
+ const render = url.searchParams.get('render');
238
+ if (render !== null && render !== '0' && render !== 'false') params.render = true;
239
+ const inj = url.searchParams.get('injections');
240
+ if (inj !== null && inj !== '0' && inj !== 'false') params.injections = true;
241
+ return params;
205
242
  }
206
243
 
207
- export interface ContextCoverageSnapshot {
208
- agent: string;
209
- branch: string;
210
- generatedAt: string;
211
- supported: boolean;
212
- totals: {
213
- chunks: number;
214
- compressedChunks: number;
215
- coveredMessages: number;
216
- coveredTokens: number;
217
- summaries: number;
218
- };
219
- levels: Array<{
220
- level: number;
221
- summaries: number;
222
- frontier: number;
223
- tokens: number;
224
- coveredChunks: number;
225
- coveredMessages: number;
226
- coveredTokens: number;
227
- }>;
228
- chunks: Array<{
229
- index: number;
230
- messages: number;
231
- tokens: number;
232
- compressed: boolean;
233
- summaryId: string | null;
234
- maxLevel: number;
235
- selectedMin: number;
236
- selectedMax: number;
237
- queued: boolean;
238
- }>;
239
- queue: {
240
- inFlight: boolean;
241
- pending: string | null;
242
- l1: number[];
243
- merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
244
- };
245
- }
244
+ /** Messages shipped in the welcome frame (the tail window). */
245
+ const WELCOME_HISTORY_LIMIT = 200;
246
+ /** Default / max page size for request-history. */
247
+ const HISTORY_PAGE_DEFAULT = 200;
248
+ const HISTORY_PAGE_MAX = 500;
246
249
 
247
- /** Build a text-free projection of the autobiographical summary pyramid. */
248
- export function buildContextCoverageSnapshot(
249
- agentName: string,
250
- cm: {
251
- currentBranch: () => { name: string };
252
- getStrategy: () => unknown;
253
- getPendingWork?: () => { description?: string } | null;
254
- },
255
- ): ContextCoverageSnapshot {
256
- const strategy = cm.getStrategy() as CoverageStrategy;
257
- const summaries = Array.isArray(strategy.summaries) ? strategy.summaries : [];
258
- const chunks = Array.isArray(strategy.chunks) ? strategy.chunks : [];
259
- const compressionQueue = Array.isArray(strategy.compressionQueue) ? strategy.compressionQueue : [];
260
- const mergeQueue = Array.isArray(strategy.mergeQueue) ? strategy.mergeQueue : [];
261
- const resolutions = strategy.resolutions instanceof Map ? strategy.resolutions : new Map<string, number>();
262
- const summaryById = new Map(summaries.map(summary => [summary.id, summary]));
263
- const queuedChunks = new Set(compressionQueue);
264
-
265
- const projectedChunks = chunks.map((chunk) => {
266
- let maxLevel = 0;
267
- let current = chunk.summaryId ? summaryById.get(chunk.summaryId) : undefined;
268
- const seen = new Set<string>();
269
- while (current && !seen.has(current.id)) {
270
- seen.add(current.id);
271
- maxLevel = Math.max(maxLevel, current.level);
272
- current = current.mergedInto ? summaryById.get(current.mergedInto) : undefined;
273
- }
274
-
275
- const selected = (chunk.messages ?? [])
276
- .map(message => typeof message.id === 'string' ? (resolutions.get(message.id) ?? 0) : 0);
277
- return {
278
- index: chunk.index,
279
- messages: chunk.messages?.length ?? 0,
280
- tokens: Math.max(0, chunk.tokens ?? 0),
281
- compressed: chunk.compressed === true,
282
- summaryId: chunk.summaryId ?? null,
283
- maxLevel,
284
- selectedMin: selected.length > 0 ? Math.min(...selected) : 0,
285
- selectedMax: selected.length > 0 ? Math.max(...selected) : 0,
286
- queued: queuedChunks.has(chunk.index),
287
- };
288
- });
289
-
290
- const levelNumbers = [...new Set(summaries.map(summary => summary.level))]
291
- .filter(level => Number.isFinite(level) && level > 0)
292
- .sort((a, b) => a - b);
293
- const levels = levelNumbers.map((level) => {
294
- const atLevel = summaries.filter(summary => summary.level === level);
295
- const covered = projectedChunks.filter(chunk => chunk.maxLevel >= level);
296
- return {
297
- level,
298
- summaries: atLevel.length,
299
- frontier: atLevel.filter(summary => !summary.mergedInto).length,
300
- tokens: atLevel.reduce((total, summary) => total + Math.max(0, summary.tokens ?? 0), 0),
301
- coveredChunks: covered.length,
302
- coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
303
- coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
304
- };
305
- });
306
- const covered = projectedChunks.filter(chunk => chunk.maxLevel > 0);
307
- const pending = cm.getPendingWork?.()?.description ?? null;
308
-
309
- return {
310
- agent: agentName,
311
- branch: cm.currentBranch().name,
312
- generatedAt: new Date().toISOString(),
313
- supported: Array.isArray(strategy.summaries) && Array.isArray(strategy.chunks),
314
- totals: {
315
- chunks: projectedChunks.length,
316
- compressedChunks: projectedChunks.filter(chunk => chunk.compressed).length,
317
- coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
318
- coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
319
- summaries: summaries.length,
320
- },
321
- levels,
322
- chunks: projectedChunks,
323
- queue: {
324
- inFlight: strategy.pendingCompression != null,
325
- pending,
326
- l1: [...compressionQueue],
327
- merges: mergeQueue.map(merge => ({
328
- targetLevel: merge.level,
329
- sourceCount: merge.sourceIds.length,
330
- firstSource: merge.sourceIds[0] ?? null,
331
- lastSource: merge.sourceIds[merge.sourceIds.length - 1] ?? null,
332
- })),
333
- },
334
- };
335
- }
250
+ /** Re-exported from the shared panel-data layer (moved there so headless
251
+ * fleet children serve the same snapshot over the fleet IPC). */
252
+ export { buildContextCoverageSnapshot, type ContextCoverageSnapshot } from '../web/panel-data.js';
336
253
 
337
254
  /**
338
255
  * Structural view of the windowed-read facade added to
@@ -679,10 +596,15 @@ export class WebUiModule implements Module {
679
596
  || eType === 'workspace-file-snapshot'
680
597
  || eType === 'cancel-subagent-result')
681
598
  ) {
682
- this.routeChildSnapshotResponse(eType, corrId, event as Record<string, unknown>);
599
+ this.routeChildSnapshotResponse(eType, corrId, childName, event as Record<string, unknown>);
683
600
  return; // don't fan out — these are private replies, not telemetry
684
601
  }
685
602
 
603
+ // Panel-op replies are consumed by FleetModule.requestPanel's own
604
+ // corrId listener; they carry operator-panel payloads (settings, pins,
605
+ // health) and must never fan out as child-event telemetry.
606
+ if (eType === 'panel-response') return;
607
+
686
608
  // Roll up fleet-child session usage so the header total reflects every
687
609
  // process the operator is paying for, not just the parent. Children emit
688
610
  // their own `usage:updated` events; we cache the last `totals` for each
@@ -740,6 +662,7 @@ export class WebUiModule implements Module {
740
662
  private routeChildSnapshotResponse(
741
663
  eType: string,
742
664
  corrId: string,
665
+ childName: string,
743
666
  event: Record<string, unknown>,
744
667
  ): void {
745
668
  if (!sharedServer) return;
@@ -752,6 +675,7 @@ export class WebUiModule implements Module {
752
675
  if (eType === 'lessons-snapshot') {
753
676
  this.send(client, {
754
677
  type: 'lessons-list',
678
+ scope: childName,
755
679
  loaded: Boolean(event.loaded),
756
680
  lessons: (event.lessons as LessonsListMessage['lessons']) ?? [],
757
681
  });
@@ -760,6 +684,7 @@ export class WebUiModule implements Module {
760
684
  if (eType === 'workspace-mounts-snapshot') {
761
685
  this.send(client, {
762
686
  type: 'workspace-mounts',
687
+ scope: childName,
763
688
  loaded: Boolean(event.loaded),
764
689
  mounts: (event.mounts as Array<{ name: string; path: string; mode: string }>) ?? [],
765
690
  });
@@ -768,6 +693,7 @@ export class WebUiModule implements Module {
768
693
  if (eType === 'workspace-tree-snapshot') {
769
694
  this.send(client, {
770
695
  type: 'workspace-tree',
696
+ scope: childName,
771
697
  mount: String(event.mount ?? ''),
772
698
  entries: (event.entries as Array<{ path: string; size: number }>) ?? [],
773
699
  });
@@ -781,6 +707,7 @@ export class WebUiModule implements Module {
781
707
  }
782
708
  this.send(client, {
783
709
  type: 'workspace-file',
710
+ scope: childName,
784
711
  path: String(event.path ?? ''),
785
712
  totalLines: Number(event.totalLines ?? 0),
786
713
  fromLine: Number(event.fromLine ?? 1),
@@ -975,6 +902,8 @@ export class WebUiModule implements Module {
975
902
 
976
903
  private async handleHttp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
977
904
  const url = new URL(req.url);
905
+ const isRetrievalTraceRoute = url.pathname === '/debug/retrieval'
906
+ || url.pathname === '/debug/retrieval/view';
978
907
 
979
908
  // Observer feature gate: live only when grants exist. With no grants
980
909
  // every path below reduces to the historical basic-auth-only behavior.
@@ -1014,6 +943,7 @@ export class WebUiModule implements Module {
1014
943
  // authenticate at all.
1015
944
  const session = sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
1016
945
  const basicOk = this.checkAuth(req) || (session?.full ?? false);
946
+ const operatorAuthenticated = this.config.basicAuth !== undefined && basicOk;
1017
947
  const sessionScopes = basicOk ? null : session?.scopes ?? null;
1018
948
  const httpAllowed = (scope: ObserverScope): boolean =>
1019
949
  basicOk || (sessionScopes?.has(scope) ?? false);
@@ -1042,12 +972,12 @@ export class WebUiModule implements Module {
1042
972
  && url.pathname !== '/healthz'
1043
973
  && !url.pathname.startsWith('/files/');
1044
974
  if (!basicOk && !(observersActive && isStatic) && !sessionScopes) {
1045
- return this.unauthorized();
975
+ return this.unauthorized(isRetrievalTraceRoute);
1046
976
  }
1047
977
 
1048
978
  // Per-route scope gates for observer sessions (basic auth passes all).
1049
979
  if ((url.pathname.startsWith('/debug/') || url.pathname === '/curve') && !httpAllowed('debug')) {
1050
- return this.unauthorized();
980
+ return this.unauthorized(isRetrievalTraceRoute);
1051
981
  }
1052
982
  if (url.pathname === '/healthz' && !httpAllowed('health')) {
1053
983
  return this.unauthorized();
@@ -1059,6 +989,36 @@ export class WebUiModule implements Module {
1059
989
  return this.unauthorized();
1060
990
  }
1061
991
 
992
+ // Retrieval traces can include lesson contents, raw selector output, and
993
+ // opt-in recent conversation. Keep them operator-only: a password-authenticated
994
+ // full session is equivalent to Basic Auth, but observer `debug` scope is not.
995
+ if (isRetrievalTraceRoute && !operatorAuthenticated) return this.unauthorized(true);
996
+
997
+ if (url.pathname === '/debug/retrieval/view') {
998
+ return new Response(RETRIEVAL_TRACE_PAGE_HTML, {
999
+ headers: {
1000
+ 'content-type': 'text/html; charset=utf-8',
1001
+ 'cache-control': 'no-store',
1002
+ },
1003
+ });
1004
+ }
1005
+ if (url.pathname === '/debug/retrieval') {
1006
+ return this.handleRetrievalTraces(url);
1007
+ }
1008
+
1009
+ // Fleet-scope proxying: every debug/health route accepts ?scope=<child>.
1010
+ // The request is forwarded to that fleet child over the panel IPC verb
1011
+ // and the child's JSON comes back verbatim — same URLs, same payloads,
1012
+ // whether the process answering is this one or a child. Keeps these
1013
+ // endpoints curl-able for operators / connectome-doctor / the fleet hub
1014
+ // without teaching any of them a second transport.
1015
+ const scopeParam = url.searchParams.get('scope');
1016
+ if (scopeParam && scopeParam !== 'local') {
1017
+ const op = HTTP_PANEL_OPS[url.pathname];
1018
+ if (op) return this.proxyPanelToChild(scopeParam, op, panelParamsFromUrl(url));
1019
+ // Fall through: non-panel routes (static, /files) ignore the param.
1020
+ }
1021
+
1062
1022
  // Debug: the membrane-normalized request that WOULD be emitted if the
1063
1023
  // agent were activated right now — no inference, no state mutation.
1064
1024
  // GET /debug/context[?agent=<name>][&hooks=false][&pretty=1]
@@ -1092,74 +1052,17 @@ export class WebUiModule implements Module {
1092
1052
  }
1093
1053
 
1094
1054
  // Liveness/health JSON for connectome-doctor and the fleet hub. Behind
1095
- // the same basic auth as everything else (checked above).
1055
+ // the same basic auth as everything else (checked above). Assembly lives
1056
+ // in panel-data so fleet children serve the identical snapshot.
1096
1057
  if (url.pathname === '/healthz') {
1097
- const app = sharedServer?.app;
1058
+ const app = this.panelApp();
1098
1059
  if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1099
- const fw = app.framework as unknown as { healthSnapshot?: () => Record<string, unknown> };
1100
- if (typeof fw.healthSnapshot !== 'function') {
1101
- return Response.json({ error: 'framework lacks healthSnapshot()' }, { status: 501 });
1102
- }
1103
- const snapshot = fw.healthSnapshot();
1104
- // Compression quarantine is a guaranteed-eventual-outage state (raw
1105
- // spans accumulate until the picker cannot fit the window). Surface it
1106
- // here so the fleet hub and connectome-doctor can alarm on it — it
1107
- // must never be observable only in agent.log.
1108
1060
  try {
1109
- const quarantine: Record<string, unknown> = {};
1110
- for (const agent of app.framework.getAllAgents()) {
1111
- const strategy = (agent.getContextManager() as unknown as {
1112
- getStrategy?: () => { getCompressionQuarantineStatus?: () => unknown };
1113
- }).getStrategy?.();
1114
- const status = strategy?.getCompressionQuarantineStatus?.();
1115
- if (status) quarantine[(agent as unknown as { name: string }).name] = status;
1116
- }
1117
- (snapshot as Record<string, unknown>).compressionQuarantine = quarantine;
1118
- } catch {
1119
- // Health reads never throw.
1120
- }
1121
- // Rendered context COMPOSITION per agent — head / raw middle / summaries
1122
- // by level / tail, as actually emitted by the last compile.
1123
- //
1124
- // Sourced from the strategy's own render stats, which are already
1125
- // computed in-process: unlike /debug/context/makeup this costs nothing
1126
- // and makes no count_tokens network call, so it is safe on the 15s
1127
- // /healthz poll. Answers "how much of what was actually sent" without
1128
- // recompiling.
1129
- try {
1130
- const composition: Record<string, unknown> = {};
1131
- for (const agent of app.framework.getAllAgents()) {
1132
- const name = (agent as unknown as { name: string }).name;
1133
- const cm = agent.getContextManager() as unknown as {
1134
- getRenderStats?: () => unknown;
1135
- };
1136
- const rs = cm.getRenderStats?.();
1137
- if (rs) composition[name] = rs;
1138
- }
1139
- (snapshot as Record<string, unknown>).contextComposition = composition;
1140
- } catch {
1141
- // Health reads never throw.
1142
- }
1143
- // Per-agent runtime settings (context budget, tail, transition pace +
1144
- // convergence state) — the same numbers `agent_settings get` returns,
1145
- // exposed externally so the fleet hub / connectome-doctor can watch
1146
- // budget convergence without an agent turn.
1147
- try {
1148
- const fw2 = app.framework as unknown as {
1149
- getAgentRuntimeSettings?: (name: string) => unknown;
1150
- };
1151
- if (typeof fw2.getAgentRuntimeSettings === 'function') {
1152
- const settings: Record<string, unknown> = {};
1153
- for (const agent of app.framework.getAllAgents()) {
1154
- const name = (agent as unknown as { name: string }).name;
1155
- settings[name] = fw2.getAgentRuntimeSettings(name);
1156
- }
1157
- (snapshot as Record<string, unknown>).runtimeSettings = settings;
1158
- }
1159
- } catch {
1160
- // Health reads never throw.
1061
+ return Response.json(buildHealthSnapshot(app));
1062
+ } catch (err) {
1063
+ const status = err instanceof PanelError ? err.status : 500;
1064
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status });
1161
1065
  }
1162
- return Response.json(snapshot);
1163
1066
  }
1164
1067
 
1165
1068
  // Workspace file passthrough: /files/<mount>/<path...>
@@ -1175,501 +1078,182 @@ export class WebUiModule implements Module {
1175
1078
  return this.serveStatic(requested);
1176
1079
  }
1177
1080
 
1081
+ /** The minimal app slice the shared panel-data layer needs, or null before
1082
+ * setApp. Includes the call ledger so health snapshots ship recent calls. */
1083
+ private panelApp(): PanelAppRef | null {
1084
+ const app = sharedServer?.app;
1085
+ if (!app) return null;
1086
+ return { framework: app.framework, recipe: app.recipe, callLedger: this.config.callLedger ?? null };
1087
+ }
1088
+
1089
+ private fleetModule(): FleetModule | undefined {
1090
+ return sharedServer?.app?.framework.getAllModules().find((m) => m.name === 'fleet') as
1091
+ | FleetModule | undefined;
1092
+ }
1093
+
1094
+ /** Forward one panel op to a fleet child and answer with its JSON. Status
1095
+ * mapping comes from the child (PanelError.status travels the wire), with
1096
+ * 502/504 supplied by requestPanel for unreachable/unresponsive children. */
1097
+ private async proxyPanelToChild(
1098
+ childName: string,
1099
+ op: string,
1100
+ params: Record<string, unknown>,
1101
+ ): Promise<Response> {
1102
+ const fleet = this.fleetModule();
1103
+ if (!fleet) {
1104
+ return Response.json(
1105
+ { error: `scope '${childName}' requested but the fleet module is not loaded` },
1106
+ { status: 404 },
1107
+ );
1108
+ }
1109
+ const result = await fleet.requestPanel(childName, op, params);
1110
+ if (!result.ok) {
1111
+ return Response.json({ error: result.error ?? 'panel request failed' }, { status: result.status ?? 502 });
1112
+ }
1113
+ return Response.json(result.data);
1114
+ }
1115
+
1178
1116
  /**
1179
1117
  * Counts-only state and bounded history for periodic context maintenance.
1180
1118
  * Authentication is enforced by handleHttp before this method is reached.
1181
1119
  * The framework snapshot deliberately contains no message or summary text.
1182
1120
  */
1183
1121
  private handleContextMaintenance(): Response {
1184
- const app = sharedServer?.app;
1122
+ const app = this.panelApp();
1185
1123
  if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1186
- const framework = app.framework as unknown as {
1187
- getContextMaintenanceSnapshot?: () => Record<string, unknown>;
1188
- };
1189
- if (typeof framework.getContextMaintenanceSnapshot !== 'function') {
1190
- return Response.json(
1191
- { error: 'framework lacks context-maintenance diagnostics' },
1192
- { status: 501 },
1193
- );
1124
+ try {
1125
+ return Response.json(buildContextMaintenance(app));
1126
+ } catch (err) {
1127
+ return panelErrorResponse(err);
1194
1128
  }
1195
- return Response.json(framework.getContextMaintenanceSnapshot());
1196
1129
  }
1197
1130
 
1198
1131
  /** Summary-tree coverage and queued work, with no message or summary text. */
1199
1132
  private handleContextCoverage(url: URL): Response {
1200
- const app = sharedServer?.app;
1133
+ const app = this.panelApp();
1201
1134
  if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1202
- const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1203
- const agent = app.framework.getAgent(agentName);
1204
- if (!agent) {
1205
- return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
1135
+ try {
1136
+ return Response.json(buildContextCoverage(app, resolveAgent(app, url.searchParams.get('agent') ?? undefined)));
1137
+ } catch (err) {
1138
+ return panelErrorResponse(err);
1206
1139
  }
1207
- const cm = agent.getContextManager();
1208
- return Response.json(buildContextCoverageSnapshot(agentName, cm));
1209
1140
  }
1210
1141
 
1211
1142
  /**
1212
1143
  * Preview the fold plan at a HYPOTHETICAL budget / tail, without applying it.
1213
1144
  *
1214
- * GET /debug/context/preview?budget=<tokens>[&tail=<tokens>][&agent=<name>]
1215
- *
1216
- * Commits nothing — no fold resolutions persisted, no compression enqueued,
1217
- * no transition bookkeeping advanced. That guarantee lives in
1218
- * context-manager's `previewContext` (dry-run select); this endpoint only
1219
- * forwards. An infeasible budget is reported as `fits: false` with the
1220
- * per-component diagnostics, NOT as an error: learning that a budget can't
1221
- * work is the reason to preview instead of applying and taking the outage.
1145
+ * GET /debug/context/preview?budget=<tokens>[&tail=<tokens>][&agent=<name>][&render=1]
1222
1146
  *
1223
- * Returns 501 when the resolved context-manager predates dry-run support, so
1224
- * the panel can say so instead of rendering an empty result.
1147
+ * Delegates to panel-data's runContextPreview, which owns the honest budget
1148
+ * accounting AND the process-wide single-flight + cooldown guard (a preview
1149
+ * is a real compile that blocks the agent's event loop — see panel-data).
1150
+ * 429 responses here are the guard, not failures.
1225
1151
  */
1226
- /**
1227
- * Single-flight + cooldown for preview.
1228
- *
1229
- * A preview is a real compile: ~8s on a large store, and `select()` is
1230
- * synchronous so it BLOCKS the agent's event loop for that whole time (no
1231
- * heartbeat, no Discord, no MCPL). Overlapping or rapid-fire previews
1232
- * therefore don't just queue — they stack agent stalls. Reject instead.
1233
- */
1234
- private previewInFlight = false;
1235
- private previewLastAt = 0;
1236
- private static readonly PREVIEW_COOLDOWN_MS = 3_000;
1237
-
1238
- /**
1239
- * Replace the dry run's full rendered entries with a compact display
1240
- * projection.
1241
- *
1242
- * Shipping the entries verbatim cost ~110s of BLOCKED AGENT on Mythos (353
1243
- * entries, megabytes of content plus any inlined media) against ~8s for the
1244
- * numbers-only path — and select() builds those entries either way, so the
1245
- * extra ~100s was pure serialization of data the UI never shows in full: the
1246
- * pane truncates every body past 600 chars anyway.
1247
- *
1248
- * So: keep identity, size and a bounded text preview; drop content blocks and
1249
- * never inline media. Also removes the blob-resolution heap risk the /curve
1250
- * handler warns about.
1251
- */
1252
- private projectDryEntries(result: unknown): unknown {
1253
- const r = result as { entries?: unknown[] } & Record<string, unknown>;
1254
- if (!Array.isArray(r.entries)) return result;
1255
- const MAX_TEXT = 1_200;
1256
- const projected = r.entries.map((e, i) => {
1257
- const o = (e ?? {}) as { participant?: string; role?: string; content?: unknown };
1258
- let text = '';
1259
- let media = 0;
1260
- const blocks = Array.isArray(o.content) ? o.content : [];
1261
- for (const b of blocks) {
1262
- if (!b || typeof b !== 'object') { text += String(b ?? ''); continue; }
1263
- const t = (b as { type?: string }).type;
1264
- if (t === 'text') text += (b as { text?: string }).text ?? '';
1265
- else if (t === 'image') { media++; text += '[image]'; }
1266
- else if (t === 'thinking' || t === 'redacted_thinking') text += '[thinking]';
1267
- else if (t === 'tool_use') text += `[tool_use ${(b as { name?: string }).name ?? ''}]`;
1268
- else if (t === 'tool_result') text += '[tool_result]';
1269
- }
1270
- if (typeof o.content === 'string') text = o.content;
1271
- return {
1272
- i,
1273
- who: o.participant ?? o.role ?? '?',
1274
- chars: text.length,
1275
- media,
1276
- truncated: text.length > MAX_TEXT,
1277
- text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text,
1278
- };
1279
- });
1280
- return { ...r, entries: projected };
1281
- }
1282
-
1283
1152
  private handleContextPreview(url: URL): Response {
1284
- const app = sharedServer?.app;
1153
+ const app = this.panelApp();
1285
1154
  if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1286
- const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1287
- const agent = app.framework.getAgent(agentName);
1288
- if (!agent) {
1289
- return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
1290
- }
1291
-
1292
- const budgetRaw = url.searchParams.get('budget');
1293
- const budget = budgetRaw === null ? NaN : Number(budgetRaw);
1294
- if (!Number.isSafeInteger(budget) || budget <= 0) {
1295
- return Response.json({ error: 'budget must be a positive integer' }, { status: 400 });
1296
- }
1297
- const tailRaw = url.searchParams.get('tail');
1298
- const overrides: Record<string, unknown> = {};
1299
- if (tailRaw !== null) {
1300
- const tail = Number(tailRaw);
1301
- if (!Number.isSafeInteger(tail) || tail < 0) {
1302
- return Response.json({ error: 'tail must be a non-negative integer' }, { status: 400 });
1303
- }
1304
- // The strategy knob behind "tail" is recentWindowTokens.
1305
- overrides.recentWindowTokens = tail;
1306
- }
1307
-
1308
- // `render=1` additionally returns the rendered dry context for display.
1309
- const wantRender = (() => {
1310
- const v = url.searchParams.get('render');
1311
- return v !== null && v !== '0' && v !== 'false';
1312
- })();
1313
-
1314
- if (this.previewInFlight) {
1315
- return Response.json(
1316
- { error: 'a preview is already running — it blocks the agent, so they are serialized' },
1317
- { status: 429 },
1318
- );
1319
- }
1320
- const sinceLast = Date.now() - this.previewLastAt;
1321
- if (sinceLast < WebUiModule.PREVIEW_COOLDOWN_MS) {
1322
- return Response.json(
1323
- {
1324
- error: `preview cooling down — ${Math.ceil((WebUiModule.PREVIEW_COOLDOWN_MS - sinceLast) / 1000)}s left. `
1325
- + 'Each run is a full compile and briefly pauses the agent.',
1326
- },
1327
- { status: 429 },
1328
- );
1329
- }
1330
-
1331
- const fw = app.framework as unknown as {
1332
- previewContextSettings?: (
1333
- n: string, b: number, o?: Record<string, unknown>, x?: { render?: boolean },
1334
- ) => unknown;
1335
- };
1336
- if (typeof fw.previewContextSettings !== 'function') {
1337
- return Response.json(
1338
- { error: 'preview unsupported: this agent-framework build has no previewContextSettings' },
1339
- { status: 501 },
1340
- );
1341
- }
1342
- this.previewInFlight = true;
1343
- const startedAt = Date.now();
1344
1155
  try {
1345
- const result = fw.previewContextSettings(
1346
- agentName,
1347
- budget,
1348
- Object.keys(overrides).length > 0 ? overrides : undefined,
1349
- wantRender ? { render: true } : undefined,
1350
- );
1351
- if (result === null || result === undefined) {
1352
- return Response.json(
1353
- {
1354
- error: 'preview unavailable: the resolved context-manager has no dry-run support, '
1355
- + 'or the active strategy has no fold plan (non-adaptive)',
1356
- },
1357
- { status: 501 },
1358
- );
1359
- }
1360
- // Honest budget accounting. context-manager's `budgetTokens` is the
1361
- // REJECTION budget: (requested - reserve) * (1 + overBudgetGraceRatio),
1362
- // i.e. the threshold above which a compile throws. Its `fits` therefore
1363
- // means "would not hard-fail", NOT "fits the budget you asked for".
1364
- //
1365
- // On a recipe with overBudgetGraceRatio 0.35 those differ by a third, so
1366
- // reporting cm's `fits` verbatim told operators that a budget they
1367
- // cannot actually reach was fine. Split the two questions apart and let
1368
- // the panel say which one it means.
1369
- const r = result as { finalTokens?: number; budgetTokens?: number; exhausted?: boolean };
1370
- const reserve = app.recipe.agent.maxTokens ?? 16_384;
1371
- const effectiveBudget = Math.max(0, budget - reserve);
1372
- const finalTokens = typeof r.finalTokens === 'number' ? r.finalTokens : NaN;
1373
- const fitsRequested = Number.isFinite(finalTokens) && finalTokens <= effectiveBudget;
1374
- const withinGrace = Number.isFinite(finalTokens) && typeof r.budgetTokens === 'number'
1375
- ? finalTokens <= r.budgetTokens
1376
- : undefined;
1377
- return Response.json({
1378
- agent: agentName,
1379
- budget,
1380
- ...(overrides as object),
1381
- accounting: {
1382
- requestedBudgetTokens: budget,
1383
- reserveForResponseTokens: reserve,
1384
- /** What the picker actually targets. */
1385
- effectiveBudgetTokens: effectiveBudget,
1386
- /** Hard-fail ceiling — requested minus reserve, plus grace. */
1387
- rejectionBudgetTokens: r.budgetTokens,
1388
- /** Fits the budget the operator asked for. */
1389
- fitsRequested,
1390
- /** Merely tolerated by the grace margin — over budget, but no throw. */
1391
- withinGrace,
1392
- /** Exhausted AND over the request => this budget is UNREACHABLE. */
1393
- unreachable: r.exhausted === true && !fitsRequested,
1394
- },
1395
- /** How long the agent was blocked, so the operator sees the real cost. */
1396
- elapsedMs: Date.now() - startedAt,
1397
- preview: wantRender ? this.projectDryEntries(result) : result,
1398
- });
1156
+ const params = panelParamsFromUrl(url);
1157
+ return Response.json(runContextPreview(app, resolveAgent(app, params.agent), params));
1399
1158
  } catch (err) {
1400
- return Response.json(
1401
- { error: err instanceof Error ? err.message : String(err) },
1402
- { status: 500 },
1403
- );
1404
- } finally {
1405
- this.previewInFlight = false;
1406
- this.previewLastAt = Date.now();
1159
+ return panelErrorResponse(err);
1407
1160
  }
1408
1161
  }
1409
1162
 
1410
1163
  /**
1411
1164
  * Debug endpoint: return the membrane-normalized request the framework would
1412
- * hand to the model if the agent were activated right now. Delegates to
1413
- * `framework.previewActivation`. Auth is already enforced by the caller
1414
- * (`handleHttp`).
1165
+ * hand to the model if the agent were activated right now. Auth is already
1166
+ * enforced by the caller (`handleHttp`).
1415
1167
  *
1416
1168
  * Transparent by default: no inference, no Chronicle writes, no external
1417
- * MCPL calls the preview leaves the system state untouched. This omits the
1418
- * dynamically-gathered injections (lessons/retrieval/MCPL context). Pass
1419
- * `?injections=1` to gather them for full fidelity, which is NOT transparent:
1420
- * it can run inference (e.g. RetrievalModule's Haiku calls) and fire MCPL
1421
- * `beforeInference` hooks (whose paired `afterInference` is never sent).
1169
+ * MCPL calls. Pass `?injections=1` to gather the dynamic injections
1170
+ * (lessons/retrieval/MCPL context) for full fidelity, which is NOT
1171
+ * transparent: it can run inference and fire MCPL `beforeInference` hooks.
1422
1172
  */
1423
1173
  private async handleDebugContext(url: URL): Promise<Response> {
1424
- const app = sharedServer?.app;
1174
+ const app = this.panelApp();
1425
1175
  if (!app) return new Response('Not ready', { status: 503 });
1426
-
1427
- const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1428
- // Default OFF: keep the preview transparent to system state. Opt in to
1429
- // dynamic injection gathering (and its side effects) with ?injections=1.
1430
- const injParam = url.searchParams.get('injections');
1431
- const injections = injParam !== null && injParam !== 'false' && injParam !== '0';
1176
+ const params = panelParamsFromUrl(url);
1432
1177
  const pretty = url.searchParams.get('pretty') !== null && url.searchParams.get('pretty') !== '0';
1433
-
1434
- if (!app.framework.getAgent(agentName)) {
1435
- return new Response(
1436
- JSON.stringify({ error: `Agent not found: ${agentName}` }),
1437
- { status: 404, headers: { 'content-type': 'application/json' } },
1438
- );
1439
- }
1440
-
1441
1178
  try {
1442
- const request = await app.framework.previewActivation(agentName, { injections });
1443
- // `transparent` reflects whether this call was side-effect-free.
1444
- const body = JSON.stringify(
1445
- { agent: agentName, injections, transparent: !injections, request },
1446
- null,
1447
- pretty ? 2 : undefined,
1448
- );
1449
- return new Response(body, { headers: { 'content-type': 'application/json' } });
1450
- } catch (error) {
1451
- const message = error instanceof Error ? error.message : String(error);
1179
+ const payload = await buildDebugContext(app, resolveAgent(app, params.agent), params);
1452
1180
  return new Response(
1453
- JSON.stringify({ error: message }),
1454
- { status: 500, headers: { 'content-type': 'application/json' } },
1181
+ JSON.stringify(payload, null, pretty ? 2 : undefined),
1182
+ { headers: { 'content-type': 'application/json' } },
1455
1183
  );
1184
+ } catch (err) {
1185
+ return panelErrorResponse(err);
1456
1186
  }
1457
1187
  }
1458
1188
 
1459
1189
  /**
1460
- * Context curve (GET /debug/context/curve[?agent=<name>]): compile the
1461
- * agent's window and return one record per compiled entry with its
1462
- * provenance kind (raw / L1..Ln summary), rendered token estimate, the
1463
- * raw-history tokens it covers (leaf messages, recursively through the
1464
- * summary tree), date span, and full text. The /curve page plots the
1465
- * cumulative raw→rendered curve from this; slope = local compression rate.
1466
- *
1467
- * Same side-effect class as previewActivation / makeup: the compile may
1468
- * commit resolution updates, exactly as the agent's own next turn would.
1469
- * No inference, no message writes.
1190
+ * Context curve (GET /debug/context/curve[?agent=<name>]): per-entry
1191
+ * provenance of the live compiled window. Same side-effect class as
1192
+ * previewActivation / makeup: the compile may commit resolution updates,
1193
+ * exactly as the agent's own next turn would. No inference, no writes.
1470
1194
  */
1471
1195
  private async handleContextCurve(url: URL): Promise<Response> {
1472
- const app = sharedServer?.app;
1196
+ const app = this.panelApp();
1473
1197
  if (!app) return new Response('Not ready', { status: 503 });
1474
- const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1475
- const agent = app.framework.getAgent(agentName);
1476
- if (!agent) {
1477
- return new Response(JSON.stringify({ error: `Agent not found: ${agentName}` }), {
1478
- status: 404, headers: { 'content-type': 'application/json' },
1479
- });
1480
- }
1481
1198
  try {
1482
- const cm = (agent as unknown as { getContextManager: () => any }).getContextManager();
1483
- // Use the LIVE budget, not the recipe's. Runtime overrides persist in the
1484
- // `framework/state` Chronicle slot and win over the recipe, so reading
1485
- // app.recipe here plotted the wrong curve on any agent whose budget had
1486
- // ever been changed at runtime — which is every agent the settings panel
1487
- // touches. Fall back to the recipe only if the live read is unavailable.
1488
- let maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
1489
- try {
1490
- const live = (app.framework as unknown as {
1491
- getAgentRuntimeSettings?: (n: string) => { contextBudgetTokens?: number };
1492
- }).getAgentRuntimeSettings?.(agentName)?.contextBudgetTokens;
1493
- if (typeof live === 'number' && live > 0) maxTokens = live;
1494
- } catch { /* keep the recipe fallback */ }
1495
- const reserveForResponse = app.recipe.agent.maxTokens ?? 16_384;
1496
- const compiled = await cm.compile({ maxTokens, reserveForResponse });
1497
-
1498
- // Curve inspection only needs text and source metadata. Resolving every
1499
- // historical blob here re-inlines all base64 media and can expand a
1500
- // few-hundred-MB Chronicle into several GB of JS heap. Use the windowed
1501
- // reader with blob resolution disabled so production diagnostics stay
1502
- // bounded by text history rather than the media archive.
1503
- const messageCount = cm.getMessageCount();
1504
- const messages: Array<{ id: string; timestamp?: unknown; content?: unknown[] }> =
1505
- cm.getMessageWindow(0, messageCount, { resolveBlobs: false }).messages;
1506
- const msgById = new Map(messages.map((mm) => [mm.id, mm]));
1507
- const estimate = (mm: { content?: unknown[] }): number => {
1508
- let t = 0;
1509
- for (const b of (mm.content ?? []) as Array<Record<string, unknown>>) {
1510
- if (b?.type === 'text') t += Math.ceil(String(b.text ?? '').length / 4);
1511
- else if (b?.type === 'image') t += 1600;
1512
- else if (b?.type === 'tool_result') t += Math.ceil(JSON.stringify(b.content ?? '').length / 4);
1513
- else if (b?.type === 'tool_use') t += Math.ceil(JSON.stringify(b.input ?? {}).length / 4);
1514
- else if (b?.type === 'thinking') t += Math.ceil(String(b.thinking ?? '').length / 4);
1515
- }
1516
- return t;
1517
- };
1518
-
1519
- type Summary = { id: string; level: number; content: string; sourceLevel: number; sourceIds: string[] };
1520
- const strategy = cm.getStrategy() as { summaries?: Summary[] };
1521
- const sums: Summary[] = strategy.summaries ?? [];
1522
- const sumById = new Map(sums.map((x) => [x.id, x]));
1523
- const headOf = (txt: string): string => txt.replace(/\s+/g, ' ').slice(0, 100);
1524
- const byHead = new Map(sums.map((x) => [headOf(x.content), x]));
1525
- const leaves = (x: Summary, seen = new Set<string>()): string[] => {
1526
- if (seen.has(x.id)) return [];
1527
- seen.add(x.id);
1528
- if (x.sourceLevel === 0) return x.sourceIds;
1529
- const out: string[] = [];
1530
- for (const cid of x.sourceIds) {
1531
- const c = sumById.get(cid);
1532
- if (c) out.push(...leaves(c, seen));
1533
- }
1534
- return out;
1535
- };
1199
+ return Response.json(await buildContextCurve(app, resolveAgent(app, url.searchParams.get('agent') ?? undefined)));
1200
+ } catch (err) {
1201
+ return panelErrorResponse(err);
1202
+ }
1203
+ }
1536
1204
 
1537
- const entries = [];
1538
- let i = 0;
1539
- for (const e of compiled.messages as Array<{ participant: string; content?: unknown[]; sourceMessageId?: string }>) {
1540
- const blocks = (e.content ?? []) as Array<Record<string, unknown>>;
1541
- const text = blocks.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n');
1542
- const nImages = blocks.filter((b) => b?.type === 'image').length;
1543
- const rendered = Math.ceil(text.length / 4) + nImages * 1600 +
1544
- blocks.filter((b) => b?.type === 'tool_result' || b?.type === 'tool_use')
1545
- .reduce((a, b) => a + Math.ceil(JSON.stringify(b.input ?? b.content ?? '').length / 4), 0);
1546
- const sum = byHead.get(headOf(text));
1547
- if (sum) {
1548
- const leafIds = leaves(sum).filter((id) => msgById.has(id));
1549
- const rawCovered = leafIds.reduce((a, id) => a + estimate(msgById.get(id)!), 0);
1550
- const dates = leafIds.map((id) => msgById.get(id)!.timestamp).filter(Boolean).sort();
1551
- entries.push({
1552
- i: i++, kind: `L${sum.level}`, id: sum.id, participant: e.participant,
1553
- rendered, rawCovered, msgCount: leafIds.length, nImages,
1554
- dateFirst: dates[0] ?? null, dateLast: dates[dates.length - 1] ?? null, text,
1555
- });
1556
- } else {
1557
- const src = e.sourceMessageId ? msgById.get(e.sourceMessageId) : null;
1558
- entries.push({
1559
- i: i++, kind: 'raw', id: e.sourceMessageId ?? null, participant: e.participant,
1560
- rendered, rawCovered: src ? estimate(src) : rendered, msgCount: 1, nImages,
1561
- dateFirst: src?.timestamp ?? null, dateLast: src?.timestamp ?? null, text,
1562
- });
1563
- }
1205
+ private handleRetrievalTraces(url: URL): Response {
1206
+ try {
1207
+ const app = sharedServer?.app;
1208
+ if (!app) return this.retrievalJson({ error: 'app not bound yet' }, { status: 503 });
1209
+
1210
+ const module = app.framework.getAllModules().find(candidate => candidate.name === 'retrieval') as
1211
+ | (RetrievalTraceSource & { name: string })
1212
+ | undefined;
1213
+ if (!module || typeof module.getRetrievalTraces !== 'function') {
1214
+ return this.retrievalJson(
1215
+ { schemaVersion: 1, enabled: false, includeInputs: false, traces: [] },
1216
+ );
1564
1217
  }
1565
- return Response.json({
1566
- agent: agentName,
1567
- generatedAt: new Date().toISOString(),
1568
- branch: cm.currentBranch().name,
1569
- budget: { maxTokens, reserveForResponse },
1570
- totals: {
1571
- entries: entries.length,
1572
- rendered: entries.reduce((a, e) => a + e.rendered, 0),
1573
- rawCovered: entries.reduce((a, e) => a + e.rawCovered, 0),
1574
- },
1575
- entries,
1576
- });
1218
+
1219
+ const requestedLimit = Number(url.searchParams.get('limit') ?? '20');
1220
+ const limit = Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : 20;
1221
+ const includeInputs = url.searchParams.get('includeInputs') === '1';
1222
+ const traces = module.getRetrievalTraces({ limit, includeInputs });
1223
+ return this.retrievalJson({ schemaVersion: 1, enabled: true, includeInputs, traces });
1577
1224
  } catch (error) {
1578
- return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
1579
- status: 500, headers: { 'content-type': 'application/json' },
1580
- });
1225
+ let message = 'unavailable error';
1226
+ try {
1227
+ message = error instanceof Error ? error.message : String(error);
1228
+ } catch { /* keep the safe fallback */ }
1229
+ return this.retrievalJson({ error: message }, { status: 500 });
1581
1230
  }
1582
1231
  }
1583
1232
 
1233
+ private retrievalJson(value: unknown, init: ResponseInit = {}): Response {
1234
+ const headers = new Headers(init.headers);
1235
+ headers.set('cache-control', 'no-store');
1236
+ return Response.json(value, { ...init, headers });
1237
+ }
1238
+
1584
1239
  /**
1585
1240
  * Context makeup: the segment breakdown of the agent's current compiled
1586
- * context — head window, raw middle, summaries by level (L1/L2/L3), and the
1587
- * recent verbatim tail — from the strategy's RenderStats, plus an exact
1588
- * total token count via the model's count_tokens endpoint. Transparent:
1241
+ * context, plus an exact total via count_tokens. Transparent:
1589
1242
  * previewActivation + count_tokens only; no inference, no Chronicle writes.
1590
1243
  *
1591
1244
  * GET /debug/context/makeup[?agent=<name>]
1592
1245
  */
1593
1246
  private async handleContextMakeup(url: URL): Promise<Response> {
1594
- const app = sharedServer?.app;
1247
+ const app = this.panelApp();
1595
1248
  if (!app) return new Response('Not ready', { status: 503 });
1596
- const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1597
- const agent = app.framework.getAgent(agentName);
1598
- if (!agent) {
1599
- return new Response(JSON.stringify({ error: `Agent not found: ${agentName}` }), {
1600
- status: 404, headers: { 'content-type': 'application/json' },
1601
- });
1602
- }
1603
1249
  try {
1604
- // Populates the strategy's render stats as a side-effect of compiling.
1605
- const request = await app.framework.previewActivation(agentName);
1606
- const cm = (agent as { getContextManager: () => { getRenderStats: () => unknown } }).getContextManager();
1607
- const stats = cm.getRenderStats();
1608
-
1609
- // Build an Anthropic-faithful payload for an exact count_tokens: map
1610
- // participants to roles (the agent's own -> assistant, others -> user
1611
- // with a "Name:" prefix) and merge consecutive same-role runs, mirroring
1612
- // what the NativeFormatter sends.
1613
- const textOf = (c: unknown): string =>
1614
- Array.isArray(c)
1615
- ? c.map((b) => (b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? (b as { text: string }).text : '')).join('')
1616
- : String(c ?? '');
1617
- const merged: Array<{ role: 'user' | 'assistant'; text: string }> = [];
1618
- for (const m of ((request as { messages?: Array<{ participant?: string; role?: string; content: unknown }> }).messages ?? [])) {
1619
- const who = m.participant ?? m.role ?? 'user';
1620
- const role: 'user' | 'assistant' = who === agentName ? 'assistant' : 'user';
1621
- let t = textOf(m.content);
1622
- if (role === 'user' && who && who !== 'user') t = `${who}: ${t}`;
1623
- const last = merged[merged.length - 1];
1624
- if (last && last.role === role) last.text += '\n' + t;
1625
- else merged.push({ role, text: t });
1626
- }
1627
- const anthMessages = merged.filter((m) => m.text.trim().length > 0).map((m) => ({ role: m.role, content: m.text }));
1628
- const sysRaw = (request as { system?: unknown }).system;
1629
- const systemStr = Array.isArray(sysRaw)
1630
- ? sysRaw.map((b) => (b && typeof b === 'object' ? (b as { text?: string }).text ?? '' : String(b))).join('\n')
1631
- : (typeof sysRaw === 'string' ? sysRaw : undefined);
1632
-
1633
- let exactTotalTokens: number | null = null;
1634
- const countModel = process.env.COUNT_TOKENS_MODEL || 'anthropic/claude-opus-4.5';
1635
- let countSource = 'count_tokens';
1636
- try {
1637
- const base = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
1638
- const res = await fetch(base + '/v1/messages/count_tokens', {
1639
- method: 'POST',
1640
- headers: {
1641
- // Mirror the main adapter's auth: OAuth Bearer (subscription) when
1642
- // ANTHROPIC_AUTH_TOKEN is set, x-api-key otherwise.
1643
- ...(process.env.ANTHROPIC_AUTH_TOKEN
1644
- ? {
1645
- authorization: `Bearer ${process.env.ANTHROPIC_AUTH_TOKEN}`,
1646
- 'anthropic-beta': 'oauth-2025-04-20',
1647
- }
1648
- : { 'x-api-key': process.env.ANTHROPIC_API_KEY ?? '' }),
1649
- 'anthropic-version': '2023-06-01',
1650
- 'content-type': 'application/json',
1651
- 'user-agent': 'conhost/1.0',
1652
- },
1653
- body: JSON.stringify({ model: countModel, ...(systemStr ? { system: systemStr } : {}), messages: anthMessages }),
1654
- });
1655
- if (res.ok) {
1656
- const j = (await res.json()) as { input_tokens?: number };
1657
- exactTotalTokens = j.input_tokens ?? null;
1658
- } else {
1659
- countSource = `count_tokens_failed_${res.status}`;
1660
- }
1661
- } catch {
1662
- countSource = 'count_tokens_error';
1663
- }
1664
-
1250
+ const payload = await buildContextMakeup(app, resolveAgent(app, url.searchParams.get('agent') ?? undefined));
1665
1251
  return new Response(
1666
- JSON.stringify({ agent: agentName, stats, exactTotalTokens, countModel, countSource }, null, 2),
1252
+ JSON.stringify(payload, null, 2),
1667
1253
  { headers: { 'content-type': 'application/json' } },
1668
1254
  );
1669
- } catch (error) {
1670
- return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
1671
- status: 500, headers: { 'content-type': 'application/json' },
1672
- });
1255
+ } catch (err) {
1256
+ return panelErrorResponse(err);
1673
1257
  }
1674
1258
  }
1675
1259
 
@@ -1936,7 +1520,11 @@ export class WebUiModule implements Module {
1936
1520
  }
1937
1521
 
1938
1522
  case 'request-mcpl': {
1939
- this.sendMcplList(client);
1523
+ if (isChildScope(parsed.scope)) {
1524
+ void this.sendScopedMcpl(client, parsed.scope!);
1525
+ } else {
1526
+ this.sendMcplList(client);
1527
+ }
1940
1528
  return;
1941
1529
  }
1942
1530
 
@@ -1945,6 +1533,11 @@ export class WebUiModule implements Module {
1945
1533
  return;
1946
1534
  }
1947
1535
 
1536
+ // MCPL mutations stay host-side regardless of panel scope: the registry
1537
+ // FILE is one cwd-shared mcpl-servers.json for the whole fleet, so
1538
+ // "edit clerk's zulip env" and "edit the shared file" are the same
1539
+ // write. Which entries a child actually loads is its recipe's opt-in —
1540
+ // the scoped VIEW (request-mcpl + live) is what differs per child.
1948
1541
  case 'mcpl-add': {
1949
1542
  try {
1950
1543
  const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
@@ -2001,31 +1594,33 @@ export class WebUiModule implements Module {
2001
1594
  }
2002
1595
 
2003
1596
  case 'request-pins': {
2004
- const msg = this.buildPinsList(this.resolveSettingsAgent(parsed.agent));
2005
- if (!msg) {
2006
- this.send(client, { type: 'error', message: 'pins unavailable on this build' });
2007
- return;
1597
+ if (isChildScope(parsed.scope)) {
1598
+ void this.sendScopedPins(client, parsed.scope!, parsed.agent);
1599
+ } else {
1600
+ this.sendPinsList(client, parsed.agent);
2008
1601
  }
2009
- this.send(client, msg);
2010
1602
  return;
2011
1603
  }
2012
1604
 
2013
1605
  // Pins change what the NEXT compile folds, so like settings these
2014
1606
  // broadcast rather than replying to the requester only.
2015
1607
  case 'pin-add': {
2016
- const agentName = this.resolveSettingsAgent(parsed.agent);
1608
+ if (isChildScope(parsed.scope)) {
1609
+ void this.applyScopedPinMutation(client, parsed.scope!, 'pin-add', {
1610
+ ...(parsed.agent ? { agent: parsed.agent } : {}),
1611
+ ...(parsed.kind ? { kind: parsed.kind } : {}),
1612
+ firstMessageId: parsed.firstMessageId,
1613
+ ...(parsed.lastMessageId ? { lastMessageId: parsed.lastMessageId } : {}),
1614
+ ...(parsed.level !== undefined ? { level: parsed.level } : {}),
1615
+ ...(parsed.maxLevel !== undefined ? { maxLevel: parsed.maxLevel } : {}),
1616
+ ...(parsed.name ? { name: parsed.name } : {}),
1617
+ });
1618
+ return;
1619
+ }
1620
+ const app = this.panelApp()!;
1621
+ const agentName = resolveAgent(app, parsed.agent);
2017
1622
  try {
2018
- const cm = this.pinnableCm(agentName);
2019
- const opts: Record<string, unknown> = {};
2020
- if (parsed.name !== undefined) opts.name = parsed.name;
2021
- if (parsed.level !== undefined) opts.level = parsed.level;
2022
- if (parsed.maxLevel !== undefined) opts.maxLevel = parsed.maxLevel;
2023
- if (parsed.kind === 'document') {
2024
- cm.markDocument!(parsed.firstMessageId, opts);
2025
- } else {
2026
- // A single-message pin is a range of one; the strategy takes both ends.
2027
- cm.pinRange!(parsed.firstMessageId, parsed.lastMessageId ?? parsed.firstMessageId, opts);
2028
- }
1623
+ applyPinAdd(app, agentName, parsed as unknown as Record<string, unknown>);
2029
1624
  } catch (err) {
2030
1625
  this.send(client, {
2031
1626
  type: 'error',
@@ -2038,10 +1633,17 @@ export class WebUiModule implements Module {
2038
1633
  }
2039
1634
 
2040
1635
  case 'pin-remove': {
2041
- const agentName = this.resolveSettingsAgent(parsed.agent);
1636
+ if (isChildScope(parsed.scope)) {
1637
+ void this.applyScopedPinMutation(client, parsed.scope!, 'pin-remove', {
1638
+ ...(parsed.agent ? { agent: parsed.agent } : {}),
1639
+ pinId: parsed.pinId,
1640
+ });
1641
+ return;
1642
+ }
1643
+ const app = this.panelApp()!;
1644
+ const agentName = resolveAgent(app, parsed.agent);
2042
1645
  try {
2043
- const cm = this.pinnableCm(agentName);
2044
- const ok = cm.unpin!(parsed.pinId);
1646
+ const ok = applyPinRemove(app, agentName, parsed.pinId);
2045
1647
  if (!ok) {
2046
1648
  // Not an exception: a stale panel can ask twice. Say so plainly and
2047
1649
  // still re-broadcast, so the client converges on reality.
@@ -2059,7 +1661,11 @@ export class WebUiModule implements Module {
2059
1661
  }
2060
1662
 
2061
1663
  case 'request-settings': {
2062
- this.sendSettingsState(client, parsed.agent);
1664
+ if (isChildScope(parsed.scope)) {
1665
+ void this.sendScopedSettings(client, parsed.scope!, parsed.agent);
1666
+ } else {
1667
+ this.sendSettingsState(client, parsed.agent);
1668
+ }
2063
1669
  return;
2064
1670
  }
2065
1671
 
@@ -2067,17 +1673,22 @@ export class WebUiModule implements Module {
2067
1673
  // rather than replying to the requester only (contrast sendMcplList,
2068
1674
  // which is file-only). Two operators must not see divergent budgets.
2069
1675
  case 'settings-update': {
2070
- const agentName = this.resolveSettingsAgent(parsed.agent);
1676
+ if (isChildScope(parsed.scope)) {
1677
+ void this.applyScopedSettingsMutation(client, parsed.scope!, 'settings-update', {
1678
+ ...(parsed.agent ? { agent: parsed.agent } : {}),
1679
+ ...(parsed.contextBudgetTokens !== undefined ? { contextBudgetTokens: parsed.contextBudgetTokens } : {}),
1680
+ ...(parsed.tailTokens !== undefined ? { tailTokens: parsed.tailTokens } : {}),
1681
+ ...(parsed.transitionPaceTokens !== undefined ? { transitionPaceTokens: parsed.transitionPaceTokens } : {}),
1682
+ ...(parsed.immediate !== undefined ? { immediate: parsed.immediate } : {}),
1683
+ ...(parsed.persist !== undefined ? { persist: parsed.persist } : {}),
1684
+ ...(parsed.notify !== undefined ? { notify: parsed.notify } : {}),
1685
+ });
1686
+ return;
1687
+ }
1688
+ const app = this.panelApp()!;
1689
+ const agentName = resolveAgent(app, parsed.agent);
2071
1690
  try {
2072
- const patch: Record<string, number | boolean> = {};
2073
- if (parsed.contextBudgetTokens !== undefined) patch.contextBudgetTokens = parsed.contextBudgetTokens;
2074
- if (parsed.tailTokens !== undefined) patch.tailTokens = parsed.tailTokens;
2075
- if (parsed.transitionPaceTokens !== undefined) patch.transitionPaceTokens = parsed.transitionPaceTokens;
2076
- if (parsed.immediate !== undefined) patch.immediate = parsed.immediate;
2077
- const fw = sharedServer!.app.framework as unknown as {
2078
- updateAgentRuntimeSettings: (n: string, p: unknown, o?: { persist?: boolean }) => unknown;
2079
- };
2080
- fw.updateAgentRuntimeSettings(agentName, patch, { persist: parsed.persist !== false });
1691
+ applySettingsUpdate(app, agentName, parsed as unknown as Record<string, unknown>);
2081
1692
  } catch (err) {
2082
1693
  // Expected failures land here and must reach the operator verbatim:
2083
1694
  // budget ≤ max response tokens, or a strategy that cannot prepare a
@@ -2089,18 +1700,25 @@ export class WebUiModule implements Module {
2089
1700
  });
2090
1701
  return;
2091
1702
  }
2092
- if (parsed.notify === true) this.notifyAgentOfSettingsChange(agentName, 'update');
1703
+ if (parsed.notify === true) notifyAgentOfSettingsChange(app, agentName, 'update');
2093
1704
  this.broadcastSettingsState(agentName);
2094
1705
  return;
2095
1706
  }
2096
1707
 
2097
1708
  case 'settings-reset': {
2098
- const agentName = this.resolveSettingsAgent(parsed.agent);
1709
+ if (isChildScope(parsed.scope)) {
1710
+ void this.applyScopedSettingsMutation(client, parsed.scope!, 'settings-reset', {
1711
+ ...(parsed.agent ? { agent: parsed.agent } : {}),
1712
+ ...(parsed.keys ? { keys: parsed.keys } : {}),
1713
+ ...(parsed.persist !== undefined ? { persist: parsed.persist } : {}),
1714
+ ...(parsed.notify !== undefined ? { notify: parsed.notify } : {}),
1715
+ });
1716
+ return;
1717
+ }
1718
+ const app = this.panelApp()!;
1719
+ const agentName = resolveAgent(app, parsed.agent);
2099
1720
  try {
2100
- const fw = sharedServer!.app.framework as unknown as {
2101
- resetAgentRuntimeSettings: (n: string, k?: string[], o?: { persist?: boolean }) => unknown;
2102
- };
2103
- fw.resetAgentRuntimeSettings(agentName, parsed.keys, { persist: parsed.persist !== false });
1721
+ applySettingsReset(app, agentName, parsed as unknown as Record<string, unknown>);
2104
1722
  } catch (err) {
2105
1723
  this.send(client, {
2106
1724
  type: 'error',
@@ -2108,18 +1726,22 @@ export class WebUiModule implements Module {
2108
1726
  });
2109
1727
  return;
2110
1728
  }
2111
- if (parsed.notify === true) this.notifyAgentOfSettingsChange(agentName, 'reset');
1729
+ if (parsed.notify === true) notifyAgentOfSettingsChange(app, agentName, 'reset');
2112
1730
  this.broadcastSettingsState(agentName);
2113
1731
  return;
2114
1732
  }
2115
1733
 
2116
1734
  case 'settings-cancel-transition': {
2117
- const agentName = this.resolveSettingsAgent(parsed.agent);
1735
+ if (isChildScope(parsed.scope)) {
1736
+ void this.applyScopedSettingsMutation(client, parsed.scope!, 'settings-cancel-transition', {
1737
+ ...(parsed.agent ? { agent: parsed.agent } : {}),
1738
+ });
1739
+ return;
1740
+ }
1741
+ const app = this.panelApp()!;
1742
+ const agentName = resolveAgent(app, parsed.agent);
2118
1743
  try {
2119
- const fw = sharedServer!.app.framework as unknown as {
2120
- cancelAgentRuntimeSettingsTransition: (n: string) => unknown;
2121
- };
2122
- fw.cancelAgentRuntimeSettingsTransition(agentName);
1744
+ applySettingsCancelTransition(app, agentName);
2123
1745
  } catch (err) {
2124
1746
  this.send(client, {
2125
1747
  type: 'error',
@@ -2234,7 +1856,7 @@ export class WebUiModule implements Module {
2234
1856
  private async sendWorkspaceMounts(client: ClientState): Promise<void> {
2235
1857
  const mod = await this.workspaceMod();
2236
1858
  if (!mod) {
2237
- this.send(client, { type: 'workspace-mounts', loaded: false, mounts: [] });
1859
+ this.send(client, { type: 'workspace-mounts', scope: 'local', loaded: false, mounts: [] });
2238
1860
  return;
2239
1861
  }
2240
1862
  try {
@@ -2242,6 +1864,7 @@ export class WebUiModule implements Module {
2242
1864
  const data = (result.data ?? {}) as { mounts?: Array<{ name: string; path: string; mode: string }> };
2243
1865
  this.send(client, {
2244
1866
  type: 'workspace-mounts',
1867
+ scope: 'local',
2245
1868
  loaded: true,
2246
1869
  mounts: data.mounts ?? [],
2247
1870
  });
@@ -2269,6 +1892,7 @@ export class WebUiModule implements Module {
2269
1892
  const data = (result.data ?? {}) as { entries?: Array<{ path: string; size: number }> };
2270
1893
  this.send(client, {
2271
1894
  type: 'workspace-tree',
1895
+ scope: 'local',
2272
1896
  mount,
2273
1897
  entries: data.entries ?? [],
2274
1898
  });
@@ -2325,6 +1949,7 @@ export class WebUiModule implements Module {
2325
1949
  }
2326
1950
  this.send(client, {
2327
1951
  type: 'workspace-file',
1952
+ scope: 'local',
2328
1953
  path: data.path ?? path,
2329
1954
  totalLines,
2330
1955
  fromLine: data.fromLine ?? 1,
@@ -2341,26 +1966,25 @@ export class WebUiModule implements Module {
2341
1966
  * config path is whatever the host's mcpl-config module resolves at
2342
1967
  * module-load time — usually `<cwd>/mcpl-servers.json`. */
2343
1968
  private sendMcplList(client: ClientState): void {
2344
- let servers: ReturnType<typeof readMcplServersFile> = {};
2345
- try { servers = readMcplServersFile(DEFAULT_CONFIG_PATH); }
2346
- catch { /* missing or malformed file → empty list */ }
2347
- const out: McplListMessage = {
2348
- type: 'mcpl-list',
2349
- configPath: DEFAULT_CONFIG_PATH,
2350
- servers: Object.entries(servers).map(([id, entry]) => ({
2351
- id,
2352
- command: entry.command,
2353
- ...(entry.args ? { args: entry.args } : {}),
2354
- ...(entry.env ? { env: entry.env } : {}),
2355
- ...(entry.toolPrefix ? { toolPrefix: entry.toolPrefix } : {}),
2356
- ...(entry.reconnect !== undefined ? { reconnect: entry.reconnect } : {}),
2357
- ...(entry.enabledFeatureSets ? { enabledFeatureSets: entry.enabledFeatureSets } : {}),
2358
- ...(entry.disabledFeatureSets ? { disabledFeatureSets: entry.disabledFeatureSets } : {}),
2359
- })),
1969
+ const app = this.panelApp();
1970
+ if (!app) return;
1971
+ const snap = buildMcplSnapshot(app) as {
1972
+ configPath: string;
1973
+ servers: McplListMessage['servers'];
1974
+ live: McplLiveServer[];
2360
1975
  };
1976
+ const out: McplListMessage = { type: 'mcpl-list', scope: 'local', ...snap };
2361
1977
  this.send(client, out);
2362
1978
  }
2363
1979
 
1980
+ /** Scoped MCPL view: the child's own runPanelOp('mcpl') — same shared
1981
+ * registry file, but the LIVE list is the child's actual loaded servers. */
1982
+ private async sendScopedMcpl(client: ClientState, scope: string): Promise<void> {
1983
+ const data = await this.requestChildPanel(client, scope, 'mcpl', {});
1984
+ if (data === null) return;
1985
+ this.send(client, { type: 'mcpl-list', scope, ...(data as object) } as WebUiServerMessage);
1986
+ }
1987
+
2364
1988
  /** Build a BranchesListMessage from the agent's context manager. Lineage
2365
1989
  * (parentId + branchPoint) comes straight from Chronicle's branch records;
2366
1990
  * the SPA folds it into a tree. */
@@ -2402,7 +2026,7 @@ export class WebUiModule implements Module {
2402
2026
  | { getLessons(): Array<{ id: string; content: string; confidence: number; tags: string[]; deprecated: boolean; deprecationReason?: string; created?: number; updated?: number }> }
2403
2027
  | undefined;
2404
2028
  if (!lessonsMod) {
2405
- this.send(client, { type: 'lessons-list', loaded: false, lessons: [] });
2029
+ this.send(client, { type: 'lessons-list', scope: 'local', loaded: false, lessons: [] });
2406
2030
  return;
2407
2031
  }
2408
2032
  const lessons = lessonsMod.getLessons().map(l => ({
@@ -2415,7 +2039,7 @@ export class WebUiModule implements Module {
2415
2039
  ...(typeof l.created === 'number' ? { created: l.created } : {}),
2416
2040
  ...(typeof l.updated === 'number' ? { updated: l.updated } : {}),
2417
2041
  }));
2418
- this.send(client, { type: 'lessons-list', loaded: true, lessons });
2042
+ this.send(client, { type: 'lessons-list', scope: 'local', loaded: true, lessons });
2419
2043
  }
2420
2044
 
2421
2045
  /** Names of fleet children currently running. Empty when no fleet module
@@ -2794,215 +2418,140 @@ export class WebUiModule implements Module {
2794
2418
  }
2795
2419
  }
2796
2420
 
2797
- /** Duck-typed pin surface. Throws with a clear reason rather than returning a
2798
- * half-usable object, so handlers can report it to the operator verbatim. */
2799
- private pinnableCm(agentName: string): {
2800
- pinRange?: (a: string, b: string, o?: unknown) => string;
2801
- markDocument?: (a: string, o?: unknown) => string;
2802
- unpin?: (id: string) => boolean;
2803
- listPins?: () => ReadonlyArray<Record<string, unknown>>;
2804
- } {
2805
- const app = sharedServer?.app;
2806
- if (!app) throw new Error('app not bound yet');
2807
- const agent = app.framework.getAgent(agentName);
2808
- if (!agent) throw new Error(`Unknown agent: ${agentName}`);
2809
- const cm = agent.getContextManager() as unknown as {
2810
- pinRange?: (a: string, b: string, o?: unknown) => string;
2811
- markDocument?: (a: string, o?: unknown) => string;
2812
- unpin?: (id: string) => boolean;
2813
- listPins?: () => ReadonlyArray<Record<string, unknown>>;
2814
- };
2815
- if (typeof cm.listPins !== 'function' || typeof cm.pinRange !== 'function') {
2816
- throw new Error('the active context strategy does not support pins');
2817
- }
2818
- return cm;
2819
- }
2820
-
2821
- /**
2822
- * Pin snapshot. Never throws — an unsupported strategy is a legitimate
2823
- * read-only state for the panel, not an error to surface.
2824
- *
2825
- * `levelHonored` matters: pin-AT-level is implemented only by the kv-stable
2826
- * controller. Elsewhere it degrades to raw, which is a safe superset but not
2827
- * what the operator asked for, so the UI needs to be able to say so.
2828
- */
2829
- private buildPinsList(agentName: string): PinsListMessage | null {
2830
- const app = sharedServer?.app;
2831
- if (!app) return null;
2832
- let pins: PinsListMessage['pins'] = [];
2833
- let supported = false;
2834
- try {
2835
- const cm = this.pinnableCm(agentName);
2836
- pins = (cm.listPins!() ?? []).map((p) => ({
2837
- id: String(p.id),
2838
- firstMessageId: String(p.firstMessageId),
2839
- lastMessageId: String(p.lastMessageId),
2840
- kind: p.kind === 'document' ? 'document' : 'pin',
2841
- ...(typeof p.name === 'string' ? { name: p.name } : {}),
2842
- created: typeof p.created === 'number' ? p.created : 0,
2843
- ...(typeof p.level === 'number' ? { level: p.level } : {}),
2844
- ...(typeof p.maxLevel === 'number' ? { maxLevel: p.maxLevel } : {}),
2845
- }));
2846
- supported = true;
2847
- } catch {
2848
- supported = false;
2849
- }
2421
+ // -------------------------------------------------------------------------
2422
+ // Pins + settings senders all snapshot/mutation logic lives in the shared
2423
+ // panel-data layer; these wrappers only add wire envelopes, scope stamps,
2424
+ // and requester-vs-broadcast routing.
2425
+ // -------------------------------------------------------------------------
2850
2426
 
2851
- let levelHonored = false;
2852
- let deepestLevel: number | undefined;
2427
+ private sendPinsList(client: ClientState, agentName?: string): void {
2428
+ const app = this.panelApp();
2429
+ if (!app) return;
2853
2430
  try {
2854
- const strategyCfg = (app.recipe.agent as unknown as {
2855
- strategy?: { foldingStrategy?: string };
2856
- }).strategy;
2857
- levelHonored = strategyCfg?.foldingStrategy === 'kv-stable';
2858
- const agent = app.framework.getAgent(agentName);
2859
- const cm = agent?.getContextManager() as unknown as {
2860
- getSummaries?: () => Array<{ level?: number }>;
2861
- } | undefined;
2862
- const sums = cm?.getSummaries?.() ?? [];
2863
- for (const s of sums) {
2864
- if (typeof s.level === 'number' && (deepestLevel === undefined || s.level > deepestLevel)) {
2865
- deepestLevel = s.level;
2866
- }
2867
- }
2868
- } catch { /* informational only */ }
2869
-
2870
- return {
2871
- type: 'pins-list',
2872
- agent: agentName,
2873
- pins,
2874
- pinsSupported: supported,
2875
- levelHonored,
2876
- ...(deepestLevel !== undefined ? { deepestLevel } : {}),
2877
- };
2878
- }
2879
-
2880
- private broadcastPinsList(agentName: string): void {
2881
- if (!sharedServer?.app) return;
2882
- const msg = this.buildPinsList(agentName);
2883
- if (!msg) return;
2884
- for (const c of sharedServer.clients.values()) {
2885
- if (c.welcomed) this.send(c, msg);
2431
+ const snap = buildPinsSnapshot(app, resolveAgent(app, agentName));
2432
+ this.send(client, { type: 'pins-list', scope: 'local', ...snap });
2433
+ } catch (err) {
2434
+ this.send(client, {
2435
+ type: 'error',
2436
+ message: `pins unavailable: ${err instanceof Error ? err.message : String(err)}`,
2437
+ });
2886
2438
  }
2887
2439
  }
2888
2440
 
2889
- /** Default to the recipe's primary agent when the client omits a name. */
2890
- private resolveSettingsAgent(name?: string): string {
2891
- if (name) return name;
2892
- const app = sharedServer?.app;
2893
- return app?.recipe.agent.name ?? app?.framework.getAllAgents()[0]?.name ?? 'agent';
2441
+ /** Scoped pins view. Asks the child for picker candidates too the SPA
2442
+ * has no window into a child's message store to build its own list. */
2443
+ private async sendScopedPins(client: ClientState, scope: string, agentName?: string): Promise<void> {
2444
+ const data = await this.requestChildPanel(client, scope, 'pins', {
2445
+ ...(agentName ? { agent: agentName } : {}),
2446
+ withCandidates: true,
2447
+ });
2448
+ if (data === null) return;
2449
+ this.send(client, { type: 'pins-list', scope, ...(data as object) } as WebUiServerMessage);
2894
2450
  }
2895
2451
 
2896
- /**
2897
- * Build the settings snapshot. Never throws: this feeds a panel, and a
2898
- * strategy that isn't hot-configurable is a legitimate state to render
2899
- * read-only rather than an error to surface.
2900
- */
2901
- private buildSettingsState(agentName: string): SettingsStateMessage | null {
2902
- const app = sharedServer?.app;
2903
- if (!app) return null;
2904
- const fw = app.framework as unknown as {
2905
- getAgentRuntimeSettings?: (n: string) => Record<string, unknown>;
2906
- getAgent?: (n: string) => unknown;
2907
- };
2908
- if (typeof fw.getAgentRuntimeSettings !== 'function') return null;
2909
-
2910
- let settings: Record<string, unknown>;
2911
- try {
2912
- settings = fw.getAgentRuntimeSettings(agentName);
2913
- } catch {
2914
- return null;
2452
+ /** Run pin-add / pin-remove in a fleet child; the op returns the fresh
2453
+ * pins snapshot, which BROADCASTS (pins alter the next compile's fold
2454
+ * plan two operators must not hold divergent views, same rule as the
2455
+ * local path). A `warning` in the data (stale pin-remove) goes back to
2456
+ * the requester only. */
2457
+ private async applyScopedPinMutation(
2458
+ client: ClientState,
2459
+ scope: string,
2460
+ op: 'pin-add' | 'pin-remove',
2461
+ params: Record<string, unknown>,
2462
+ ): Promise<void> {
2463
+ const data = await this.requestChildPanel(client, scope, op, { ...params, withCandidates: true });
2464
+ if (data === null) return;
2465
+ const warning = (data as { warning?: unknown }).warning;
2466
+ if (typeof warning === 'string') {
2467
+ this.send(client, { type: 'error', message: warning });
2915
2468
  }
2469
+ this.broadcastToWelcomed({ type: 'pins-list', scope, ...(data as object) } as WebUiServerMessage);
2470
+ }
2916
2471
 
2917
- // Which knobs this build can apply live, probed rather than assumed: the
2918
- // hot set is a property of the ACTIVE strategy, not of the host version.
2919
- let hotConfigurable = false;
2920
- let previewAvailable = false;
2921
- try {
2922
- const agent = app.framework.getAgent(agentName);
2923
- const cm = agent?.getContextManager() as unknown as {
2924
- getHotContextSettings?: () => unknown;
2925
- previewContext?: unknown;
2926
- } | undefined;
2927
- hotConfigurable = !!cm && typeof cm.getHotContextSettings === 'function'
2928
- && cm.getHotContextSettings() !== null;
2929
- // Older context-manager builds resolve without previewContext. Report it
2930
- // so the panel says "preview unavailable on this build" instead of
2931
- // rendering an empty chart and looking broken.
2932
- previewAvailable = !!cm && typeof cm.previewContext === 'function';
2933
- } catch { /* leave both false — read-only panel */ }
2934
-
2935
- const overrides: string[] = [];
2472
+ private broadcastPinsList(agentName: string): void {
2473
+ const app = this.panelApp();
2474
+ if (!app) return;
2936
2475
  try {
2937
- const agent = app.framework.getAgent(agentName) as unknown as {
2938
- getRuntimeSettingsOverrides?: () => Record<string, unknown>;
2939
- } | undefined;
2940
- const ov = agent?.getRuntimeSettingsOverrides?.() ?? {};
2941
- for (const [k, val] of Object.entries(ov)) if (val !== undefined) overrides.push(k);
2942
- } catch { /* informational only */ }
2943
-
2944
- return {
2945
- type: 'settings-state',
2946
- agent: agentName,
2947
- settings: settings as SettingsStateMessage['settings'],
2948
- overrides,
2949
- // contextBudgetTokens is applied by the Agent itself; the other three are
2950
- // forwarded into the strategy's hot-settings channel, so they need a
2951
- // hot-configurable strategy to mean anything.
2952
- hotKeys: hotConfigurable
2953
- ? ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens', 'sameRoundThinkTextPolicy']
2954
- : ['contextBudgetTokens'],
2955
- hotConfigurable,
2956
- previewAvailable,
2957
- };
2476
+ const snap = buildPinsSnapshot(app, agentName);
2477
+ this.broadcastToWelcomed({ type: 'pins-list', scope: 'local', ...snap });
2478
+ } catch { /* pins unsupported — nothing to broadcast */ }
2958
2479
  }
2959
2480
 
2960
2481
  private sendSettingsState(client: ClientState, agentName?: string): void {
2961
- const msg = this.buildSettingsState(this.resolveSettingsAgent(agentName));
2482
+ const app = this.panelApp();
2483
+ if (!app) return;
2484
+ const msg = buildSettingsState(app, resolveAgent(app, agentName));
2962
2485
  if (!msg) {
2963
2486
  this.send(client, { type: 'error', message: 'runtime settings unavailable on this build' });
2964
2487
  return;
2965
2488
  }
2966
- this.send(client, msg);
2489
+ this.send(client, { type: 'settings-state', scope: 'local', ...msg } as WebUiServerMessage);
2490
+ }
2491
+
2492
+ private async sendScopedSettings(client: ClientState, scope: string, agentName?: string): Promise<void> {
2493
+ const data = await this.requestChildPanel(client, scope, 'settings',
2494
+ agentName ? { agent: agentName } : {});
2495
+ if (data === null) return;
2496
+ this.send(client, { type: 'settings-state', scope, ...(data as object) } as WebUiServerMessage);
2497
+ }
2498
+
2499
+ /** Run a settings mutation in a fleet child. The op applies the change AND
2500
+ * returns the fresh state in one round trip; like the local path, the
2501
+ * result broadcasts to every welcomed client. */
2502
+ private async applyScopedSettingsMutation(
2503
+ client: ClientState,
2504
+ scope: string,
2505
+ op: 'settings-update' | 'settings-reset' | 'settings-cancel-transition',
2506
+ params: Record<string, unknown>,
2507
+ ): Promise<void> {
2508
+ const data = await this.requestChildPanel(client, scope, op, params);
2509
+ if (data === null) return;
2510
+ this.broadcastToWelcomed({ type: 'settings-state', scope, ...(data as object) } as WebUiServerMessage);
2967
2511
  }
2968
2512
 
2969
2513
  /** Fan out to every welcomed client — settings are live process state. */
2970
2514
  private broadcastSettingsState(agentName: string): void {
2971
- if (!sharedServer?.app) return;
2972
- const msg = this.buildSettingsState(agentName);
2515
+ const app = this.panelApp();
2516
+ if (!app) return;
2517
+ const msg = buildSettingsState(app, agentName);
2973
2518
  if (!msg) return;
2519
+ this.broadcastToWelcomed({ type: 'settings-state', scope: 'local', ...msg } as WebUiServerMessage);
2520
+ }
2521
+
2522
+ private broadcastToWelcomed(msg: WebUiServerMessage): void {
2523
+ if (!sharedServer) return;
2974
2524
  for (const c of sharedServer.clients.values()) {
2975
2525
  if (c.welcomed) this.send(c, msg);
2976
2526
  }
2977
2527
  }
2978
2528
 
2979
2529
  /**
2980
- * Opt-in push notice to the agent that an operator changed its context
2981
- * settings. OFF by default at the protocol level, because this injects text
2982
- * into the very context being tuned: it invalidates the KV prefix and is
2983
- * itself classifier-visible. The zero-cost alternative the agent always has
2984
- * is to PULL via its `agent_settings` tool.
2530
+ * Run one panel op in a fleet child and hand back its data, or send the
2531
+ * error to the requesting client and return null. The WS twin of
2532
+ * proxyPanelToChild corrId bookkeeping lives inside requestPanel, so
2533
+ * unlike routeFleetRequest there is no pendingFleetRequests entry.
2985
2534
  */
2986
- private notifyAgentOfSettingsChange(agentName: string, kind: 'update' | 'reset'): void {
2987
- try {
2988
- const app = sharedServer?.app;
2989
- if (!app) return;
2990
- const s = this.buildSettingsState(agentName);
2991
- const budget = s?.settings.contextBudgetTokens;
2992
- const tail = s?.settings.tailTokens;
2993
- const transition = s?.settings.transition;
2994
- const text = kind === 'reset'
2995
- ? `[operator] context settings reset to recipe defaults`
2996
- : `[operator] context settings changed`
2997
- + (budget !== undefined ? ` — budget ${budget}` : '')
2998
- + (tail !== undefined ? `, tail ${tail}` : '')
2999
- + (transition === 'converging' ? ' (converging gradually)' : '');
3000
- const cm = app.framework.getAgent(agentName)?.getContextManager();
3001
- cm?.addMessage('Context Manager', [{ type: 'text', text }], { system: true });
3002
- } catch (err) {
3003
- // A failed notice must never fail the apply that already succeeded.
3004
- console.warn('[settings] notify failed (change still applied):', err);
2535
+ private async requestChildPanel(
2536
+ client: ClientState,
2537
+ childName: string,
2538
+ op: string,
2539
+ params: Record<string, unknown>,
2540
+ ): Promise<unknown | null> {
2541
+ const fleet = this.fleetModule();
2542
+ if (!fleet) {
2543
+ this.send(client, { type: 'error', message: 'fleet module not loaded' });
2544
+ return null;
2545
+ }
2546
+ const result = await fleet.requestPanel(childName, op, params);
2547
+ if (!result.ok) {
2548
+ this.send(client, {
2549
+ type: 'error',
2550
+ message: `${op} on '${childName}' failed: ${result.error ?? 'unknown error'}`,
2551
+ });
2552
+ return null;
3005
2553
  }
2554
+ return result.data ?? {};
3006
2555
  }
3007
2556
 
3008
2557
  private broadcastBranchChanged(): void {
@@ -3273,10 +2822,12 @@ export class WebUiModule implements Module {
3273
2822
  return userOk && passOk;
3274
2823
  }
3275
2824
 
3276
- private unauthorized(): Response {
2825
+ private unauthorized(noStore = false): Response {
2826
+ const headers = new Headers({ 'www-authenticate': 'Basic realm="connectome-host"' });
2827
+ if (noStore) headers.set('cache-control', 'no-store');
3277
2828
  return new Response('Unauthorized', {
3278
2829
  status: 401,
3279
- headers: { 'www-authenticate': 'Basic realm="connectome-host"' },
2830
+ headers,
3280
2831
  });
3281
2832
  }
3282
2833
  }