@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
package/web/src/App.tsx CHANGED
@@ -8,7 +8,7 @@ import { TreeSidebar } from './TreeSidebar';
8
8
  import { StreamPanel, formatStreamEvent, type StreamLine } from './Stream';
9
9
  import { UsagePanel } from './Usage';
10
10
  import { LessonsPanel, type LessonRow } from './Lessons';
11
- import { McplPanel, type McplServerRow } from './Mcpl';
11
+ import { McplPanel, type McplServerRow, type McplLiveRow } from './Mcpl';
12
12
  import { SettingsPanel, type SettingsState } from './Settings';
13
13
  import { DryContext, type DryContextData } from './DryContext';
14
14
  import { PinsPanel, type PinsState, type PinCandidate } from './Pins';
@@ -233,29 +233,49 @@ export function App() {
233
233
  };
234
234
 
235
235
  /** `force` retries past a remembered 401/403 — the operator's grant may
236
- * have gained the 'health' scope since the poll gave up. */
236
+ * have gained the 'health' scope since the poll gave up. Follows the
237
+ * inspection scope: a fleet child's /healthz is proxied over the panel
238
+ * IPC by the host. Alert reconciliation stays LOCAL-only — the strip is
239
+ * a host-level surface, and child agent names could collide with the
240
+ * parent's alert keys. */
237
241
  const loadHealth = async (force = false): Promise<void> => {
238
242
  if (healthDenied && !force) return;
239
243
  if (force) healthDenied = false;
244
+ const scope = panelScope();
240
245
  try {
241
- const res = await fetch('/healthz', { credentials: 'same-origin' });
246
+ const res = await fetch(`/healthz${scopeQuery()}`, { credentials: 'same-origin' });
242
247
  if (!res.ok) {
243
248
  if (res.status === 403 || res.status === 401) {
244
249
  healthDenied = true;
245
250
  setHealthErr('403');
246
251
  return;
247
252
  }
248
- throw new Error(`HTTP ${res.status}`);
253
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
254
+ throw new Error(body?.error ?? `HTTP ${res.status}`);
249
255
  }
250
256
  const h = (await res.json()) as HealthSnapshot;
257
+ if (scope !== panelScope()) return; // scope switched mid-flight — stale
251
258
  setHealth(h);
252
259
  setHealthErr(null);
253
- reconcileHealthAlerts(h);
260
+ if (scope === 'local') reconcileHealthAlerts(h);
254
261
  } catch (e) {
262
+ if (scope !== panelScope()) return;
255
263
  setHealthErr(e instanceof Error ? e.message : String(e));
256
264
  }
257
265
  };
258
266
 
267
+ /** Local-alert reconciliation when the panel scope is parked on a child:
268
+ * the strip's durable-state alerts (quarantine, hard-down) are host-level
269
+ * and must keep reconciling regardless of what the Health tab inspects. */
270
+ const reconcileLocalAlerts = async (): Promise<void> => {
271
+ if (healthDenied) return;
272
+ try {
273
+ const res = await fetch('/healthz', { credentials: 'same-origin' });
274
+ if (!res.ok) return; // auth handling lives on the loadHealth path
275
+ reconcileHealthAlerts((await res.json()) as HealthSnapshot);
276
+ } catch { /* transient — next tick retries */ }
277
+ };
278
+
259
279
  // ---------------------------------------------------------------------------
260
280
  // Branch panel — Chronicle lineage view, opened from the header branch chip.
261
281
  // ---------------------------------------------------------------------------
