@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.
- package/CHANGELOG.md +203 -10
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +22 -11
- package/docs/AGENT-ONBOARDING.md +20 -1
- package/docs/debug-context-api.md +2 -2
- package/docs/retrieval-traces.md +173 -0
- package/docs/webui-deployment.md +2 -1
- package/package.json +3 -3
- package/scripts/audit-module-optins.ts +288 -0
- package/scripts/warmup-session.ts +17 -3
- package/src/codex-subscription-adapter.ts +13 -1
- package/src/framework-agent-config.ts +59 -4
- package/src/framework-strategy.ts +33 -3
- package/src/headless.ts +14 -0
- package/src/index.ts +95 -35
- package/src/logging-adapter.ts +13 -2
- package/src/mcpl-config.ts +8 -0
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/identity-module.ts +274 -0
- package/src/modules/mcpl-admin-module.ts +78 -5
- package/src/modules/observers-module.ts +12 -0
- package/src/modules/retrieval-module.ts +254 -52
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/settings-module.ts +28 -2
- package/src/modules/subscription-gc-module.ts +54 -1
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +137 -12
- package/src/retrieval-config.ts +39 -0
- package/src/strategies/frontdesk-strategy.ts +34 -125
- package/src/tui.ts +325 -54
- package/src/web/panel-data.ts +1187 -0
- package/src/web/protocol.ts +75 -10
- package/test/audit-module-optins.test.ts +167 -0
- package/test/bedrock-prompt-caching.test.ts +170 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-strategy-defaults.test.ts +110 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/headless-panel-request.test.ts +201 -0
- package/test/identity-and-surfaces.test.ts +157 -0
- package/test/mcpl-admin-module.test.ts +23 -0
- package/test/mock-headless-child.ts +14 -0
- package/test/retrieval-auth-loopback.test.ts +49 -0
- package/test/retrieval-config.test.ts +74 -0
- package/test/retrieval-module.test.ts +821 -0
- package/test/subscription-gc-module.test.ts +152 -0
- package/test/tui-format.test.ts +106 -0
- package/test/web-ui-context-coverage.test.ts +1 -1
- package/test/web-ui-module.test.ts +189 -3
- package/test/web-ui-observers.test.ts +8 -5
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/bun.lock +345 -0
- package/web/src/App.tsx +159 -44
- package/web/src/Context.tsx +35 -8
- package/web/src/ContextDocument.tsx +20 -5
- package/web/src/Files.tsx +2 -8
- package/web/src/Lessons.tsx +2 -38
- package/web/src/Mcpl.tsx +80 -14
- package/web/src/Pins.tsx +5 -0
- package/web/src/Settings.tsx +5 -0
- 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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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
|
|
372
|
-
*
|
|
373
|
-
*
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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(() =>
|
|
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
|
|
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={
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/web/src/Context.tsx
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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)
|
|
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)
|
|
124
|
-
|
|
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
|
-
|
|
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=
|
|
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,
|
|
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: {
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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
|
|
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>
|