@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
|
@@ -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
|
-
/**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
/**
|
|
248
|
-
|
|
249
|
-
|
|
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 =
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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 =
|
|
1122
|
+
const app = this.panelApp();
|
|
1185
1123
|
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
}
|
|
1189
|
-
|
|
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 =
|
|
1133
|
+
const app = this.panelApp();
|
|
1201
1134
|
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
return
|
|
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
|
-
*
|
|
1224
|
-
*
|
|
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 =
|
|
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
|
|
1346
|
-
|
|
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
|
|
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.
|
|
1413
|
-
*
|
|
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
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
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 =
|
|
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
|
|
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(
|
|
1454
|
-
{
|
|
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>]):
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
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 =
|
|
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
1579
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
1252
|
+
JSON.stringify(payload, null, 2),
|
|
1667
1253
|
{ headers: { 'content-type': 'application/json' } },
|
|
1668
1254
|
);
|
|
1669
|
-
} catch (
|
|
1670
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
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
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
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
|
-
|
|
2852
|
-
|
|
2427
|
+
private sendPinsList(client: ClientState, agentName?: string): void {
|
|
2428
|
+
const app = this.panelApp();
|
|
2429
|
+
if (!app) return;
|
|
2853
2430
|
try {
|
|
2854
|
-
const
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
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
|
-
/**
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
const
|
|
2893
|
-
|
|
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
|
-
*
|
|
2898
|
-
*
|
|
2899
|
-
*
|
|
2900
|
-
*/
|
|
2901
|
-
private
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
};
|
|
2908
|
-
if (
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
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
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
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
|
|
2938
|
-
|
|
2939
|
-
|
|
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
|
|
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
|
-
|
|
2972
|
-
|
|
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
|
-
*
|
|
2981
|
-
*
|
|
2982
|
-
*
|
|
2983
|
-
*
|
|
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
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
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
|
|
2830
|
+
headers,
|
|
3280
2831
|
});
|
|
3281
2832
|
}
|
|
3282
2833
|
}
|