@@ -281,11 +301,15 @@ export function App() {
281
301
  /** Rendered dry-run context awaiting display. Never applied — see DryContext. */
282
302
  const [dryContext, setDryContext] = createSignal<DryContextData | null>(null);
283
303
 
284
- /** Scope shared by Lessons / Files / Recipe panels. 'local' means the
285
- * parent process; otherwise the fleet child's name. The scope is shared
286
- * so an operator can pin a child of interest and see all three views
287
- * without re-selecting per panel. */
304
+ /** THE inspection scope one selector for every operator panel (lessons,
305
+ * files, MCPL, context, settings, pins, health, recipe). 'local' means
306
+ * the parent process; otherwise the fleet child's name. Deliberately
307
+ * shared and sticky: an operator pins a child of interest once (the
308
+ * dropdown in the sidebar header) and every panel follows. */
288
309
  const [panelScope, setPanelScope] = createSignal<string>('local');
310
+ /** Query-string suffix routing scoped HTTP debug fetches to a child. */
311
+ const scopeQuery = (): string =>
312
+ panelScope() !== 'local' ? `?scope=${encodeURIComponent(panelScope())}` : '';
289
313
  const availableScopes = (): Array<{ id: string; label: string }> => {
290
314
  const w = welcome();
291
315
  const local = { id: 'local', label: w?.recipe.name ?? 'parent' };
@@ -307,13 +331,17 @@ export function App() {
307
331
  };
308
332
 
309
333
  /** MCPL panel state — populated by 'mcpl-list' responses, which the server
310
- * also re-sends after every mutation so the panel auto-refreshes. */
334
+ * also re-sends after every mutation so the panel auto-refreshes. The
335
+ * `live` list is the scoped process's actually-loaded servers (recipe
336
+ * opt-in + overlay) with connection status — per-scope truth the shared
337
+ * registry file can't express. */
311
338
  const [mcplServers, setMcplServers] = createSignal<McplServerRow[]>([]);
339
+ const [mcplLive, setMcplLive] = createSignal<McplLiveRow[]>([]);
312
340
  const [mcplLoaded, setMcplLoaded] = createSignal(false);
313
341
  const [mcplConfigPath, setMcplConfigPath] = createSignal('');
314
342
  const refreshMcpl = (): void => {
315
343
  setMcplLoaded(false);
316
- wire.send({ type: 'request-mcpl' });
344
+ wire.send({ type: 'request-mcpl', scope: panelScope() });
317
345
  };
318
346
 
319
347
  /** Context-settings panel state. The server BROADCASTS `settings-state` after
@@ -323,16 +351,19 @@ export function App() {
323
351
  const [settingsLoaded, setSettingsLoaded] = createSignal(false);
324
352
  const refreshSettings = (): void => {
325
353
  setSettingsLoaded(false);
326
- wire.send({ type: 'request-settings' });
354
+ wire.send({ type: 'request-settings', scope: panelScope() });
327
355
  };
328
356
 
329
357
  /** Pins panel state — broadcast on change, like settings: pins alter the next
330
- * compile's fold plan, so operators must not hold divergent views. */
358
+ * compile's fold plan, so operators must not hold divergent views. For
359
+ * fleet-child scopes the snapshot also carries picker `candidates` (real
360
+ * store ids from the child) — the local candidate list below only knows
361
+ * the parent's conversation. */
331
362
  const [pinsState, setPinsState] = createSignal<PinsState | null>(null);
332
363
  const [pinsLoaded, setPinsLoaded] = createSignal(false);
333
364
  const refreshPins = (): void => {
334
365
  setPinsLoaded(false);
335
- wire.send({ type: 'request-pins' });
366
+ wire.send({ type: 'request-pins', scope: panelScope() });
336
367
  };
337
368
 
338
369
  /** Workspace files panel state — mounts list + per-mount tree cache. */
@@ -368,9 +399,11 @@ export function App() {
368
399
  wire.send({ type: 'request-workspace-file', path, scope: panelScope() });
369
400
  };
370
401
 
371
- /** Switch the shared panel scope. Invalidates cached lessons/files so the
372
- * new scope's data is re-fetched on next access. MCPL config is global,
373
- * so it doesn't follow scope. */
402
+ /** Switch the inspection scope. Every per-scope cache is invalidated; the
403
+ * currently-visible tab re-fetches immediately and the rest lazy-load on
404
+ * next open. Context / ContextDocument re-fetch reactively off their
405
+ * `scope` prop, and the health poll picks the new scope up on its next
406
+ * tick (forced immediately when the Health tab is open). */
374
407
  const changePanelScope = (scope: string): void => {
375
408
  if (scope === panelScope()) return;
376
409
  setPanelScope(scope);
@@ -380,10 +413,24 @@ export function App() {
380
413
  setMounts([]);
381
414
  setTreesByMount(new Map<string, FlatEntry[]>());
382
415
  setExpandedMounts(new Set<string>());
383
- // Re-request whichever tab the operator is currently looking at; the
384
- // others will lazy-load when they're opened.
385
- if (sidebarTab() === 'lessons') refreshLessons();
386
- if (sidebarTab() === 'files') refreshMounts();
416
+ setOpenFile(null);
417
+ setFileLoading(false);
418
+ setMcplLoaded(false);
419
+ setMcplServers([]);
420
+ setMcplLive([]);
421
+ setSettingsLoaded(false);
422
+ setSettingsState(null);
423
+ setPinsLoaded(false);
424
+ setPinsState(null);
425
+ setHealth(null);
426
+ setHealthErr(null);
427
+ const tab = sidebarTab();
428
+ if (tab === 'lessons') refreshLessons();
429
+ if (tab === 'files') refreshMounts();
430
+ if (tab === 'mcp') refreshMcpl();
431
+ if (tab === 'settings') refreshSettings();
432
+ if (tab === 'pins') refreshPins();
433
+ if (tab === 'health') void loadHealth(true);
387
434
  };
388
435
  /** Pending-token buffer for the active stream; flushes on newline or non-token event. */
389
436
  let streamTokenBuffer = '';
@@ -544,6 +591,12 @@ export function App() {
544
591
  }
545
592
  setProtoMismatch(null);
546
593
  setWelcome(msg);
594
+ // A scope pinned to a child that no longer exists (stopped, crashed,
595
+ // session switch) would leave every panel dead-ended on a name the
596
+ // fleet no longer knows. Fall back to the host.
597
+ if (panelScope() !== 'local' && !msg.childTrees.some((c) => c.name === panelScope())) {
598
+ changePanelScope('local');
599
+ }
547
600
  const key = `${msg.session.id}/${msg.branch.id}`;
548
601
  const entries = msg.messages.map(entryToMessage);
549
602
 
@@ -890,15 +943,17 @@ export function App() {
890
943
  finishStream,
891
944
  queueScroll,
892
945
  openQuitConfirm: (children) => setQuitConfirm(children),
946
+ currentScope: panelScope,
893
947
  setLessons: (loaded, moduleLoaded, list) => {
894
948
  setLessonsLoaded(loaded);
895
949
  setLessonsModuleLoaded(moduleLoaded);
896
950
  setLessons(list);
897
951
  },
898
- setMcpl: (configPath, servers) => {
952
+ setMcpl: (configPath, servers, live) => {
899
953
  setMcplLoaded(true);
900
954
  setMcplConfigPath(configPath);
901
955
  setMcplServers(servers);
956
+ setMcplLive(live);
902
957
  },
903
958
  setSettings: (state) => {
904
959
  setSettingsLoaded(true);
@@ -946,9 +1001,14 @@ export function App() {
946
1001
 
947
1002
  // Health poll: durable-state alerts (quarantine, hard-down) must not
948
1003
  // depend on being connected when the klaxon fired. 15s keeps the strip
949
- // honest without meaningful load (counts-only JSON).
1004
+ // honest without meaningful load (counts-only JSON). With the scope on a
1005
+ // fleet child, loadHealth feeds the Health tab from that child and a
1006
+ // second local fetch keeps the alert strip reconciled.
950
1007
  void loadHealth();
951
- const healthTimer = window.setInterval(() => void loadHealth(), 15_000);
1008
+ const healthTimer = window.setInterval(() => {
1009
+ void loadHealth();
1010
+ if (panelScope() !== 'local') void reconcileLocalAlerts();
1011
+ }, 15_000);
952
1012
  onCleanup(() => window.clearInterval(healthTimer));
953
1013
  });
954
1014
 
@@ -1126,7 +1186,7 @@ export function App() {
1126
1186
  <MessageView msg={m} results={toolResults()} toolUseIds={toolUseIds()} />
1127
1187
  )}</For>
1128
1188
  </Show>}>
1129
- <ContextDocument agent={panelScope() === 'local' ? undefined : panelScope()} />
1189
+ <ContextDocument scope={panelScope()} />
1130
1190
  </Show>
1131
1191
  </div>
1132
1192
 
@@ -1218,6 +1278,11 @@ export function App() {
1218
1278
  if (tab === 'pins' && !pinsLoaded()) refreshPins();
1219
1279
  }}
1220
1280
  />
1281
+ <ScopeBar
1282
+ scopes={availableScopes()}
1283
+ scope={panelScope()}
1284
+ onChange={changePanelScope}
1285
+ />
1221
1286
  <div class="flex-1 min-h-0">
1222
1287
  <Show when={sidebarTab() === 'tree'}>
1223
1288
  <TreeSidebar
@@ -1237,9 +1302,6 @@ export function App() {
1237
1302
  loaded={lessonsLoaded()}
1238
1303
  moduleLoaded={lessonsModuleLoaded()}
1239
1304
  lessons={lessons()}
1240
- scope={panelScope()}
1241
- scopes={availableScopes()}
1242
- onScopeChange={changePanelScope}
1243
1305
  onRefresh={refreshLessons}
1244
1306
  />
1245
1307
  </Show>
@@ -1248,6 +1310,8 @@ export function App() {
1248
1310
  loaded={mcplLoaded()}
1249
1311
  configPath={mcplConfigPath()}
1250
1312
  servers={mcplServers()}
1313
+ live={mcplLive()}
1314
+ readOnly={panelScope() !== 'local'}
1251
1315
  onRefresh={refreshMcpl}
1252
1316
  onAdd={(input) => wire.send({ type: 'mcpl-add', ...input })}
1253
1317
  onRemove={(id) => wire.send({ type: 'mcpl-remove', id })}
@@ -1258,11 +1322,12 @@ export function App() {
1258
1322
  <SettingsPanel
1259
1323
  loaded={settingsLoaded()}
1260
1324
  state={settingsState()}
1325
+ scope={panelScope()}
1261
1326
  onRefresh={refreshSettings}
1262
- onApply={(patch) => wire.send({ type: 'settings-update', ...patch })}
1327
+ onApply={(patch) => wire.send({ type: 'settings-update', scope: panelScope(), ...patch })}
1263
1328
  onReset={(keys, persist) =>
1264
- wire.send({ type: 'settings-reset', ...(keys ? { keys } : {}), persist })}
1265
- onCancelTransition={() => wire.send({ type: 'settings-cancel-transition' })}
1329
+ wire.send({ type: 'settings-reset', scope: panelScope(), ...(keys ? { keys } : {}), persist })}
1330
+ onCancelTransition={() => wire.send({ type: 'settings-cancel-transition', scope: panelScope() })}
1266
1331
  onDryContext={(ctx) => { setDryContext(ctx as DryContextData); setMainView('dry'); }}
1267
1332
  />
1268
1333
  </Show>
@@ -1271,14 +1336,16 @@ export function App() {
1271
1336
  loaded={pinsLoaded()}
1272
1337
  state={pinsState()}
1273
1338
  agent={pinsState()?.agent}
1274
- candidates={messages
1275
- .filter((m) => m.index !== undefined && m.id)
1276
- .map<PinCandidate>((m) => ({
1277
- id: m.id, index: m.index!, participant: m.participant, text: m.text ?? '',
1278
- }))}
1339
+ candidates={panelScope() === 'local'
1340
+ ? messages
1341
+ .filter((m) => m.index !== undefined && m.id)
1342
+ .map<PinCandidate>((m) => ({
1343
+ id: m.id, index: m.index!, participant: m.participant, text: m.text ?? '',
1344
+ }))
1345
+ : pinsState()?.candidates ?? []}
1279
1346
  onRefresh={refreshPins}
1280
- onAdd={(input) => wire.send({ type: 'pin-add', ...input })}
1281
- onRemove={(pinId) => wire.send({ type: 'pin-remove', pinId })}
1347
+ onAdd={(input) => wire.send({ type: 'pin-add', scope: panelScope(), ...input })}
1348
+ onRemove={(pinId) => wire.send({ type: 'pin-remove', scope: panelScope(), pinId })}
1282
1349
  />
1283
1350
  </Show>
1284
1351
  <Show when={sidebarTab() === 'files'}>
@@ -1288,9 +1355,6 @@ export function App() {
1288
1355
  mounts={mounts()}
1289
1356
  treesByMount={treesByMount()}
1290
1357
  expandedMounts={expandedMounts()}
1291
- scope={panelScope()}
1292
- scopes={availableScopes()}
1293
- onScopeChange={changePanelScope}
1294
1358
  onRefreshMounts={refreshMounts}
1295
1359
  onExpandMount={expandMount}
1296
1360
  onCollapseMount={collapseMount}
@@ -1298,7 +1362,7 @@ export function App() {
1298
1362
  />
1299
1363
  </Show>
1300
1364
  <Show when={sidebarTab() === 'context'}>
1301
- <ContextPanel agent={panelScope() === 'local' ? undefined : panelScope()} />
1365
+ <ContextPanel scope={panelScope()} />
1302
1366
  </Show>
1303
1367
  <Show when={sidebarTab() === 'health'}>
1304
1368
  <HealthPanel
@@ -1348,10 +1412,14 @@ interface HandlerHooks {
1348
1412
  queueScroll: () => void;
1349
1413
  /** Show the quit-confirm modal with the given list of running children. */
1350
1414
  openQuitConfirm: (children: string[]) => void;
1415
+ /** The live inspection scope — scoped responses that don't match it are
1416
+ * dropped as stale (a slow child reply must not render under another
1417
+ * scope's header). Responses without a scope stamp (older host) pass. */
1418
+ currentScope: () => string;
1351
1419
  /** Apply a lessons-list response from the server. */
1352
1420
  setLessons: (loaded: boolean, moduleLoaded: boolean, lessons: LessonRow[]) => void;
1353
1421
  /** Apply an mcpl-list response from the server. */
1354
- setMcpl: (configPath: string, servers: McplServerRow[]) => void;
1422
+ setMcpl: (configPath: string, servers: McplServerRow[], live: McplLiveRow[]) => void;
1355
1423
  /** Apply a settings-state broadcast. */
1356
1424
  setSettings: (state: SettingsState) => void;
1357
1425
  /** Apply a pins-list broadcast. */
@@ -1370,6 +1438,12 @@ interface HandlerHooks {
1370
1438
  onBranchChanged: (branch: { id: string; name: string }) => void;
1371
1439
  }
1372
1440
 
1441
+ /** True when a scope-stamped response belongs to a scope the operator has
1442
+ * already navigated away from. Unstamped responses (older host) pass. */
1443
+ function staleScope(msgScope: string | undefined, current: string): boolean {
1444
+ return msgScope !== undefined && msgScope !== current;
1445
+ }
1446
+
1373
1447
  function handleServerMessage(
1374
1448
  msg: WebUiServerMessage,
1375
1449
  _wire: WireClient,
@@ -1481,24 +1555,31 @@ function handleServerMessage(
1481
1555
  hooks.openQuitConfirm(msg.children);
1482
1556
  return;
1483
1557
  case 'lessons-list':
1558
+ if (staleScope(msg.scope, hooks.currentScope())) return;
1484
1559
  hooks.setLessons(true, msg.loaded, msg.lessons);
1485
1560
  return;
1486
1561
  case 'mcpl-list':
1487
- hooks.setMcpl(msg.configPath, msg.servers);
1562
+ if (staleScope(msg.scope, hooks.currentScope())) return;
1563
+ hooks.setMcpl(msg.configPath, msg.servers, msg.live ?? []);
1488
1564
  return;
1489
1565
  case 'settings-state':
1566
+ if (staleScope(msg.scope, hooks.currentScope())) return;
1490
1567
  hooks.setSettings(msg as unknown as SettingsState);
1491
1568
  return;
1492
1569
  case 'pins-list':
1570
+ if (staleScope(msg.scope, hooks.currentScope())) return;
1493
1571
  hooks.setPins(msg as unknown as PinsState);
1494
1572
  return;
1495
1573
  case 'workspace-mounts':
1574
+ if (staleScope(msg.scope, hooks.currentScope())) return;
1496
1575
  hooks.setMounts(true, msg.loaded, msg.mounts);
1497
1576
  return;
1498
1577
  case 'workspace-tree':
1578
+ if (staleScope(msg.scope, hooks.currentScope())) return;
1499
1579
  hooks.setMountTree(msg.mount, msg.entries);
1500
1580
  return;
1501
1581
  case 'workspace-file':
1582
+ if (staleScope(msg.scope, hooks.currentScope())) return;
1502
1583
  hooks.setOpenFile(msg);
1503
1584
  return;
1504
1585
  case 'error':
@@ -2058,6 +2139,40 @@ function SidebarTabs(props: {
2058
2139
  );
2059
2140
  }
2060
2141
 
2142
+ /**
2143
+ * The one fleet-scope selector — a persistent dropdown under the sidebar
2144
+ * tabs that every inspection panel (lessons, files, MCPL, context, settings,
2145
+ * pins, health, recipe) follows. A dropdown rather than pill rows so the
2146
+ * control's footprint is independent of fleet size, and it stays visible on
2147
+ * every tab instead of being re-invented per panel. Hidden entirely when no
2148
+ * fleet children exist — single-process mode has nothing to select.
2149
+ */
2150
+ function ScopeBar(props: {
2151
+ scopes: Array<{ id: string; label: string }>;
2152
+ scope: string;
2153
+ onChange: (scope: string) => void;
2154
+ }) {
2155
+ return (
2156
+ <Show when={props.scopes.length > 1}>
2157
+ <div class="flex items-center gap-2 px-3 py-1.5 border-b border-neutral-800 bg-neutral-900/20 font-mono">
2158
+ <span class="text-neutral-600 uppercase tracking-wider text-[10px] shrink-0" title="Which process the panels below inspect — the host or a fleet child">
2159
+ inspecting
2160
+ </span>
2161
+ <select
2162
+ class="flex-1 min-w-0 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
2163
+ text-[11px] text-neutral-100 focus:outline-none focus:ring-1 focus:ring-cyan-700"
2164
+ value={props.scope}
2165
+ onChange={(e) => props.onChange(e.currentTarget.value)}
2166
+ >
2167
+ <For each={props.scopes}>{(s) => (
2168
+ <option value={s.id} selected={s.id === props.scope}>{s.label}</option>
2169
+ )}</For>
2170
+ </select>
2171
+ </div>
2172
+ </Show>
2173
+ );
2174
+ }
2175
+
2061
2176
  function QuitConfirmModal(props: {
2062
2177
  childNames: string[];
2063
2178
  onAction: (action: 'kill-children' | 'detach' | 'cancel') => void;
@@ -8,7 +8,7 @@
8
8
  * a per-segment table, with an exact total token count.
9
9
  */
10
10
 
11
- import { createSignal, onCleanup, onMount, For, Show } from 'solid-js';
11
+ import { createEffect, createSignal, on, onCleanup, onMount, For, Show } from 'solid-js';
12
12
 
13
13
  interface Seg { messages: number; tokens: number }
14
14
  interface Stats {
@@ -89,7 +89,7 @@ function rowsOf(s: Stats): Row[] {
89
89
  ].filter((r) => r.tokens > 0 || r.messages > 0);
90
90
  }
91
91
 
92
- export function ContextPanel(props: { agent?: string }) {
92
+ export function ContextPanel(props: { scope?: string }) {
93
93
  const [data, setData] = createSignal<Makeup | null>(null);
94
94
  const [coverage, setCoverage] = createSignal<Coverage | null>(null);
95
95
  const [loading, setLoading] = createSignal(false);
@@ -97,18 +97,28 @@ export function ContextPanel(props: { agent?: string }) {
97
97
  const [err, setErr] = createSignal<string | null>(null);
98
98
  const [coverageErr, setCoverageErr] = createSignal<string | null>(null);
99
99
 
100
- const query = () => props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
100
+ /** Fleet-child scopes route through the host's ?scope= proxy; the child's
101
+ * primary agent answers. Local scope needs no params. */
102
+ const query = () => props.scope && props.scope !== 'local'
103
+ ? `?scope=${encodeURIComponent(props.scope)}`
104
+ : '';
101
105
 
102
106
  const load = async () => {
107
+ const scopeAtStart = props.scope;
103
108
  setLoading(true);
104
109
  setErr(null);
105
110
  try {
106
111
  const res = await fetch(`/debug/context/makeup${query()}`, { credentials: 'same-origin' });
107
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
112
+ if (!res.ok) {
113
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
114
+ throw new Error(body?.error ?? `HTTP ${res.status}`);
115
+ }
108
116
  const j = (await res.json()) as Makeup;
109
117
  if (j.error) throw new Error(j.error);
118
+ if (props.scope !== scopeAtStart) return; // scope switched mid-flight
110
119
  setData(j);
111
120
  } catch (e) {
121
+ if (props.scope !== scopeAtStart) return;
112
122
  setErr(e instanceof Error ? e.message : String(e));
113
123
  } finally {
114
124
  setLoading(false);
@@ -116,13 +126,20 @@ export function ContextPanel(props: { agent?: string }) {
116
126
  };
117
127
 
118
128
  const loadCoverage = async () => {
129
+ const scopeAtStart = props.scope;
119
130
  setCoverageLoading(true);
120
131
  setCoverageErr(null);
121
132
  try {
122
133
  const res = await fetch(`/debug/context/coverage${query()}`, { credentials: 'same-origin' });
123
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
124
- setCoverage((await res.json()) as Coverage);
134
+ if (!res.ok) {
135
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
136
+ throw new Error(body?.error ?? `HTTP ${res.status}`);
137
+ }
138
+ const cov = (await res.json()) as Coverage;
139
+ if (props.scope !== scopeAtStart) return;
140
+ setCoverage(cov);
125
141
  } catch (e) {
142
+ if (props.scope !== scopeAtStart) return;
126
143
  setCoverageErr(e instanceof Error ? e.message : String(e));
127
144
  } finally {
128
145
  setCoverageLoading(false);
@@ -134,8 +151,18 @@ export function ContextPanel(props: { agent?: string }) {
134
151
  void loadCoverage();
135
152
  };
136
153
 
137
- onMount(() => {
154
+ // Initial load AND scope-switch refetch: the effect re-runs whenever the
155
+ // shared scope selector changes, clearing the previous scope's data so a
156
+ // slow child never renders under the wrong header.
157
+ createEffect(on(() => props.scope, () => {
158
+ setData(null);
159
+ setCoverage(null);
160
+ setErr(null);
161
+ setCoverageErr(null);
138
162
  refreshAll();
163
+ }));
164
+
165
+ onMount(() => {
139
166
  const timer = window.setInterval(() => void loadCoverage(), 5_000);
140
167
  onCleanup(() => window.clearInterval(timer));
141
168
  });
@@ -153,7 +180,7 @@ export function ContextPanel(props: { agent?: string }) {
153
180
  <div class="flex items-center gap-2">
154
181
  <a
155
182
  class="text-cyan-500 hover:text-cyan-300"
156
- href="/curve"
183
+ href={`/curve${query()}`}
157
184
  target="_blank"
158
185
  rel="noreferrer"
159
186
  title="Compression curve — raw vs rendered tokens per compiled entry"
@@ -11,7 +11,7 @@
11
11
  * the middle are styled distinctly.
12
12
  */
13
13
 
14
- import { createSignal, onMount, For, Show } from 'solid-js';
14
+ import { createEffect, createSignal, on, For, Show } from 'solid-js';
15
15
 
16
16
  interface Msg { participant?: string; role?: string; content: unknown }
17
17
  interface Seg { messages: number; tokens: number }
@@ -30,30 +30,45 @@ function textOf(c: unknown): string {
30
30
  return String(c ?? '');
31
31
  }
32
32
 
33
- export function ContextDocument(props: { agent?: string; scrollRoot?: () => HTMLElement | undefined }) {
33
+ export function ContextDocument(props: { scope?: string; scrollRoot?: () => HTMLElement | undefined }) {
34
34
  const [msgs, setMsgs] = createSignal<Msg[]>([]);
35
35
  const [stats, setStats] = createSignal<Stats | null>(null);
36
36
  const [exact, setExact] = createSignal<number | null>(null);
37
37
  const [err, setErr] = createSignal<string | null>(null);
38
38
  const [loading, setLoading] = createSignal(false);
39
39
 
40
+ /** Fleet-child scopes route through the host's ?scope= proxy. */
41
+ const query = () => props.scope && props.scope !== 'local'
42
+ ? `?scope=${encodeURIComponent(props.scope)}`
43
+ : '';
44
+
40
45
  const load = async () => {
46
+ const scopeAtStart = props.scope;
41
47
  setLoading(true); setErr(null);
42
48
  try {
43
- const q = props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
49
+ const q = query();
44
50
  const [ctxRes, mkRes] = await Promise.all([
45
51
  fetch(`/debug/context${q}`, { credentials: 'same-origin' }),
46
52
  fetch(`/debug/context/makeup${q}`, { credentials: 'same-origin' }),
47
53
  ]);
48
- if (!ctxRes.ok) throw new Error(`context HTTP ${ctxRes.status}`);
54
+ if (!ctxRes.ok) {
55
+ const body = (await ctxRes.json().catch(() => null)) as { error?: string } | null;
56
+ throw new Error(body?.error ?? `context HTTP ${ctxRes.status}`);
57
+ }
49
58
  const ctx = await ctxRes.json();
59
+ if (props.scope !== scopeAtStart) return; // scope switched mid-flight
50
60
  setMsgs((ctx?.request?.messages ?? []) as Msg[]);
51
61
  if (mkRes.ok) { const mk = await mkRes.json(); setStats(mk.stats); setExact(mk.exactTotalTokens); }
52
62
  } catch (e) {
63
+ if (props.scope !== scopeAtStart) return;
53
64
  setErr(e instanceof Error ? e.message : String(e));
54
65
  } finally { setLoading(false); }
55
66
  };
56
- onMount(load);
67
+ // Initial load AND scope-switch refetch, clearing the stale document first.
68
+ createEffect(on(() => props.scope, () => {
69
+ setMsgs([]); setStats(null); setExact(null); setErr(null);
70
+ void load();
71
+ }));
57
72
 
58
73
  // Zone boundaries from the makeup counts (render order: head | middle | tail).
59
74
  const headN = () => stats()?.head.messages ?? 0;
package/web/src/Files.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Files panel — workspace-aware browser for the parent process's mounts.
2
+ * Files panel — workspace-aware browser for one process's mounts (the host
3
+ * or a fleet child; the sidebar's shared scope selector decides which).
3
4
  *
4
5
  * Lists mounts, expands one at a time on click, builds a hierarchical tree
5
6
  * from the flat workspace `ls -r` output, and lets the operator click a
@@ -10,7 +11,6 @@
10
11
  */
11
12
 
12
13
  import { createSignal, For, Show } from 'solid-js';
13
- import { ScopePicker } from './Lessons';
14
14
 
15
15
  export interface Mount {
16
16
  name: string;
@@ -41,11 +41,6 @@ export function FilesPanel(props: {
41
41
  treesByMount: Map<string, FlatEntry[]>;
42
42
  /** Mounts that have been expanded at least once. */
43
43
  expandedMounts: Set<string>;
44
- /** Currently-selected scope ('local' or fleet child name). */
45
- scope: string;
46
- /** Selectable scopes — always includes 'local', plus every fleet child. */
47
- scopes: Array<{ id: string; label: string }>;
48
- onScopeChange(scope: string): void;
49
44
  onRefreshMounts(): void;
50
45
  onExpandMount(name: string): void;
51
46
  onCollapseMount(name: string): void;
@@ -66,7 +61,6 @@ export function FilesPanel(props: {
66
61
  refresh
67
62
  </button>
68
63
  </div>
69
- <ScopePicker scope={props.scope} scopes={props.scopes} onChange={props.onScopeChange} />
70
64
 
71
65
  <Show when={!props.loaded}>
72
66
  <div class="text-neutral-600 italic">Loading…</div>